0.5.1-pre.002
This commit is contained in:
1984
ks-pipeline/src/backfill.rs
Normal file
1984
ks-pipeline/src/backfill.rs
Normal file
File diff suppressed because it is too large
Load Diff
7
ks-pipeline/src/constants.rs
Normal file
7
ks-pipeline/src/constants.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// file: ks-pipeline/src/constants.rs
|
||||
// version: 3
|
||||
|
||||
//! Pipeline constants.
|
||||
|
||||
/// Canonical tracing target for pipeline orchestration.
|
||||
pub(crate) const TRACING_TARGET: &str = "ks-pipeline";
|
||||
1845
ks-pipeline/src/core_extraction.rs
Normal file
1845
ks-pipeline/src/core_extraction.rs
Normal file
File diff suppressed because it is too large
Load Diff
2262
ks-pipeline/src/decode_replay.rs
Normal file
2262
ks-pipeline/src/decode_replay.rs
Normal file
File diff suppressed because it is too large
Load Diff
321
ks-pipeline/src/lib.rs
Normal file
321
ks-pipeline/src/lib.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
// file: ks-pipeline/src/lib.rs
|
||||
// version: 22
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
//! Pipeline orchestration boundary.
|
||||
|
||||
mod backfill;
|
||||
mod constants;
|
||||
mod core_extraction;
|
||||
mod decode_replay;
|
||||
mod metadata_metaplex_token_metadata_execution_orchestration;
|
||||
mod metadata_metaplex_token_metadata_preflight;
|
||||
mod metadata_metaplex_token_metadata_stateful;
|
||||
mod metadata_solana_program_execution_orchestration;
|
||||
mod metadata_solana_program_preflight;
|
||||
mod metadata_solana_program_stateful;
|
||||
mod plan;
|
||||
mod solana_stateful;
|
||||
mod spl_ata_stateful;
|
||||
mod spl_elgamal_registry_stateful;
|
||||
mod spl_token_2022_correlation;
|
||||
mod spl_token_2022_crypto_preflight;
|
||||
mod spl_token_2022_execution_orchestration;
|
||||
mod spl_token_2022_metadata;
|
||||
mod spl_token_2022_preflight;
|
||||
mod spl_token_2022_proof_orchestration;
|
||||
mod spl_token_2022_stateful;
|
||||
mod spl_token_stateful;
|
||||
|
||||
/// Address category used by one targeted backfill campaign.
|
||||
pub use self::backfill::BackfillAddressKind;
|
||||
/// Chronological direction relative to one anchor signature.
|
||||
pub use self::backfill::BackfillDirection;
|
||||
/// Observer notified during HTTP backfill campaigns.
|
||||
pub use self::backfill::BackfillObserver;
|
||||
/// Progress event emitted by HTTP backfill campaigns.
|
||||
pub use self::backfill::BackfillProgressEvent;
|
||||
/// Severity of one HTTP backfill progress event.
|
||||
pub use self::backfill::BackfillProgressLevel;
|
||||
/// Complete bounded HTTP backfill request.
|
||||
pub use self::backfill::BackfillRequest;
|
||||
/// Candidate source used by one HTTP backfill campaign.
|
||||
pub use self::backfill::BackfillSource;
|
||||
/// Summary returned by one HTTP backfill campaign.
|
||||
pub use self::backfill::BackfillSummary;
|
||||
/// Executes one bounded HTTP transaction backfill campaign.
|
||||
pub use self::backfill::execute_http_backfill;
|
||||
/// Stable core extraction processor name.
|
||||
pub use self::core_extraction::CORE_EXTRACTION_PROCESSOR_NAME;
|
||||
/// Stable core extraction processor version.
|
||||
pub use self::core_extraction::CORE_EXTRACTION_PROCESSOR_VERSION;
|
||||
/// Stable core extraction stage code.
|
||||
pub use self::core_extraction::CORE_EXTRACTION_STAGE;
|
||||
/// Observer notified during core extraction campaigns.
|
||||
pub use self::core_extraction::CoreExtractionObserver;
|
||||
/// Progress event emitted by core extraction campaigns.
|
||||
pub use self::core_extraction::CoreExtractionProgressEvent;
|
||||
/// Severity of one core extraction progress event.
|
||||
pub use self::core_extraction::CoreExtractionProgressLevel;
|
||||
/// Bounded core extraction request.
|
||||
pub use self::core_extraction::CoreExtractionRequest;
|
||||
/// Source selection for a core extraction campaign.
|
||||
pub use self::core_extraction::CoreExtractionSource;
|
||||
/// Summary returned by a core extraction campaign.
|
||||
pub use self::core_extraction::CoreExtractionSummary;
|
||||
/// Executes one bounded core extraction campaign.
|
||||
pub use self::core_extraction::execute_core_extraction;
|
||||
/// Extracts one canonical raw transaction into normalized core rows.
|
||||
pub use self::core_extraction::extract_raw_transaction_to_core;
|
||||
/// Current common decode pipeline orchestration version.
|
||||
pub use self::decode_replay::DECODE_PIPELINE_VERSION;
|
||||
/// Deterministic decoder dispatch policy.
|
||||
pub use self::decode_replay::DecodeDispatchPolicy;
|
||||
/// Per-decoder terminal counters.
|
||||
pub use self::decode_replay::DecodeProcessorSummary;
|
||||
/// Decode replay observer contract.
|
||||
pub use self::decode_replay::DecodeReplayObserver;
|
||||
/// Decode replay progress event.
|
||||
pub use self::decode_replay::DecodeReplayProgressEvent;
|
||||
/// Decode replay progress severity.
|
||||
pub use self::decode_replay::DecodeReplayProgressLevel;
|
||||
/// Bounded contextual decode replay request.
|
||||
pub use self::decode_replay::DecodeReplayRequest;
|
||||
/// Final contextual decode replay summary.
|
||||
pub use self::decode_replay::DecodeReplaySummary;
|
||||
/// Stable materialization processing stage.
|
||||
pub use self::decode_replay::EVENT_MATERIALIZATION_STAGE;
|
||||
/// Stable instruction decode processing stage.
|
||||
pub use self::decode_replay::INSTRUCTION_DECODE_STAGE;
|
||||
/// Executes one contextual decode replay campaign.
|
||||
pub use self::decode_replay::execute_decode_replay;
|
||||
/// Creates one stable process-local decode campaign identifier.
|
||||
pub use self::decode_replay::new_decode_campaign_id;
|
||||
/// Maximum number of resolved signers accepted by one Metaplex execution envelope.
|
||||
pub use self::metadata_metaplex_token_metadata_execution_orchestration::MAX_METAPLEX_TOKEN_METADATA_EXECUTION_SIGNERS;
|
||||
/// Deterministic Metaplex execution-readiness report.
|
||||
pub use self::metadata_metaplex_token_metadata_execution_orchestration::MetaplexTokenMetadataExecutionReadinessReport;
|
||||
/// Complete Metaplex execution-readiness request.
|
||||
pub use self::metadata_metaplex_token_metadata_execution_orchestration::MetaplexTokenMetadataExecutionReadinessRequest;
|
||||
/// Result of one Metaplex stateful postcondition.
|
||||
pub use self::metadata_metaplex_token_metadata_execution_orchestration::MetaplexTokenMetadataPostconditionStatus;
|
||||
/// Aggregates Metaplex postconditions without inventing success.
|
||||
pub use self::metadata_metaplex_token_metadata_execution_orchestration::summarize_metaplex_token_metadata_postconditions;
|
||||
/// Validates the complete Metaplex execution envelope before signing.
|
||||
pub use self::metadata_metaplex_token_metadata_execution_orchestration::validate_metaplex_token_metadata_execution_readiness;
|
||||
/// Maximum number of correlated Metaplex snapshots accepted by one preflight.
|
||||
pub use self::metadata_metaplex_token_metadata_preflight::MAX_METAPLEX_TOKEN_METADATA_PREFLIGHT_ACCOUNTS;
|
||||
/// Deterministic Metaplex preflight report.
|
||||
pub use self::metadata_metaplex_token_metadata_preflight::MetaplexTokenMetadataPreflightReport;
|
||||
/// Complete Metaplex preflight request.
|
||||
pub use self::metadata_metaplex_token_metadata_preflight::MetaplexTokenMetadataPreflightRequest;
|
||||
/// Inspects one prepared Metaplex plan against confirmed state snapshots.
|
||||
pub use self::metadata_metaplex_token_metadata_preflight::inspect_metaplex_token_metadata_preflight;
|
||||
/// Maximum complete Metaplex account data accepted by one RPC read.
|
||||
pub use self::metadata_metaplex_token_metadata_stateful::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES;
|
||||
/// Supported Metaplex account category for one stateful read.
|
||||
pub use self::metadata_metaplex_token_metadata_stateful::MetaplexTokenMetadataAccountKind;
|
||||
/// One bounded Metaplex stateful read request.
|
||||
pub use self::metadata_metaplex_token_metadata_stateful::MetaplexTokenMetadataStatefulReadRequest;
|
||||
/// One bounded Metaplex stateful read result.
|
||||
pub use self::metadata_metaplex_token_metadata_stateful::MetaplexTokenMetadataStatefulReadResult;
|
||||
/// Canonical bounded Metaplex state snapshot.
|
||||
pub use self::metadata_metaplex_token_metadata_stateful::MetaplexTokenMetadataStatefulSnapshot;
|
||||
/// Validates one RPC account response and delegates parsing to `ks-lib`.
|
||||
pub use self::metadata_metaplex_token_metadata_stateful::materialize_metaplex_token_metadata_account_info_result;
|
||||
/// Reads one bounded Metaplex Token Metadata account.
|
||||
pub use self::metadata_metaplex_token_metadata_stateful::read_metaplex_token_metadata_stateful_snapshot;
|
||||
/// Maximum number of resolved signers accepted by one Program Metadata execution envelope.
|
||||
pub use self::metadata_solana_program_execution_orchestration::MAX_SOLANA_PROGRAM_METADATA_EXECUTION_SIGNERS;
|
||||
/// Complete Program Metadata execution-readiness report.
|
||||
pub use self::metadata_solana_program_execution_orchestration::SolanaProgramMetadataExecutionReadinessReport;
|
||||
/// Complete Program Metadata execution-readiness request.
|
||||
pub use self::metadata_solana_program_execution_orchestration::SolanaProgramMetadataExecutionReadinessRequest;
|
||||
/// Complete Program Metadata post-execution report.
|
||||
pub use self::metadata_solana_program_execution_orchestration::SolanaProgramMetadataPostExecutionReport;
|
||||
/// Complete Program Metadata post-execution request.
|
||||
pub use self::metadata_solana_program_execution_orchestration::SolanaProgramMetadataPostExecutionRequest;
|
||||
/// One Program Metadata postcondition.
|
||||
pub use self::metadata_solana_program_execution_orchestration::SolanaProgramMetadataPostcondition;
|
||||
/// Program Metadata postcondition status.
|
||||
pub use self::metadata_solana_program_execution_orchestration::SolanaProgramMetadataPostconditionStatus;
|
||||
/// Inspects confirmed Program Metadata post-execution state.
|
||||
pub use self::metadata_solana_program_execution_orchestration::inspect_solana_program_metadata_post_execution;
|
||||
/// Aggregates Program Metadata postconditions without invented success.
|
||||
pub use self::metadata_solana_program_execution_orchestration::summarize_solana_program_metadata_postconditions;
|
||||
/// Validates the complete Program Metadata execution envelope before signing.
|
||||
pub use self::metadata_solana_program_execution_orchestration::validate_solana_program_metadata_execution_readiness;
|
||||
/// Maximum number of bounded account snapshots accepted by one Program Metadata preflight.
|
||||
pub use self::metadata_solana_program_preflight::MAX_SOLANA_PROGRAM_METADATA_PREFLIGHT_ACCOUNTS;
|
||||
/// Deterministic Program Metadata preflight report.
|
||||
pub use self::metadata_solana_program_preflight::SolanaProgramMetadataPreflightReport;
|
||||
/// Complete Program Metadata preflight request.
|
||||
pub use self::metadata_solana_program_preflight::SolanaProgramMetadataPreflightRequest;
|
||||
/// One Program Metadata rent-exemption observation.
|
||||
pub use self::metadata_solana_program_preflight::SolanaProgramMetadataRentObservation;
|
||||
/// Inspects one Program Metadata plan against confirmed state.
|
||||
pub use self::metadata_solana_program_preflight::inspect_solana_program_metadata_preflight;
|
||||
/// Maximum complete Program Metadata account data accepted by one RPC read.
|
||||
pub use self::metadata_solana_program_stateful::MAX_SOLANA_PROGRAM_METADATA_STATEFUL_ACCOUNT_BYTES;
|
||||
/// Expected Program Metadata account state.
|
||||
pub use self::metadata_solana_program_stateful::SolanaProgramMetadataExpectedAccountState;
|
||||
/// Observed Program Metadata account state.
|
||||
pub use self::metadata_solana_program_stateful::SolanaProgramMetadataObservedAccountState;
|
||||
/// One bounded Program Metadata stateful read request.
|
||||
pub use self::metadata_solana_program_stateful::SolanaProgramMetadataStatefulReadRequest;
|
||||
/// One bounded Program Metadata stateful read result.
|
||||
pub use self::metadata_solana_program_stateful::SolanaProgramMetadataStatefulReadResult;
|
||||
/// Validates one Program Metadata RPC response and materializes its account state.
|
||||
pub use self::metadata_solana_program_stateful::materialize_solana_program_metadata_account_info_result;
|
||||
/// Reads one bounded Program Metadata account.
|
||||
pub use self::metadata_solana_program_stateful::read_solana_program_metadata_stateful_snapshot;
|
||||
/// Pipeline stage identifier.
|
||||
pub use self::plan::PipelineStage;
|
||||
/// Replay selection scope.
|
||||
pub use self::plan::ReplayScope;
|
||||
/// One machine-readable native Solana stateful readiness check.
|
||||
pub use self::solana_stateful::SolanaCoreStatefulCheck;
|
||||
/// One contextual fact measured during native Solana stateful readiness.
|
||||
pub use self::solana_stateful::SolanaCoreStatefulFact;
|
||||
/// Native Solana stateful readiness report.
|
||||
pub use self::solana_stateful::SolanaCoreStatefulReadinessReport;
|
||||
/// Native Solana stateful readiness request.
|
||||
pub use self::solana_stateful::SolanaCoreStatefulReadinessRequest;
|
||||
/// Native Solana stateful readiness status.
|
||||
pub use self::solana_stateful::SolanaCoreStatefulReadinessStatus;
|
||||
/// Inspects state required before simulating one native Solana operation.
|
||||
pub use self::solana_stateful::inspect_solana_core_stateful_readiness;
|
||||
/// Stateful invariants observed after one confirmed Associated Token Account execution.
|
||||
pub use self::spl_ata_stateful::SplAssociatedTokenAccountPostExecutionReport;
|
||||
/// One machine-readable Associated Token Account stateful check.
|
||||
pub use self::spl_ata_stateful::SplAssociatedTokenAccountStatefulCheck;
|
||||
/// One contextual Associated Token Account stateful fact.
|
||||
pub use self::spl_ata_stateful::SplAssociatedTokenAccountStatefulFact;
|
||||
/// Complete Associated Token Account stateful readiness report.
|
||||
pub use self::spl_ata_stateful::SplAssociatedTokenAccountStatefulReadinessReport;
|
||||
/// Complete request for one Associated Token Account stateful readiness inspection.
|
||||
pub use self::spl_ata_stateful::SplAssociatedTokenAccountStatefulReadinessRequest;
|
||||
/// Associated Token Account stateful readiness outcome.
|
||||
pub use self::spl_ata_stateful::SplAssociatedTokenAccountStatefulReadinessStatus;
|
||||
/// Verifies final Associated Token Account relationships after confirmed execution.
|
||||
pub use self::spl_ata_stateful::inspect_spl_associated_token_account_post_execution;
|
||||
/// Inspects state required before simulating one Associated Token Account operation.
|
||||
pub use self::spl_ata_stateful::inspect_spl_associated_token_account_stateful_readiness;
|
||||
/// Migrated ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES contract.
|
||||
pub use self::spl_elgamal_registry_stateful::ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES;
|
||||
/// Migrated ElGamalRegistryStatefulReadRequest contract.
|
||||
pub use self::spl_elgamal_registry_stateful::ElGamalRegistryStatefulReadRequest;
|
||||
/// Migrated ElGamalRegistryStatefulReadResult contract.
|
||||
pub use self::spl_elgamal_registry_stateful::ElGamalRegistryStatefulReadResult;
|
||||
/// Migrated ElGamalRegistryStatefulSnapshot contract.
|
||||
pub use self::spl_elgamal_registry_stateful::ElGamalRegistryStatefulSnapshot;
|
||||
/// Migrated materialize_elgamal_registry_account_info_result contract.
|
||||
pub use self::spl_elgamal_registry_stateful::materialize_elgamal_registry_account_info_result;
|
||||
/// Migrated materialize_elgamal_registry_stateful_snapshot contract.
|
||||
pub use self::spl_elgamal_registry_stateful::materialize_elgamal_registry_stateful_snapshot;
|
||||
/// Migrated read_elgamal_registry_stateful_snapshot contract.
|
||||
pub use self::spl_elgamal_registry_stateful::read_elgamal_registry_stateful_snapshot;
|
||||
/// Correlation outcome between one committed instruction fact and one final state snapshot.
|
||||
pub use self::spl_token_2022_correlation::Token2022CorrelationStatus;
|
||||
/// Deterministic correlation report for one instruction output and one final snapshot.
|
||||
pub use self::spl_token_2022_correlation::Token2022StateCorrelationReport;
|
||||
/// Correlates one materialized instruction fact with one authoritative Token-2022 snapshot.
|
||||
pub use self::spl_token_2022_correlation::correlate_token_2022_instruction_with_snapshot;
|
||||
/// Maximum number of distinct proof context-state accounts accepted by one preflight.
|
||||
pub use self::spl_token_2022_crypto_preflight::MAX_TOKEN_2022_PROOF_CONTEXTS;
|
||||
/// Token-2022 cryptographic preflight report.
|
||||
pub use self::spl_token_2022_crypto_preflight::Token2022CryptographicPreflightReport;
|
||||
/// Token-2022 cryptographic preflight request.
|
||||
pub use self::spl_token_2022_crypto_preflight::Token2022CryptographicPreflightRequest;
|
||||
/// One validated proof context report.
|
||||
pub use self::spl_token_2022_crypto_preflight::Token2022ProofContextReport;
|
||||
/// One required proof context-state account.
|
||||
pub use self::spl_token_2022_crypto_preflight::Token2022ProofContextRequirement;
|
||||
/// Encoded proof context metadata bytes.
|
||||
pub use self::spl_token_2022_crypto_preflight::ZK_PROOF_CONTEXT_META_BYTES;
|
||||
/// Inspects bounded Token-2022 cryptographic proof contexts.
|
||||
pub use self::spl_token_2022_crypto_preflight::inspect_token_2022_cryptographic_preflight;
|
||||
/// Maximum number of distinct signers accepted by one Token-2022 execution envelope.
|
||||
pub use self::spl_token_2022_execution_orchestration::MAX_TOKEN_2022_EXECUTION_SIGNERS;
|
||||
/// One explicit stateful postcondition retained after Token-2022 execution.
|
||||
pub use self::spl_token_2022_execution_orchestration::Token2022ExecutionPostcondition;
|
||||
/// Result of one stateful postcondition after Token-2022 execution.
|
||||
pub use self::spl_token_2022_execution_orchestration::Token2022ExecutionPostconditionStatus;
|
||||
/// Deterministic Token-2022 execution-readiness report.
|
||||
pub use self::spl_token_2022_execution_orchestration::Token2022ExecutionReadinessReport;
|
||||
/// Complete deterministic Token-2022 execution-readiness request.
|
||||
pub use self::spl_token_2022_execution_orchestration::Token2022ExecutionReadinessRequest;
|
||||
/// Aggregates Token-2022 postconditions without inventing success.
|
||||
pub use self::spl_token_2022_execution_orchestration::summarize_token_2022_postconditions;
|
||||
/// Validates the complete Token-2022 execution envelope before signing.
|
||||
pub use self::spl_token_2022_execution_orchestration::validate_token_2022_execution_readiness;
|
||||
/// Maximum Token Metadata bytes accepted from Solana return data.
|
||||
pub use self::spl_token_2022_metadata::MAX_TOKEN_2022_METADATA_EMIT_BYTES;
|
||||
/// Bounded evidence extracted from one successful Token Metadata `Emit` simulation.
|
||||
pub use self::spl_token_2022_metadata::Token2022MetadataEmitEvidence;
|
||||
/// Checks the final embedded metadata authority against one authoritative snapshot.
|
||||
pub use self::spl_token_2022_metadata::inspect_token_2022_metadata_authority_postcondition;
|
||||
/// Extracts and validates Token Metadata return data from one exact simulation.
|
||||
pub use self::spl_token_2022_metadata::inspect_token_2022_metadata_emit_simulation;
|
||||
/// Validates one Token Metadata `Emit` range against Solana return-data bounds.
|
||||
pub use self::spl_token_2022_metadata::validate_token_2022_metadata_emit_range;
|
||||
/// Migrated MAX_TOKEN_2022_PREFLIGHT_ACCOUNTS contract.
|
||||
pub use self::spl_token_2022_preflight::MAX_TOKEN_2022_PREFLIGHT_ACCOUNTS;
|
||||
/// Migrated MAX_TOKEN_2022_PREFLIGHT_TOTAL_BYTES contract.
|
||||
pub use self::spl_token_2022_preflight::MAX_TOKEN_2022_PREFLIGHT_TOTAL_BYTES;
|
||||
/// Migrated Token2022PreflightAccountReport contract.
|
||||
pub use self::spl_token_2022_preflight::Token2022PreflightAccountReport;
|
||||
/// Migrated Token2022PreflightReport contract.
|
||||
pub use self::spl_token_2022_preflight::Token2022PreflightReport;
|
||||
/// Migrated Token2022PreflightRequest contract.
|
||||
pub use self::spl_token_2022_preflight::Token2022PreflightRequest;
|
||||
/// Migrated Token2022PreflightRequirement contract.
|
||||
pub use self::spl_token_2022_preflight::Token2022PreflightRequirement;
|
||||
/// Migrated inspect_token_2022_preflight contract.
|
||||
pub use self::spl_token_2022_preflight::inspect_token_2022_preflight;
|
||||
/// Maximum number of proofs accepted for one Token-2022 operation.
|
||||
pub use self::spl_token_2022_proof_orchestration::MAX_TOKEN_2022_OPERATION_PROOFS;
|
||||
/// Token-2022 proof orchestration report.
|
||||
pub use self::spl_token_2022_proof_orchestration::Token2022ProofOrchestrationReport;
|
||||
/// Token-2022 proof orchestration request.
|
||||
pub use self::spl_token_2022_proof_orchestration::Token2022ProofOrchestrationRequest;
|
||||
/// Validates mixed inline and context-state proof orchestration.
|
||||
pub use self::spl_token_2022_proof_orchestration::orchestrate_token_2022_proofs;
|
||||
/// Migrated MAX_TOKEN_2022_STATEFUL_ACCOUNT_BYTES contract.
|
||||
pub use self::spl_token_2022_stateful::MAX_TOKEN_2022_STATEFUL_ACCOUNT_BYTES;
|
||||
/// Migrated Token2022StatefulContext contract.
|
||||
pub use self::spl_token_2022_stateful::Token2022StatefulContext;
|
||||
/// Migrated Token2022StatefulReadRequest contract.
|
||||
pub use self::spl_token_2022_stateful::Token2022StatefulReadRequest;
|
||||
/// Migrated Token2022StatefulReadResult contract.
|
||||
pub use self::spl_token_2022_stateful::Token2022StatefulReadResult;
|
||||
/// Migrated Token2022StatefulSnapshotBundle contract.
|
||||
pub use self::spl_token_2022_stateful::Token2022StatefulSnapshotBundle;
|
||||
/// Migrated materialize_parsed_token_2022_stateful_snapshot contract.
|
||||
pub use self::spl_token_2022_stateful::materialize_parsed_token_2022_stateful_snapshot;
|
||||
/// Migrated materialize_parsed_token_2022_stateful_snapshot_with_context contract.
|
||||
pub use self::spl_token_2022_stateful::materialize_parsed_token_2022_stateful_snapshot_with_context;
|
||||
/// Migrated materialize_token_2022_account_info_result contract.
|
||||
pub use self::spl_token_2022_stateful::materialize_token_2022_account_info_result;
|
||||
/// Migrated materialize_token_2022_stateful_snapshot contract.
|
||||
pub use self::spl_token_2022_stateful::materialize_token_2022_stateful_snapshot;
|
||||
/// Migrated read_token_2022_stateful_snapshot contract.
|
||||
pub use self::spl_token_2022_stateful::read_token_2022_stateful_snapshot;
|
||||
/// One machine-readable classic SPL Token stateful check.
|
||||
pub use self::spl_token_stateful::SplTokenStatefulCheck;
|
||||
/// One contextual classic SPL Token stateful fact.
|
||||
pub use self::spl_token_stateful::SplTokenStatefulFact;
|
||||
/// Complete classic SPL Token stateful readiness report.
|
||||
pub use self::spl_token_stateful::SplTokenStatefulReadinessReport;
|
||||
/// Complete request for one classic SPL Token stateful readiness inspection.
|
||||
pub use self::spl_token_stateful::SplTokenStatefulReadinessRequest;
|
||||
/// Classic SPL Token stateful readiness outcome.
|
||||
pub use self::spl_token_stateful::SplTokenStatefulReadinessStatus;
|
||||
/// Inspects Localnet or Devnet classic SPL Token state required before simulation.
|
||||
pub use self::spl_token_stateful::inspect_spl_token_stateful_readiness;
|
||||
|
||||
/// Canonical tracing target for pipeline orchestration.
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
@@ -0,0 +1,240 @@
|
||||
// file: ks-pipeline/src/metadata_metaplex_token_metadata_execution_orchestration.rs
|
||||
// version: 4
|
||||
|
||||
//! Simulation-first Metaplex Token Metadata execution orchestration.
|
||||
|
||||
/// Maximum number of resolved signers accepted by one Metaplex execution envelope.
|
||||
pub const MAX_METAPLEX_TOKEN_METADATA_EXECUTION_SIGNERS: usize = 32;
|
||||
|
||||
/// Complete request checked before signing or submitting one Metaplex transaction.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MetaplexTokenMetadataExecutionReadinessRequest {
|
||||
/// Exact prepared execution plan.
|
||||
pub plan: ks_lib::ExApiPreparedExecutionPlan,
|
||||
/// Successful stateful preflight report.
|
||||
pub preflight: crate::MetaplexTokenMetadataPreflightReport,
|
||||
/// Exact compiled message hash.
|
||||
pub message_hash: std::string::String,
|
||||
/// Hash retained by simulation evidence.
|
||||
pub simulated_message_hash: std::string::String,
|
||||
/// Whether simulation was performed.
|
||||
pub simulated: bool,
|
||||
/// Whether simulation succeeded.
|
||||
pub simulation_succeeded: bool,
|
||||
/// Public keys available to sign.
|
||||
pub resolved_signers: std::vec::Vec<ks_lib::MdPubkey>,
|
||||
/// Whether submission was requested.
|
||||
pub submit: bool,
|
||||
/// Whether the operator confirmed submission.
|
||||
pub operator_confirmed: bool,
|
||||
}
|
||||
|
||||
/// Deterministic readiness report emitted before signing.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct MetaplexTokenMetadataExecutionReadinessReport {
|
||||
/// Stable operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Exact message hash bound to simulation.
|
||||
pub message_hash: std::string::String,
|
||||
/// Confirmed preflight context slot.
|
||||
pub context_slot: u64,
|
||||
/// Deduplicated required signers.
|
||||
pub required_signers: std::vec::Vec<ks_lib::MdPubkey>,
|
||||
/// Whether signing and submission are authorized.
|
||||
pub send_authorized: bool,
|
||||
/// Ordered readiness checks, including a probe-only failed-simulation marker when applicable.
|
||||
pub checks: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// Validates preflight, exact simulation evidence, signers and submission policies.
|
||||
///
|
||||
/// Failed simulations remain observable only for `submit=false`; submission always requires success.
|
||||
pub fn validate_metaplex_token_metadata_execution_readiness(
|
||||
request: &crate::MetaplexTokenMetadataExecutionReadinessRequest,
|
||||
) -> ks_core::Result<crate::MetaplexTokenMetadataExecutionReadinessReport> {
|
||||
if request.plan.operation_code != request.preflight.operation_code {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_execution_operation_mismatch",
|
||||
"Metaplex plan and preflight operation codes must match",
|
||||
));
|
||||
}
|
||||
if request.message_hash.trim().is_empty()
|
||||
|| request.message_hash != request.simulated_message_hash
|
||||
|| !request.simulated
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_execution_simulation_required",
|
||||
"Metaplex execution requires exact-message simulation evidence",
|
||||
));
|
||||
}
|
||||
if request.submit && !request.simulation_succeeded {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_execution_simulation_required",
|
||||
"Metaplex submission requires successful exact-message simulation",
|
||||
));
|
||||
}
|
||||
if request.resolved_signers.len() > crate::MAX_METAPLEX_TOKEN_METADATA_EXECUTION_SIGNERS {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_execution_signer_limit_exceeded",
|
||||
"Metaplex execution signer limit exceeded",
|
||||
));
|
||||
}
|
||||
let resolved = request
|
||||
.resolved_signers
|
||||
.iter()
|
||||
.map(|item| return item.0.clone())
|
||||
.collect::<std::collections::BTreeSet<std::string::String>>();
|
||||
let mut required = std::collections::BTreeMap::<std::string::String, ks_lib::MdPubkey>::new();
|
||||
for signer in &request.plan.required_signers {
|
||||
if !resolved.contains(signer.pubkey.0.as_str()) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_execution_signer_unresolved",
|
||||
format!("Metaplex signer {} is unresolved", signer.pubkey.0),
|
||||
));
|
||||
}
|
||||
required
|
||||
.entry(signer.pubkey.0.clone())
|
||||
.or_insert_with(|| return signer.pubkey.clone());
|
||||
}
|
||||
if request.submit && !request.operator_confirmed {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_execution_confirmation_required",
|
||||
"Metaplex submission requires explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
if request.submit && request.plan.policy.dry_run {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_execution_dry_run_blocks_submission",
|
||||
"Metaplex dry-run plan cannot be submitted",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(crate::MetaplexTokenMetadataExecutionReadinessReport {
|
||||
operation_code: request.plan.operation_code.clone(),
|
||||
message_hash: request.message_hash.clone(),
|
||||
context_slot: request.preflight.context_slot,
|
||||
required_signers: required.into_values().collect(),
|
||||
send_authorized: request.submit
|
||||
&& request.operator_confirmed
|
||||
&& request.simulation_succeeded
|
||||
&& !request.plan.policy.dry_run,
|
||||
checks: vec![
|
||||
"preflight_bound_to_operation".to_string(),
|
||||
if request.simulation_succeeded {
|
||||
"simulation_bound_to_exact_message".to_string()
|
||||
} else {
|
||||
"failed_simulation_retained_for_probe_only".to_string()
|
||||
},
|
||||
"all_required_signers_resolved".to_string(),
|
||||
"submission_policy_consistent".to_string(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/// Result of one explicit postcondition.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataPostconditionStatus {
|
||||
/// Final state confirms the expected effect.
|
||||
Confirmed,
|
||||
/// Final state contradicts the expected effect.
|
||||
Contradicted,
|
||||
/// No stateful assertion applies.
|
||||
NotApplicable,
|
||||
}
|
||||
|
||||
/// Aggregates Metaplex postconditions without turning absence into success.
|
||||
pub fn summarize_metaplex_token_metadata_postconditions(
|
||||
statuses: &[crate::MetaplexTokenMetadataPostconditionStatus],
|
||||
) -> crate::MetaplexTokenMetadataPostconditionStatus {
|
||||
if statuses
|
||||
.iter()
|
||||
.any(|value| return *value == crate::MetaplexTokenMetadataPostconditionStatus::Contradicted)
|
||||
{
|
||||
return crate::MetaplexTokenMetadataPostconditionStatus::Contradicted;
|
||||
}
|
||||
if statuses
|
||||
.iter()
|
||||
.any(|value| return *value == crate::MetaplexTokenMetadataPostconditionStatus::Confirmed)
|
||||
{
|
||||
return crate::MetaplexTokenMetadataPostconditionStatus::Confirmed;
|
||||
}
|
||||
return crate::MetaplexTokenMetadataPostconditionStatus::NotApplicable;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn failed_simulation_is_reportable_only_without_submission() {
|
||||
let fee_payer = ks_lib::MdPubkey("11111111111111111111111111111111".to_string());
|
||||
let plan = ks_lib::ExApiPreparedExecutionPlan {
|
||||
executor_name: "kb-lib.executor.metadata.metaplex_token_metadata".to_string(),
|
||||
executor_version: "0.4.8".to_string(),
|
||||
intent_id: "metaplex-failed-simulation-probe".to_string(),
|
||||
operation_code: ks_lib::EX_METAPLEX_TOKEN_METADATA_USE_OPERATION.to_string(),
|
||||
fee_payer: fee_payer.clone(),
|
||||
instructions: vec![ks_lib::ExApiPlannedInstruction {
|
||||
program_id: ks_lib::MdProgramId(
|
||||
ks_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID.to_string(),
|
||||
),
|
||||
operation_code: ks_lib::EX_METAPLEX_TOKEN_METADATA_USE_OPERATION.to_string(),
|
||||
accounts: vec![],
|
||||
data: vec![51],
|
||||
}],
|
||||
required_signers: vec![],
|
||||
policy: ks_lib::ExApiExecutionPolicy {
|
||||
dry_run: false,
|
||||
..ks_lib::ExApiExecutionPolicy::default()
|
||||
},
|
||||
requested_spend_lamports: 0,
|
||||
requested_compute_unit_price_micro_lamports: std::option::Option::None,
|
||||
};
|
||||
let request = crate::MetaplexTokenMetadataExecutionReadinessRequest {
|
||||
plan: plan.clone(),
|
||||
preflight: crate::MetaplexTokenMetadataPreflightReport {
|
||||
operation_code: plan.operation_code.clone(),
|
||||
context_slot: 42,
|
||||
inspected_accounts: vec![],
|
||||
checks: vec![],
|
||||
},
|
||||
message_hash: "message-hash".to_string(),
|
||||
simulated_message_hash: "message-hash".to_string(),
|
||||
simulated: true,
|
||||
simulation_succeeded: false,
|
||||
resolved_signers: vec![],
|
||||
submit: false,
|
||||
operator_confirmed: false,
|
||||
};
|
||||
let report = crate::validate_metaplex_token_metadata_execution_readiness(&request);
|
||||
assert!(report.is_ok());
|
||||
if let std::result::Result::Ok(report) = report {
|
||||
assert!(!report.send_authorized);
|
||||
assert!(
|
||||
report
|
||||
.checks
|
||||
.iter()
|
||||
.any(|check| return check == "failed_simulation_retained_for_probe_only")
|
||||
);
|
||||
}
|
||||
let mut submission = request;
|
||||
submission.submit = true;
|
||||
submission.operator_confirmed = true;
|
||||
assert!(crate::validate_metaplex_token_metadata_execution_readiness(&submission).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postconditions_never_convert_not_applicable_into_success() {
|
||||
assert_eq!(
|
||||
crate::summarize_metaplex_token_metadata_postconditions(&[
|
||||
crate::MetaplexTokenMetadataPostconditionStatus::NotApplicable,
|
||||
]),
|
||||
crate::MetaplexTokenMetadataPostconditionStatus::NotApplicable,
|
||||
);
|
||||
assert_eq!(
|
||||
crate::summarize_metaplex_token_metadata_postconditions(&[
|
||||
crate::MetaplexTokenMetadataPostconditionStatus::Confirmed,
|
||||
crate::MetaplexTokenMetadataPostconditionStatus::Contradicted,
|
||||
]),
|
||||
crate::MetaplexTokenMetadataPostconditionStatus::Contradicted,
|
||||
);
|
||||
}
|
||||
}
|
||||
136
ks-pipeline/src/metadata_metaplex_token_metadata_preflight.rs
Normal file
136
ks-pipeline/src/metadata_metaplex_token_metadata_preflight.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
// file: ks-pipeline/src/metadata_metaplex_token_metadata_preflight.rs
|
||||
// version: 4
|
||||
|
||||
//! Stateful Metaplex Token Metadata preflight contracts.
|
||||
|
||||
/// Maximum number of correlated Metaplex snapshots accepted by one preflight.
|
||||
pub const MAX_METAPLEX_TOKEN_METADATA_PREFLIGHT_ACCOUNTS: usize = 32;
|
||||
|
||||
/// Complete preflight request for one prepared Metaplex execution plan.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MetaplexTokenMetadataPreflightRequest {
|
||||
/// Exact prepared plan produced by `ks-lib`.
|
||||
pub plan: ks_lib::ExApiPreparedExecutionPlan,
|
||||
/// Confirmed bounded state snapshots correlated with the plan.
|
||||
pub snapshots: std::vec::Vec<crate::MetaplexTokenMetadataStatefulReadResult>,
|
||||
/// Whether deprecated execution was explicitly approved by the operator.
|
||||
pub allow_deprecated_operation: bool,
|
||||
}
|
||||
|
||||
/// Deterministic Metaplex preflight report.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct MetaplexTokenMetadataPreflightReport {
|
||||
/// Stable operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Highest confirmed context slot across supplied snapshots.
|
||||
pub context_slot: u64,
|
||||
/// Canonical accounts inspected by the preflight.
|
||||
pub inspected_accounts: std::vec::Vec<ks_lib::MdPubkey>,
|
||||
/// Ordered successful checks.
|
||||
pub checks: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// Validates program ownership, snapshot bounds, account correlation and deprecation policy.
|
||||
pub fn inspect_metaplex_token_metadata_preflight(
|
||||
request: &crate::MetaplexTokenMetadataPreflightRequest,
|
||||
) -> ks_core::Result<crate::MetaplexTokenMetadataPreflightReport> {
|
||||
if request.snapshots.len() > crate::MAX_METAPLEX_TOKEN_METADATA_PREFLIGHT_ACCOUNTS {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_preflight_account_limit_exceeded",
|
||||
"Metaplex preflight account limit exceeded",
|
||||
));
|
||||
}
|
||||
if request.plan.instructions.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_preflight_empty_plan",
|
||||
"Metaplex execution plan must contain at least one instruction",
|
||||
));
|
||||
}
|
||||
for instruction in &request.plan.instructions {
|
||||
if instruction.program_id.0 != ks_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_preflight_program_mismatch",
|
||||
"Every Metaplex plan instruction must target Token Metadata",
|
||||
));
|
||||
}
|
||||
if instruction.operation_code != request.plan.operation_code {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_preflight_operation_mismatch",
|
||||
"Metaplex plan and instruction operation codes must match",
|
||||
));
|
||||
}
|
||||
}
|
||||
let deprecated = ks_lib::EX_METAPLEX_TOKEN_METADATA_DEPRECATED_OPERATION_CODES
|
||||
.contains(&request.plan.operation_code.as_str());
|
||||
if deprecated && !request.allow_deprecated_operation {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_preflight_deprecated_operation_not_approved",
|
||||
"Deprecated Metaplex execution requires explicit operator approval",
|
||||
));
|
||||
}
|
||||
let mut inspected = std::collections::BTreeMap::<std::string::String, ks_lib::MdPubkey>::new();
|
||||
let mut context_slot = 0_u64;
|
||||
for snapshot in &request.snapshots {
|
||||
if snapshot.commitment != "confirmed" {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_preflight_commitment_mismatch",
|
||||
"Metaplex stateful snapshots must use confirmed commitment",
|
||||
));
|
||||
}
|
||||
context_slot = context_slot.max(snapshot.context_slot);
|
||||
inspected
|
||||
.entry(snapshot.snapshot.account.0.clone())
|
||||
.or_insert_with(|| return snapshot.snapshot.account.clone());
|
||||
}
|
||||
return std::result::Result::Ok(crate::MetaplexTokenMetadataPreflightReport {
|
||||
operation_code: request.plan.operation_code.clone(),
|
||||
context_slot,
|
||||
inspected_accounts: inspected.into_values().collect(),
|
||||
checks: vec![
|
||||
"program_id_exact".to_string(),
|
||||
"operation_code_exact".to_string(),
|
||||
"confirmed_state_snapshots".to_string(),
|
||||
"deprecated_policy_explicit".to_string(),
|
||||
"account_correlation_bounded".to_string(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn rejects_empty_plans_and_unapproved_deprecated_operations() {
|
||||
let mut plan = ks_lib::ExApiPreparedExecutionPlan {
|
||||
executor_name: "ks-lib".to_string(),
|
||||
executor_version: "0".to_string(),
|
||||
intent_id: "i".to_string(),
|
||||
operation_code: ks_lib::EX_METAPLEX_TOKEN_METADATA_PUFF_METADATA_OPERATION.to_string(),
|
||||
fee_payer: ks_lib::MdPubkey("11111111111111111111111111111111".to_string()),
|
||||
instructions: vec![],
|
||||
required_signers: vec![],
|
||||
policy: std::default::Default::default(),
|
||||
requested_spend_lamports: 0,
|
||||
requested_compute_unit_price_micro_lamports: std::option::Option::None,
|
||||
};
|
||||
let empty = crate::MetaplexTokenMetadataPreflightRequest {
|
||||
plan: plan.clone(),
|
||||
snapshots: vec![],
|
||||
allow_deprecated_operation: false,
|
||||
};
|
||||
assert!(crate::inspect_metaplex_token_metadata_preflight(&empty).is_err());
|
||||
plan.instructions.push(ks_lib::ExApiPlannedInstruction {
|
||||
program_id: ks_lib::MdProgramId(
|
||||
ks_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID.to_string(),
|
||||
),
|
||||
operation_code: plan.operation_code.clone(),
|
||||
accounts: vec![],
|
||||
data: vec![14],
|
||||
});
|
||||
let deprecated = crate::MetaplexTokenMetadataPreflightRequest {
|
||||
plan,
|
||||
snapshots: vec![],
|
||||
allow_deprecated_operation: false,
|
||||
};
|
||||
assert!(crate::inspect_metaplex_token_metadata_preflight(&deprecated).is_err());
|
||||
}
|
||||
}
|
||||
600
ks-pipeline/src/metadata_metaplex_token_metadata_stateful.rs
Normal file
600
ks-pipeline/src/metadata_metaplex_token_metadata_stateful.rs
Normal file
@@ -0,0 +1,600 @@
|
||||
// file: ks-pipeline/src/metadata_metaplex_token_metadata_stateful.rs
|
||||
// version: 10
|
||||
|
||||
//! Bounded Metaplex Token Metadata account reads and canonical state projections.
|
||||
|
||||
/// Maximum complete Metaplex account data accepted by one RPC read.
|
||||
///
|
||||
/// This bound follows the complete `getAccountInfo` contract of the execution transport. Decoder
|
||||
/// limits remain independent for already-available offline bytes or future chunked readers.
|
||||
pub const MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES: usize =
|
||||
ks_onchain_transport::MAX_COMPLETE_ACCOUNT_DATA_BYTES;
|
||||
|
||||
/// Supported Metaplex account category for one bounded stateful read.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataAccountKind {
|
||||
/// Metadata PDA derived from the mint encoded in the account.
|
||||
Metadata,
|
||||
/// Master edition or printed edition PDA derived from the supplied mint.
|
||||
Edition {
|
||||
/// Mint used to derive the edition PDA.
|
||||
mint: ks_lib::MdPubkey,
|
||||
},
|
||||
/// Edition marker PDA derived from the master mint and printed edition number.
|
||||
EditionMarker {
|
||||
/// Master mint used to derive the edition marker PDA.
|
||||
mint: ks_lib::MdPubkey,
|
||||
/// Printed edition number whose marker bit must be decoded.
|
||||
edition: u64,
|
||||
},
|
||||
/// Programmable token record PDA derived from mint and token account.
|
||||
TokenRecord {
|
||||
/// Mint used to derive the token-record PDA.
|
||||
mint: ks_lib::MdPubkey,
|
||||
/// Token account used to derive the token-record PDA.
|
||||
token: ks_lib::MdPubkey,
|
||||
},
|
||||
/// Token Owned Escrow PDA whose authority seeds and bump are self-validated by the decoder.
|
||||
TokenOwnedEscrow,
|
||||
}
|
||||
|
||||
/// One bounded Metaplex account read request.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct MetaplexTokenMetadataStatefulReadRequest {
|
||||
/// Endpoint role used for the HTTP RPC request.
|
||||
pub query_role: std::string::String,
|
||||
/// Canonical account address.
|
||||
pub account: ks_lib::MdPubkey,
|
||||
/// Expected account category and derivation inputs.
|
||||
pub kind: crate::MetaplexTokenMetadataAccountKind,
|
||||
/// Optional minimum RPC context slot.
|
||||
pub min_context_slot: std::option::Option<u64>,
|
||||
/// Maximum decoded account bytes accepted from the endpoint.
|
||||
pub max_data_bytes: usize,
|
||||
}
|
||||
|
||||
impl crate::MetaplexTokenMetadataStatefulReadRequest {
|
||||
/// Validates the bounded complete-account RPC request.
|
||||
pub fn validate(&self) -> ks_core::Result<()> {
|
||||
if self.query_role.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Metaplex stateful read query_role must not be empty",
|
||||
));
|
||||
}
|
||||
let account_result = ks_onchain_transport::validate_solana_pubkey_text(
|
||||
self.account.0.as_str(),
|
||||
"Metaplex stateful account",
|
||||
);
|
||||
if let std::result::Result::Err(error) = account_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let derivation_result = match &self.kind {
|
||||
crate::MetaplexTokenMetadataAccountKind::Metadata => std::result::Result::Ok(()),
|
||||
crate::MetaplexTokenMetadataAccountKind::Edition { mint }
|
||||
| crate::MetaplexTokenMetadataAccountKind::EditionMarker { mint, .. } => {
|
||||
ks_onchain_transport::validate_solana_pubkey_text(
|
||||
mint.0.as_str(),
|
||||
"Metaplex stateful derivation mint",
|
||||
)
|
||||
},
|
||||
crate::MetaplexTokenMetadataAccountKind::TokenRecord { mint, token } => {
|
||||
let mint_result = ks_onchain_transport::validate_solana_pubkey_text(
|
||||
mint.0.as_str(),
|
||||
"Metaplex stateful derivation mint",
|
||||
);
|
||||
if let std::result::Result::Err(error) = mint_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
ks_onchain_transport::validate_solana_pubkey_text(
|
||||
token.0.as_str(),
|
||||
"Metaplex stateful derivation token",
|
||||
)
|
||||
},
|
||||
crate::MetaplexTokenMetadataAccountKind::TokenOwnedEscrow => {
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) = derivation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if self.max_data_bytes == 0
|
||||
|| self.max_data_bytes > crate::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Metaplex stateful max_data_bytes must be between 1 and {}",
|
||||
crate::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES
|
||||
)));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical bounded Metaplex state snapshot.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct MetaplexTokenMetadataStatefulSnapshot {
|
||||
/// Canonical account address.
|
||||
pub account: ks_lib::MdPubkey,
|
||||
/// Stable account category.
|
||||
pub account_kind: std::string::String,
|
||||
/// Mint correlated with this account when available.
|
||||
pub mint: std::option::Option<ks_lib::MdPubkey>,
|
||||
/// RPC context slot.
|
||||
pub slot: u64,
|
||||
/// Bounded decoder-owned projection.
|
||||
pub payload_json: serde_json::Value,
|
||||
}
|
||||
|
||||
/// One bounded RPC read and its validated snapshot.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct MetaplexTokenMetadataStatefulReadResult {
|
||||
/// Commitment used by the read.
|
||||
pub commitment: std::string::String,
|
||||
/// Context slot returned by the endpoint.
|
||||
pub context_slot: u64,
|
||||
/// Validated canonical snapshot.
|
||||
pub snapshot: crate::MetaplexTokenMetadataStatefulSnapshot,
|
||||
}
|
||||
|
||||
/// Reads and validates one bounded Metaplex Token Metadata account.
|
||||
pub async fn read_metaplex_token_metadata_stateful_snapshot(
|
||||
pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
request: &crate::MetaplexTokenMetadataStatefulReadRequest,
|
||||
) -> ks_core::Result<crate::MetaplexTokenMetadataStatefulReadResult> {
|
||||
if let std::result::Result::Err(error) = request.validate() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let config = match ks_onchain_transport::GetAccountInfoConfig::new_with_data(
|
||||
ks_onchain_transport::RpcCommitmentLevel::Confirmed,
|
||||
request.min_context_slot,
|
||||
request.max_data_bytes,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = match pool
|
||||
.get_account_info_for_role(request.query_role.as_str(), &request.account, &config)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return crate::materialize_metaplex_token_metadata_account_info_result(request, &result);
|
||||
}
|
||||
|
||||
/// Validates one RPC account response and delegates parsing to `ks-lib`.
|
||||
pub fn materialize_metaplex_token_metadata_account_info_result(
|
||||
request: &crate::MetaplexTokenMetadataStatefulReadRequest,
|
||||
result: &ks_onchain_transport::AccountInfoResult,
|
||||
) -> ks_core::Result<crate::MetaplexTokenMetadataStatefulReadResult> {
|
||||
if let std::result::Result::Err(error) = request.validate() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::option::Option::Some(minimum) = request.min_context_slot {
|
||||
if result.context.slot < minimum {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_stateful_context_slot_too_old",
|
||||
"Metaplex account context slot is below the requested minimum",
|
||||
));
|
||||
}
|
||||
}
|
||||
let account = match result.account.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_stateful_account_missing",
|
||||
format!("Metaplex account {} does not exist", request.account.0),
|
||||
));
|
||||
},
|
||||
};
|
||||
if account.executable {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_stateful_account_executable",
|
||||
"Metaplex state accounts must not be executable",
|
||||
));
|
||||
}
|
||||
if account.owner.0 != ks_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_stateful_owner_mismatch",
|
||||
"Metaplex state account owner does not match Token Metadata",
|
||||
));
|
||||
}
|
||||
if account.space != account.data.len() as u64 || account.data.len() > request.max_data_bytes {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_stateful_account_data_invalid",
|
||||
"Metaplex account data is incomplete or above the configured bound",
|
||||
));
|
||||
}
|
||||
let snapshot = match decode_snapshot(request, result.context.slot, account.data.as_slice()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::MetaplexTokenMetadataStatefulReadResult {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot: result.context.slot,
|
||||
snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
fn stateful_decode_error(
|
||||
request: &crate::MetaplexTokenMetadataStatefulReadRequest,
|
||||
error: ks_lib::DcMetadataMtmMetadataAccountDecodeError,
|
||||
) -> ks_core::Error {
|
||||
return ks_core::Error::new(
|
||||
"metaplex_stateful_decode_failed",
|
||||
format!(
|
||||
"Metaplex stateful decode failed for account {} kind {:?}: {error}",
|
||||
request.account.0, request.kind
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn decode_snapshot(
|
||||
request: &crate::MetaplexTokenMetadataStatefulReadRequest,
|
||||
slot: u64,
|
||||
data: &[u8],
|
||||
) -> ks_core::Result<crate::MetaplexTokenMetadataStatefulSnapshot> {
|
||||
let owner = ks_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID;
|
||||
return match &request.kind {
|
||||
crate::MetaplexTokenMetadataAccountKind::Metadata => {
|
||||
match ks_lib::decoder_metadata_metaplex_token_metadata_decode_metadata_account(
|
||||
request.account.0.as_str(),
|
||||
owner,
|
||||
data,
|
||||
) {
|
||||
std::result::Result::Ok(value) => {
|
||||
std::result::Result::Ok(crate::MetaplexTokenMetadataStatefulSnapshot {
|
||||
account: request.account.clone(),
|
||||
account_kind: "metadata".to_string(),
|
||||
mint: std::option::Option::Some(ks_lib::MdPubkey(value.mint)),
|
||||
slot,
|
||||
payload_json: value.payload_json,
|
||||
})
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(stateful_decode_error(request, error))
|
||||
},
|
||||
}
|
||||
},
|
||||
crate::MetaplexTokenMetadataAccountKind::Edition { mint } => {
|
||||
match ks_lib::decoder_metadata_metaplex_token_metadata_decode_edition_account(
|
||||
request.account.0.as_str(),
|
||||
owner,
|
||||
mint.0.as_str(),
|
||||
data,
|
||||
) {
|
||||
std::result::Result::Ok(value) => {
|
||||
std::result::Result::Ok(crate::MetaplexTokenMetadataStatefulSnapshot {
|
||||
account: request.account.clone(),
|
||||
account_kind: "edition".to_string(),
|
||||
mint: std::option::Option::Some(mint.clone()),
|
||||
slot,
|
||||
payload_json: value.payload_json,
|
||||
})
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(stateful_decode_error(request, error))
|
||||
},
|
||||
}
|
||||
},
|
||||
crate::MetaplexTokenMetadataAccountKind::EditionMarker { mint, edition } => {
|
||||
match ks_lib::decoder_metadata_metaplex_token_metadata_decode_edition_marker_account(
|
||||
request.account.0.as_str(),
|
||||
owner,
|
||||
mint.0.as_str(),
|
||||
*edition,
|
||||
data,
|
||||
) {
|
||||
std::result::Result::Ok(value) => {
|
||||
std::result::Result::Ok(crate::MetaplexTokenMetadataStatefulSnapshot {
|
||||
account: request.account.clone(),
|
||||
account_kind: "edition_marker".to_string(),
|
||||
mint: std::option::Option::Some(mint.clone()),
|
||||
slot,
|
||||
payload_json: serde_json::json!({
|
||||
"account": value.account,
|
||||
"bump": value.bump,
|
||||
"mint": value.mint,
|
||||
"kind": match value.kind {
|
||||
ks_lib::DcMetadataMtmEditionMarkerAccountKind::EditionMarkerV1 => "edition_marker_v1",
|
||||
ks_lib::DcMetadataMtmEditionMarkerAccountKind::EditionMarkerV2 => "edition_marker_v2",
|
||||
},
|
||||
"marker_group": value.marker_group,
|
||||
"edition": value.edition,
|
||||
"byte_index": value.byte_index,
|
||||
"bit_mask": value.bit_mask,
|
||||
"edition_taken": value.edition_taken,
|
||||
"ledger": value.ledger,
|
||||
"payload": value.payload_json,
|
||||
}),
|
||||
})
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(stateful_decode_error(request, error))
|
||||
},
|
||||
}
|
||||
},
|
||||
crate::MetaplexTokenMetadataAccountKind::TokenRecord { mint, token } => {
|
||||
match ks_lib::decoder_metadata_metaplex_token_metadata_decode_token_record_account(
|
||||
request.account.0.as_str(),
|
||||
owner,
|
||||
mint.0.as_str(),
|
||||
token.0.as_str(),
|
||||
data,
|
||||
) {
|
||||
std::result::Result::Ok(value) => {
|
||||
std::result::Result::Ok(crate::MetaplexTokenMetadataStatefulSnapshot {
|
||||
account: request.account.clone(),
|
||||
account_kind: "token_record".to_string(),
|
||||
mint: std::option::Option::Some(mint.clone()),
|
||||
slot,
|
||||
payload_json: value.payload_json,
|
||||
})
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(stateful_decode_error(request, error))
|
||||
},
|
||||
}
|
||||
},
|
||||
crate::MetaplexTokenMetadataAccountKind::TokenOwnedEscrow => {
|
||||
match ks_lib::decoder_metadata_metaplex_token_metadata_decode_token_owned_escrow_account(
|
||||
request.account.0.as_str(),
|
||||
owner,
|
||||
data,
|
||||
) {
|
||||
std::result::Result::Ok(value) => {
|
||||
let authority_kind = match value.authority_kind {
|
||||
ks_lib::DcMetadataMtmTokenOwnedEscrowAuthorityKind::TokenOwner => {
|
||||
"token_owner"
|
||||
},
|
||||
ks_lib::DcMetadataMtmTokenOwnedEscrowAuthorityKind::Creator => "creator",
|
||||
};
|
||||
std::result::Result::Ok(crate::MetaplexTokenMetadataStatefulSnapshot {
|
||||
account: request.account.clone(),
|
||||
account_kind: "token_owned_escrow".to_string(),
|
||||
mint: std::option::Option::Some(ks_lib::MdPubkey(value.base_token.clone())),
|
||||
slot,
|
||||
payload_json: serde_json::json!({
|
||||
"account": value.account,
|
||||
"bump": value.bump,
|
||||
"base_token": value.base_token,
|
||||
"authority_kind": authority_kind,
|
||||
"creator": value.creator,
|
||||
"stored_bump": value.stored_bump,
|
||||
"payload": value.payload_json,
|
||||
}),
|
||||
})
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(stateful_decode_error(request, error))
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn stateful_read_request_round_trips_through_json() {
|
||||
let request = crate::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: "rpc".to_string(),
|
||||
account: ks_lib::MdPubkey("11111111111111111111111111111111".to_string()),
|
||||
kind: crate::MetaplexTokenMetadataAccountKind::Metadata,
|
||||
min_context_slot: std::option::Option::Some(42),
|
||||
max_data_bytes: 1024,
|
||||
};
|
||||
let json = match serde_json::to_string(&request) {
|
||||
std::result::Result::Ok(json) => json,
|
||||
std::result::Result::Err(error) => panic!("request serialization failed: {error}"),
|
||||
};
|
||||
let decoded = match serde_json::from_str::<crate::MetaplexTokenMetadataStatefulReadRequest>(
|
||||
json.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(decoded) => decoded,
|
||||
std::result::Result::Err(error) => panic!("request deserialization failed: {error}"),
|
||||
};
|
||||
assert_eq!(decoded, request);
|
||||
let marker = crate::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: "rpc".to_string(),
|
||||
account: ks_lib::MdPubkey("11111111111111111111111111111111".to_string()),
|
||||
kind: crate::MetaplexTokenMetadataAccountKind::EditionMarker {
|
||||
mint: ks_lib::MdPubkey("11111111111111111111111111111111".to_string()),
|
||||
edition: 1,
|
||||
},
|
||||
min_context_slot: std::option::Option::Some(43),
|
||||
max_data_bytes: 1024,
|
||||
};
|
||||
let marker_json = match serde_json::to_string(&marker) {
|
||||
std::result::Result::Ok(json) => json,
|
||||
std::result::Result::Err(error) => {
|
||||
panic!("marker request serialization failed: {error}")
|
||||
},
|
||||
};
|
||||
let marker_decoded = match serde_json::from_str::<
|
||||
crate::MetaplexTokenMetadataStatefulReadRequest,
|
||||
>(marker_json.as_str())
|
||||
{
|
||||
std::result::Result::Ok(decoded) => decoded,
|
||||
std::result::Result::Err(error) => {
|
||||
panic!("marker request deserialization failed: {error}")
|
||||
},
|
||||
};
|
||||
assert_eq!(marker_decoded, marker);
|
||||
let escrow = crate::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: "rpc".to_string(),
|
||||
account: ks_lib::MdPubkey("11111111111111111111111111111111".to_string()),
|
||||
kind: crate::MetaplexTokenMetadataAccountKind::TokenOwnedEscrow,
|
||||
min_context_slot: std::option::Option::Some(44),
|
||||
max_data_bytes: 1024,
|
||||
};
|
||||
let escrow_json = match serde_json::to_string(&escrow) {
|
||||
std::result::Result::Ok(json) => json,
|
||||
std::result::Result::Err(error) => {
|
||||
panic!("escrow request serialization failed: {error}")
|
||||
},
|
||||
};
|
||||
let escrow_decoded = match serde_json::from_str::<
|
||||
crate::MetaplexTokenMetadataStatefulReadRequest,
|
||||
>(escrow_json.as_str())
|
||||
{
|
||||
std::result::Result::Ok(decoded) => decoded,
|
||||
std::result::Result::Err(error) => {
|
||||
panic!("escrow request deserialization failed: {error}")
|
||||
},
|
||||
};
|
||||
assert_eq!(escrow_decoded, escrow);
|
||||
let invalid_marker = crate::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: "rpc".to_string(),
|
||||
account: ks_lib::MdPubkey("11111111111111111111111111111111".to_string()),
|
||||
kind: crate::MetaplexTokenMetadataAccountKind::EditionMarker {
|
||||
mint: ks_lib::MdPubkey("invalid".to_string()),
|
||||
edition: 1,
|
||||
},
|
||||
min_context_slot: std::option::Option::None,
|
||||
max_data_bytes: 1024,
|
||||
};
|
||||
assert!(invalid_marker.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_and_oversize_bounds_before_account_decoding() {
|
||||
let request = crate::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: "rpc".to_string(),
|
||||
account: ks_lib::MdPubkey("11111111111111111111111111111111".to_string()),
|
||||
kind: crate::MetaplexTokenMetadataAccountKind::Metadata,
|
||||
min_context_slot: std::option::Option::None,
|
||||
max_data_bytes: 0,
|
||||
};
|
||||
let result = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 1,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::None,
|
||||
};
|
||||
assert!(
|
||||
crate::materialize_metaplex_token_metadata_account_info_result(&request, &result)
|
||||
.is_err()
|
||||
);
|
||||
let mut oversized = request;
|
||||
oversized.max_data_bytes = ks_onchain_transport::MAX_COMPLETE_ACCOUNT_DATA_BYTES + 1;
|
||||
assert!(oversized.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_owned_escrow_materializes_with_canonical_authority_projection() {
|
||||
let program = match ks_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID
|
||||
.parse::<solana_pubkey::Pubkey>()
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("program parse failed: {error}"),
|
||||
};
|
||||
let mint = solana_pubkey::Pubkey::new_from_array([6_u8; 32]);
|
||||
let (account, bump) = solana_pubkey::Pubkey::find_program_address(
|
||||
&[b"metadata", program.as_ref(), mint.as_ref(), &[0], b"escrow"],
|
||||
&program,
|
||||
);
|
||||
let mut data = std::vec![10_u8];
|
||||
data.extend_from_slice(mint.as_ref());
|
||||
data.push(0);
|
||||
data.push(bump);
|
||||
assert_eq!(data.len(), 35);
|
||||
let request = crate::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: "rpc".to_string(),
|
||||
account: ks_lib::MdPubkey(account.to_string()),
|
||||
kind: crate::MetaplexTokenMetadataAccountKind::TokenOwnedEscrow,
|
||||
min_context_slot: std::option::Option::Some(11),
|
||||
max_data_bytes: 1024,
|
||||
};
|
||||
let result = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 11,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::Some(ks_onchain_transport::AccountInfoValue {
|
||||
lamports: 1_000,
|
||||
owner: ks_lib::MdProgramId(program.to_string()),
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
space: data.len() as u64,
|
||||
data,
|
||||
}),
|
||||
};
|
||||
let output =
|
||||
crate::materialize_metaplex_token_metadata_account_info_result(&request, &result);
|
||||
let snapshot = match output {
|
||||
std::result::Result::Ok(value) => value.snapshot,
|
||||
std::result::Result::Err(error) => panic!("escrow stateful decode failed: {error}"),
|
||||
};
|
||||
assert_eq!(snapshot.account_kind, "token_owned_escrow");
|
||||
assert_eq!(snapshot.mint, std::option::Option::Some(ks_lib::MdPubkey(mint.to_string())));
|
||||
assert_eq!(snapshot.payload_json["base_token"], serde_json::json!(mint.to_string()));
|
||||
assert_eq!(snapshot.payload_json["authority_kind"], serde_json::json!("token_owner"));
|
||||
assert_eq!(snapshot.payload_json["creator"], serde_json::Value::Null);
|
||||
assert_eq!(snapshot.payload_json["stored_bump"], serde_json::json!(bump));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_printed_edition_account_materializes_from_current_network_allocation() {
|
||||
let program = match ks_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID
|
||||
.parse::<solana_pubkey::Pubkey>()
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("program parse failed: {error}"),
|
||||
};
|
||||
let mint = solana_pubkey::Pubkey::new_from_array([7_u8; 32]);
|
||||
let parent = solana_pubkey::Pubkey::new_from_array([8_u8; 32]);
|
||||
let (account, _) = solana_pubkey::Pubkey::find_program_address(
|
||||
&[b"metadata", program.as_ref(), mint.as_ref(), b"edition"],
|
||||
&program,
|
||||
);
|
||||
let mut data = vec![1_u8];
|
||||
data.extend_from_slice(parent.as_ref());
|
||||
data.extend_from_slice(&1_u64.to_le_bytes());
|
||||
data.push(0);
|
||||
assert_eq!(data.len(), 42);
|
||||
let request = crate::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: "rpc".to_string(),
|
||||
account: ks_lib::MdPubkey(account.to_string()),
|
||||
kind: crate::MetaplexTokenMetadataAccountKind::Edition {
|
||||
mint: ks_lib::MdPubkey(mint.to_string()),
|
||||
},
|
||||
min_context_slot: std::option::Option::Some(12),
|
||||
max_data_bytes: 1024,
|
||||
};
|
||||
let result = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 12,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::Some(ks_onchain_transport::AccountInfoValue {
|
||||
lamports: 1_000,
|
||||
owner: ks_lib::MdProgramId(program.to_string()),
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
space: 42,
|
||||
data,
|
||||
}),
|
||||
};
|
||||
let output =
|
||||
crate::materialize_metaplex_token_metadata_account_info_result(&request, &result);
|
||||
let snapshot = match output {
|
||||
std::result::Result::Ok(value) => value.snapshot,
|
||||
std::result::Result::Err(error) => {
|
||||
panic!("compact edition stateful decode failed: {error}")
|
||||
},
|
||||
};
|
||||
assert_eq!(snapshot.account_kind, "edition");
|
||||
assert_eq!(snapshot.payload_json["edition"], serde_json::json!(1));
|
||||
assert_eq!(snapshot.payload_json["parent"], serde_json::json!(parent.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stateful_complete_read_bound_matches_the_transport_contract() {
|
||||
assert_eq!(
|
||||
crate::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES,
|
||||
ks_onchain_transport::MAX_COMPLETE_ACCOUNT_DATA_BYTES
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
// file: ks-pipeline/src/metadata_solana_program_execution_orchestration.rs
|
||||
// version: 3
|
||||
|
||||
//! Simulation-first Solana Program Metadata execution orchestration and postconditions.
|
||||
|
||||
/// Maximum number of resolved signers accepted by one Program Metadata envelope.
|
||||
pub const MAX_SOLANA_PROGRAM_METADATA_EXECUTION_SIGNERS: usize = 16;
|
||||
|
||||
/// Complete request checked before signing or submitting one Program Metadata transaction.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaProgramMetadataExecutionReadinessRequest {
|
||||
/// Exact prepared execution plan.
|
||||
pub plan: ks_lib::ExApiPreparedExecutionPlan,
|
||||
/// Successful stateful preflight report.
|
||||
pub preflight: crate::SolanaProgramMetadataPreflightReport,
|
||||
/// Exact compiled message hash.
|
||||
pub message_hash: std::string::String,
|
||||
/// Hash retained by simulation evidence.
|
||||
pub simulated_message_hash: std::string::String,
|
||||
/// Whether simulation was performed.
|
||||
pub simulated: bool,
|
||||
/// Whether simulation succeeded.
|
||||
pub simulation_succeeded: bool,
|
||||
/// Public keys available to sign.
|
||||
pub resolved_signers: std::vec::Vec<ks_lib::MdPubkey>,
|
||||
/// Whether submission was requested.
|
||||
pub submit: bool,
|
||||
/// Whether the operator confirmed submission.
|
||||
pub operator_confirmed: bool,
|
||||
}
|
||||
|
||||
/// Deterministic readiness report emitted before signing.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct SolanaProgramMetadataExecutionReadinessReport {
|
||||
/// Stable operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Exact message hash bound to simulation.
|
||||
pub message_hash: std::string::String,
|
||||
/// Confirmed preflight context slot.
|
||||
pub context_slot: u64,
|
||||
/// Deduplicated required signers.
|
||||
pub required_signers: std::vec::Vec<ks_lib::MdPubkey>,
|
||||
/// Whether signing and submission are authorized.
|
||||
pub send_authorized: bool,
|
||||
/// Ordered successful checks.
|
||||
pub checks: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// Result of one Program Metadata postcondition.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SolanaProgramMetadataPostconditionStatus {
|
||||
/// Final state confirms the expected effect.
|
||||
Confirmed,
|
||||
/// Final state contradicts the expected effect.
|
||||
Contradicted,
|
||||
/// No stateful assertion applies.
|
||||
NotApplicable,
|
||||
}
|
||||
|
||||
/// One machine-readable Program Metadata postcondition.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct SolanaProgramMetadataPostcondition {
|
||||
/// Stable check code.
|
||||
pub code: std::string::String,
|
||||
/// Account inspected by the check.
|
||||
pub account: ks_lib::MdPubkey,
|
||||
/// Terminal status.
|
||||
pub status: crate::SolanaProgramMetadataPostconditionStatus,
|
||||
/// Human-readable diagnostic.
|
||||
pub diagnostic: std::string::String,
|
||||
}
|
||||
|
||||
/// Complete request for one confirmed Program Metadata post-execution inspection.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaProgramMetadataPostExecutionRequest {
|
||||
/// Exact operation that was submitted.
|
||||
pub operation: ks_lib::ExMetadataSpmOperation,
|
||||
/// Confirmed snapshots captured before execution.
|
||||
pub before: std::vec::Vec<crate::SolanaProgramMetadataStatefulReadResult>,
|
||||
/// Confirmed snapshots captured after execution.
|
||||
pub after: std::vec::Vec<crate::SolanaProgramMetadataStatefulReadResult>,
|
||||
}
|
||||
|
||||
/// Aggregated Program Metadata post-execution report.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct SolanaProgramMetadataPostExecutionReport {
|
||||
/// Stable operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Aggregate status without invented success.
|
||||
pub status: crate::SolanaProgramMetadataPostconditionStatus,
|
||||
/// Individual postconditions.
|
||||
pub postconditions: std::vec::Vec<crate::SolanaProgramMetadataPostcondition>,
|
||||
}
|
||||
|
||||
/// Validates simulation, preflight, signer and submission policies.
|
||||
pub fn validate_solana_program_metadata_execution_readiness(
|
||||
request: &crate::SolanaProgramMetadataExecutionReadinessRequest,
|
||||
) -> ks_core::Result<crate::SolanaProgramMetadataExecutionReadinessReport> {
|
||||
if request.plan.operation_code != request.preflight.operation_code {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_execution_operation_mismatch",
|
||||
"Program Metadata plan and preflight operation codes must match",
|
||||
));
|
||||
}
|
||||
if request.preflight.safety.decision == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_execution_safety_denied",
|
||||
"Program Metadata preflight safety decision denied execution",
|
||||
));
|
||||
}
|
||||
if request.preflight.safety.decision == ks_lib::ExSafetyDecision::RequireConfirmation
|
||||
&& !request.operator_confirmed
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_execution_safety_confirmation_required",
|
||||
"Program Metadata safety policy requires explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
if request.message_hash.trim().is_empty()
|
||||
|| request.message_hash != request.simulated_message_hash
|
||||
|| !request.simulated
|
||||
|| !request.simulation_succeeded
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_execution_simulation_required",
|
||||
"Program Metadata execution requires successful exact-message simulation",
|
||||
));
|
||||
}
|
||||
if request.resolved_signers.len() > crate::MAX_SOLANA_PROGRAM_METADATA_EXECUTION_SIGNERS {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_execution_signer_limit_exceeded",
|
||||
"Program Metadata execution signer limit exceeded",
|
||||
));
|
||||
}
|
||||
let resolved = request
|
||||
.resolved_signers
|
||||
.iter()
|
||||
.map(|item| return item.0.clone())
|
||||
.collect::<std::collections::BTreeSet<std::string::String>>();
|
||||
let mut required = std::collections::BTreeMap::<std::string::String, ks_lib::MdPubkey>::new();
|
||||
for signer in &request.plan.required_signers {
|
||||
if !resolved.contains(signer.pubkey.0.as_str()) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_execution_signer_unresolved",
|
||||
format!("Program Metadata signer {} is unresolved", signer.pubkey.0),
|
||||
));
|
||||
}
|
||||
required
|
||||
.entry(signer.pubkey.0.clone())
|
||||
.or_insert_with(|| return signer.pubkey.clone());
|
||||
}
|
||||
if request.submit && !request.operator_confirmed {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_execution_confirmation_required",
|
||||
"Program Metadata submission requires explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
if request.submit && request.plan.policy.dry_run {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_execution_dry_run_blocks_submission",
|
||||
"Program Metadata dry-run plan cannot be submitted",
|
||||
));
|
||||
}
|
||||
tracing::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "validate_solana_program_metadata_execution_readiness",
|
||||
operation_code = %request.plan.operation_code,
|
||||
context_slot = request.preflight.context_slot,
|
||||
signer_count = required.len(),
|
||||
submit = request.submit,
|
||||
send_authorized = request.submit && request.operator_confirmed && !request.plan.policy.dry_run,
|
||||
"validated Solana Program Metadata execution readiness"
|
||||
);
|
||||
return std::result::Result::Ok(crate::SolanaProgramMetadataExecutionReadinessReport {
|
||||
operation_code: request.plan.operation_code.clone(),
|
||||
message_hash: request.message_hash.clone(),
|
||||
context_slot: request.preflight.context_slot,
|
||||
required_signers: required.into_values().collect(),
|
||||
send_authorized: request.submit
|
||||
&& request.operator_confirmed
|
||||
&& !request.plan.policy.dry_run,
|
||||
checks: vec![
|
||||
"preflight_bound_to_operation".to_string(),
|
||||
"common_safety_allowed".to_string(),
|
||||
"simulation_bound_to_exact_message".to_string(),
|
||||
"all_required_signers_resolved".to_string(),
|
||||
"submission_policy_consistent".to_string(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/// Inspects confirmed state after one Program Metadata execution.
|
||||
pub fn inspect_solana_program_metadata_post_execution(
|
||||
request: &crate::SolanaProgramMetadataPostExecutionRequest,
|
||||
) -> ks_core::Result<crate::SolanaProgramMetadataPostExecutionReport> {
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_snapshot_set(request.before.as_slice(), "before")
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_snapshot_set(request.after.as_slice(), "after")
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let target = operation_target(&request.operation).clone();
|
||||
let before = find_snapshot(request.before.as_slice(), &target);
|
||||
let after = find_snapshot(request.after.as_slice(), &target);
|
||||
let postcondition = evaluate_operation_postcondition(
|
||||
&request.operation,
|
||||
before,
|
||||
after,
|
||||
request.before.as_slice(),
|
||||
);
|
||||
let status =
|
||||
summarize_solana_program_metadata_postconditions(std::slice::from_ref(&postcondition));
|
||||
tracing::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "inspect_solana_program_metadata_post_execution",
|
||||
operation_code = request.operation.operation_code(),
|
||||
target_account = %target.0,
|
||||
status = ?status,
|
||||
"inspected Solana Program Metadata post-execution state"
|
||||
);
|
||||
return std::result::Result::Ok(crate::SolanaProgramMetadataPostExecutionReport {
|
||||
operation_code: request.operation.operation_code().to_string(),
|
||||
status,
|
||||
postconditions: vec![postcondition],
|
||||
});
|
||||
}
|
||||
|
||||
/// Aggregates Program Metadata postconditions without converting absence into success.
|
||||
pub fn summarize_solana_program_metadata_postconditions(
|
||||
postconditions: &[crate::SolanaProgramMetadataPostcondition],
|
||||
) -> crate::SolanaProgramMetadataPostconditionStatus {
|
||||
if postconditions.iter().any(|item| {
|
||||
return item.status == crate::SolanaProgramMetadataPostconditionStatus::Contradicted;
|
||||
}) {
|
||||
return crate::SolanaProgramMetadataPostconditionStatus::Contradicted;
|
||||
}
|
||||
if postconditions.iter().any(|item| {
|
||||
return item.status == crate::SolanaProgramMetadataPostconditionStatus::Confirmed;
|
||||
}) {
|
||||
return crate::SolanaProgramMetadataPostconditionStatus::Confirmed;
|
||||
}
|
||||
return crate::SolanaProgramMetadataPostconditionStatus::NotApplicable;
|
||||
}
|
||||
|
||||
fn validate_snapshot_set(
|
||||
snapshots: &[crate::SolanaProgramMetadataStatefulReadResult],
|
||||
phase: &str,
|
||||
) -> ks_core::Result<()> {
|
||||
if snapshots.len() > crate::MAX_SOLANA_PROGRAM_METADATA_PREFLIGHT_ACCOUNTS {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_post_execution_account_limit_exceeded",
|
||||
format!("Program Metadata {phase} snapshot limit exceeded"),
|
||||
));
|
||||
}
|
||||
let mut accounts = std::collections::BTreeSet::new();
|
||||
for snapshot in snapshots {
|
||||
if snapshot.commitment != "confirmed" {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_post_execution_commitment_mismatch",
|
||||
format!("Program Metadata {phase} snapshots must use confirmed commitment"),
|
||||
));
|
||||
}
|
||||
if !accounts.insert(snapshot.account.0.as_str()) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_post_execution_duplicate_account",
|
||||
format!("duplicate Program Metadata {phase} snapshot for {}", snapshot.account.0),
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn operation_target(operation: &ks_lib::ExMetadataSpmOperation) -> &ks_lib::MdPubkey {
|
||||
return match operation {
|
||||
ks_lib::ExMetadataSpmOperation::Write { buffer, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::Allocate { buffer, .. } => buffer,
|
||||
ks_lib::ExMetadataSpmOperation::Initialize { metadata, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::SetData { metadata, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::SetImmutable { metadata, .. } => metadata,
|
||||
ks_lib::ExMetadataSpmOperation::SetAuthority { account, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::Trim { account, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::Close { account, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::Extend { account, .. } => account,
|
||||
};
|
||||
}
|
||||
|
||||
fn find_snapshot<'a>(
|
||||
snapshots: &'a [crate::SolanaProgramMetadataStatefulReadResult],
|
||||
account: &ks_lib::MdPubkey,
|
||||
) -> std::option::Option<&'a crate::SolanaProgramMetadataStatefulReadResult> {
|
||||
return snapshots.iter().find(|value| return value.account == *account);
|
||||
}
|
||||
|
||||
fn evaluate_operation_postcondition(
|
||||
operation: &ks_lib::ExMetadataSpmOperation,
|
||||
before: std::option::Option<&crate::SolanaProgramMetadataStatefulReadResult>,
|
||||
after: std::option::Option<&crate::SolanaProgramMetadataStatefulReadResult>,
|
||||
before_all: &[crate::SolanaProgramMetadataStatefulReadResult],
|
||||
) -> crate::SolanaProgramMetadataPostcondition {
|
||||
let target = operation_target(operation).clone();
|
||||
let confirmed = match operation {
|
||||
ks_lib::ExMetadataSpmOperation::Write { offset, source, .. } => {
|
||||
write_postcondition(*offset, source, after, before_all)
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::Initialize {
|
||||
program,
|
||||
canonical,
|
||||
seed,
|
||||
encoding,
|
||||
compression,
|
||||
format,
|
||||
data_source,
|
||||
data,
|
||||
..
|
||||
} => initialize_postcondition(
|
||||
program,
|
||||
*canonical,
|
||||
seed.as_slice(),
|
||||
*encoding,
|
||||
*compression,
|
||||
*format,
|
||||
*data_source,
|
||||
data.as_ref(),
|
||||
after,
|
||||
),
|
||||
ks_lib::ExMetadataSpmOperation::SetAuthority { new_authority, .. } => {
|
||||
authority_postcondition(new_authority.as_ref(), after)
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::SetData {
|
||||
encoding, compression, format, source, ..
|
||||
} => set_data_postcondition(
|
||||
*encoding,
|
||||
*compression,
|
||||
*format,
|
||||
source,
|
||||
before,
|
||||
after,
|
||||
before_all,
|
||||
),
|
||||
ks_lib::ExMetadataSpmOperation::SetImmutable { .. } => match after {
|
||||
std::option::Option::Some(value) => matches!(
|
||||
&value.state,
|
||||
crate::SolanaProgramMetadataObservedAccountState::Metadata(metadata) if !metadata.mutable
|
||||
),
|
||||
std::option::Option::None => false,
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::Trim { .. } => trim_postcondition(before, after),
|
||||
ks_lib::ExMetadataSpmOperation::Close { .. } => match after {
|
||||
std::option::Option::Some(value) => {
|
||||
matches!(&value.state, crate::SolanaProgramMetadataObservedAccountState::Missing)
|
||||
},
|
||||
std::option::Option::None => false,
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::Allocate { authority, seed, canonical, .. } => {
|
||||
allocate_postcondition(authority, seed.as_deref(), *canonical, after)
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::Extend { length, .. } => {
|
||||
extend_postcondition(*length, before, after)
|
||||
},
|
||||
};
|
||||
let status = if after.is_none() {
|
||||
crate::SolanaProgramMetadataPostconditionStatus::NotApplicable
|
||||
} else if confirmed {
|
||||
crate::SolanaProgramMetadataPostconditionStatus::Confirmed
|
||||
} else {
|
||||
crate::SolanaProgramMetadataPostconditionStatus::Contradicted
|
||||
};
|
||||
return crate::SolanaProgramMetadataPostcondition {
|
||||
code: format!("{}.state", operation.operation_code()),
|
||||
account: target,
|
||||
status,
|
||||
diagnostic: match status {
|
||||
crate::SolanaProgramMetadataPostconditionStatus::Confirmed => {
|
||||
"confirmed state matches the expected Program Metadata effect".to_string()
|
||||
},
|
||||
crate::SolanaProgramMetadataPostconditionStatus::Contradicted => {
|
||||
"confirmed state contradicts the expected Program Metadata effect".to_string()
|
||||
},
|
||||
crate::SolanaProgramMetadataPostconditionStatus::NotApplicable => {
|
||||
"no confirmed after-state snapshot was supplied".to_string()
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn write_postcondition(
|
||||
offset: u32,
|
||||
source: &ks_lib::ExMetadataSpmWriteSource,
|
||||
after: std::option::Option<&crate::SolanaProgramMetadataStatefulReadResult>,
|
||||
before_all: &[crate::SolanaProgramMetadataStatefulReadResult],
|
||||
) -> bool {
|
||||
let buffer = match after.map(|value| return &value.state) {
|
||||
std::option::Option::Some(crate::SolanaProgramMetadataObservedAccountState::Buffer(
|
||||
value,
|
||||
)) => value,
|
||||
_ => return false,
|
||||
};
|
||||
let start = match usize::try_from(offset) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return false,
|
||||
};
|
||||
let expected = match source {
|
||||
ks_lib::ExMetadataSpmWriteSource::Inline { data } => data.as_slice(),
|
||||
ks_lib::ExMetadataSpmWriteSource::Buffer { source_buffer } => {
|
||||
let source_snapshot = match find_snapshot(before_all, source_buffer) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return false,
|
||||
};
|
||||
match &source_snapshot.state {
|
||||
crate::SolanaProgramMetadataObservedAccountState::Buffer(value) => {
|
||||
value.data.as_slice()
|
||||
},
|
||||
_ => return false,
|
||||
}
|
||||
},
|
||||
};
|
||||
let end = match start.checked_add(expected.len()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return false,
|
||||
};
|
||||
return buffer.data.get(start..end) == std::option::Option::Some(expected);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn initialize_postcondition(
|
||||
program: &ks_lib::MdPubkey,
|
||||
canonical: bool,
|
||||
seed: &[u8],
|
||||
encoding: ks_lib::ExMetadataSpmEncoding,
|
||||
compression: ks_lib::ExMetadataSpmCompression,
|
||||
format: ks_lib::ExMetadataSpmFormat,
|
||||
data_source: ks_lib::ExMetadataSpmDataSource,
|
||||
data: std::option::Option<&ks_lib::ExMetadataSpmDataInput>,
|
||||
after: std::option::Option<&crate::SolanaProgramMetadataStatefulReadResult>,
|
||||
) -> bool {
|
||||
let metadata = match after.map(|value| return &value.state) {
|
||||
std::option::Option::Some(crate::SolanaProgramMetadataObservedAccountState::Metadata(
|
||||
value,
|
||||
)) => value,
|
||||
_ => return false,
|
||||
};
|
||||
if metadata.program.as_str() != program.0.as_str()
|
||||
|| metadata.canonical != canonical
|
||||
|| &metadata.seed.as_bytes()[..] != seed
|
||||
|| metadata.encoding.wire_value() != encoding.wire_value()
|
||||
|| metadata.compression.wire_value() != compression.wire_value()
|
||||
|| metadata.format.wire_value() != format.wire_value()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let expected_source = match data {
|
||||
std::option::Option::Some(value) => value.data_source(),
|
||||
std::option::Option::None => data_source,
|
||||
};
|
||||
if metadata.data_source.wire_value() != expected_source.wire_value() {
|
||||
return false;
|
||||
}
|
||||
return match data {
|
||||
std::option::Option::Some(value) => data_matches(value, &metadata.data),
|
||||
std::option::Option::None => true,
|
||||
};
|
||||
}
|
||||
|
||||
fn authority_postcondition(
|
||||
new_authority: std::option::Option<&ks_lib::MdPubkey>,
|
||||
after: std::option::Option<&crate::SolanaProgramMetadataStatefulReadResult>,
|
||||
) -> bool {
|
||||
let observed = match after.map(|value| return &value.state) {
|
||||
std::option::Option::Some(crate::SolanaProgramMetadataObservedAccountState::Buffer(
|
||||
value,
|
||||
)) => value.authority.as_deref(),
|
||||
std::option::Option::Some(crate::SolanaProgramMetadataObservedAccountState::Metadata(
|
||||
value,
|
||||
)) => value.authority.as_deref(),
|
||||
_ => return false,
|
||||
};
|
||||
return observed == new_authority.map(|value| return value.0.as_str());
|
||||
}
|
||||
|
||||
fn set_data_postcondition(
|
||||
encoding: ks_lib::ExMetadataSpmEncoding,
|
||||
compression: ks_lib::ExMetadataSpmCompression,
|
||||
format: ks_lib::ExMetadataSpmFormat,
|
||||
source: &ks_lib::ExMetadataSpmSetDataSource,
|
||||
before: std::option::Option<&crate::SolanaProgramMetadataStatefulReadResult>,
|
||||
after: std::option::Option<&crate::SolanaProgramMetadataStatefulReadResult>,
|
||||
before_all: &[crate::SolanaProgramMetadataStatefulReadResult],
|
||||
) -> bool {
|
||||
let metadata = match after.map(|value| return &value.state) {
|
||||
std::option::Option::Some(crate::SolanaProgramMetadataObservedAccountState::Metadata(
|
||||
value,
|
||||
)) => value,
|
||||
_ => return false,
|
||||
};
|
||||
if metadata.encoding.wire_value() != encoding.wire_value()
|
||||
|| metadata.compression.wire_value() != compression.wire_value()
|
||||
|| metadata.format.wire_value() != format.wire_value()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return match source {
|
||||
ks_lib::ExMetadataSpmSetDataSource::PreserveExisting => {
|
||||
match before.map(|value| return &value.state) {
|
||||
std::option::Option::Some(
|
||||
crate::SolanaProgramMetadataObservedAccountState::Metadata(value),
|
||||
) => metadata.data == value.data && metadata.data_source == value.data_source,
|
||||
_ => false,
|
||||
}
|
||||
},
|
||||
ks_lib::ExMetadataSpmSetDataSource::Inline { data } => data_matches(data, &metadata.data),
|
||||
ks_lib::ExMetadataSpmSetDataSource::Buffer { buffer, data_source } => {
|
||||
if metadata.data_source.wire_value() != data_source.wire_value() {
|
||||
return false;
|
||||
}
|
||||
let source_snapshot = match find_snapshot(before_all, buffer) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return false,
|
||||
};
|
||||
let bytes = match &source_snapshot.state {
|
||||
crate::SolanaProgramMetadataObservedAccountState::Buffer(value) => {
|
||||
value.data.as_slice()
|
||||
},
|
||||
_ => return false,
|
||||
};
|
||||
return match &metadata.data {
|
||||
ks_lib::DcMetadataSpmData::Direct(value) => value.as_slice() == bytes,
|
||||
ks_lib::DcMetadataSpmData::Url(value) => value.as_bytes() == bytes,
|
||||
ks_lib::DcMetadataSpmData::External(value) => &value.to_wire_bytes()[..] == bytes,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn trim_postcondition(
|
||||
before: std::option::Option<&crate::SolanaProgramMetadataStatefulReadResult>,
|
||||
after: std::option::Option<&crate::SolanaProgramMetadataStatefulReadResult>,
|
||||
) -> bool {
|
||||
let before = match before {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return false,
|
||||
};
|
||||
let after = match after {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return false,
|
||||
};
|
||||
let same_kind = before.state.code() == after.state.code();
|
||||
let size_reduced = match (before.space, after.space) {
|
||||
(std::option::Option::Some(before_space), std::option::Option::Some(after_space)) => {
|
||||
after_space <= before_space
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
return same_kind && size_reduced;
|
||||
}
|
||||
|
||||
fn allocate_postcondition(
|
||||
authority: &ks_lib::MdPubkey,
|
||||
seed: std::option::Option<&[u8]>,
|
||||
canonical: bool,
|
||||
after: std::option::Option<&crate::SolanaProgramMetadataStatefulReadResult>,
|
||||
) -> bool {
|
||||
let buffer = match after.map(|value| return &value.state) {
|
||||
std::option::Option::Some(crate::SolanaProgramMetadataObservedAccountState::Buffer(
|
||||
value,
|
||||
)) => value,
|
||||
_ => return false,
|
||||
};
|
||||
if buffer.authority.as_deref() != std::option::Option::Some(authority.0.as_str()) {
|
||||
return false;
|
||||
}
|
||||
return match seed {
|
||||
std::option::Option::Some(value) => {
|
||||
buffer.canonical == canonical && &buffer.seed.as_bytes()[..] == value
|
||||
},
|
||||
std::option::Option::None => !buffer.canonical,
|
||||
};
|
||||
}
|
||||
|
||||
fn extend_postcondition(
|
||||
length: u16,
|
||||
before: std::option::Option<&crate::SolanaProgramMetadataStatefulReadResult>,
|
||||
after: std::option::Option<&crate::SolanaProgramMetadataStatefulReadResult>,
|
||||
) -> bool {
|
||||
let before = match before {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return false,
|
||||
};
|
||||
let after = match after {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return false,
|
||||
};
|
||||
let expected = match before.space.and_then(|value| return value.checked_add(u64::from(length)))
|
||||
{
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return false,
|
||||
};
|
||||
return before.state.code() == after.state.code()
|
||||
&& after.space == std::option::Option::Some(expected);
|
||||
}
|
||||
|
||||
fn data_matches(
|
||||
input: &ks_lib::ExMetadataSpmDataInput,
|
||||
observed: &ks_lib::DcMetadataSpmData,
|
||||
) -> bool {
|
||||
return match (input, observed) {
|
||||
(
|
||||
ks_lib::ExMetadataSpmDataInput::Direct { bytes },
|
||||
ks_lib::DcMetadataSpmData::Direct(value),
|
||||
) => bytes == value,
|
||||
(ks_lib::ExMetadataSpmDataInput::Url { url }, ks_lib::DcMetadataSpmData::Url(value)) => {
|
||||
url == value
|
||||
},
|
||||
(
|
||||
ks_lib::ExMetadataSpmDataInput::External { address, offset, length },
|
||||
ks_lib::DcMetadataSpmData::External(value),
|
||||
) => {
|
||||
value.address.to_string().as_str() == address.0.as_str()
|
||||
&& value.offset == *offset
|
||||
&& value.length.map(std::num::NonZeroU32::get) == *length
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn pubkey(byte: u8) -> ks_lib::MdPubkey {
|
||||
return ks_lib::MdPubkey(solana_pubkey::Pubkey::new_from_array([byte; 32]).to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_requires_exact_simulation_and_resolved_signers() {
|
||||
let signer = pubkey(1);
|
||||
let plan = ks_lib::ExApiPreparedExecutionPlan {
|
||||
executor_name: "kb-lib.executor.metadata.solana_program_metadata".to_string(),
|
||||
executor_version: "0.4.8".to_string(),
|
||||
intent_id: "spm-readiness".to_string(),
|
||||
operation_code: ks_lib::EX_METADATA_SPM_WRITE_OPERATION.to_string(),
|
||||
fee_payer: signer.clone(),
|
||||
instructions: vec![ks_lib::ExApiPlannedInstruction {
|
||||
program_id: ks_lib::MdProgramId(
|
||||
ks_program_ids::METADATA_SOLANA_PROGRAM_METADATA_PROGRAM_ID.to_string(),
|
||||
),
|
||||
operation_code: ks_lib::EX_METADATA_SPM_WRITE_OPERATION.to_string(),
|
||||
accounts: vec![],
|
||||
data: vec![0],
|
||||
}],
|
||||
required_signers: vec![ks_lib::ExApiRequiredSigner {
|
||||
pubkey: signer.clone(),
|
||||
role: "authority".to_string(),
|
||||
}],
|
||||
policy: ks_lib::ExApiExecutionPolicy {
|
||||
dry_run: false,
|
||||
..ks_lib::ExApiExecutionPolicy::default()
|
||||
},
|
||||
requested_spend_lamports: 0,
|
||||
requested_compute_unit_price_micro_lamports: std::option::Option::None,
|
||||
};
|
||||
let request = crate::SolanaProgramMetadataExecutionReadinessRequest {
|
||||
plan,
|
||||
preflight: crate::SolanaProgramMetadataPreflightReport {
|
||||
operation_code: ks_lib::EX_METADATA_SPM_WRITE_OPERATION.to_string(),
|
||||
target_account: pubkey(2),
|
||||
context_slot: 99,
|
||||
inspected_accounts: vec![],
|
||||
safety: ks_lib::ExSafetyEvaluation {
|
||||
decision: ks_lib::ExSafetyDecision::Allow,
|
||||
violations: vec![],
|
||||
},
|
||||
runtime_authority_validation_required: false,
|
||||
checks: vec![],
|
||||
},
|
||||
message_hash: "message".to_string(),
|
||||
simulated_message_hash: "message".to_string(),
|
||||
simulated: true,
|
||||
simulation_succeeded: true,
|
||||
resolved_signers: vec![signer],
|
||||
submit: true,
|
||||
operator_confirmed: true,
|
||||
};
|
||||
let report = crate::validate_solana_program_metadata_execution_readiness(&request);
|
||||
assert!(report.is_ok());
|
||||
if let std::result::Result::Ok(value) = report {
|
||||
assert!(value.send_authorized);
|
||||
assert_eq!(value.context_slot, 99);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_matrix_is_machine_readable_and_matches_nine_operations() {
|
||||
let matrix_result = serde_json::from_str::<serde_json::Value>(include_str!(
|
||||
"../../test-fixtures/contract-matrices/METADATA_SOLANA_PROGRAM_PIPELINE_MATRIX.json"
|
||||
));
|
||||
assert!(matrix_result.is_ok());
|
||||
if let std::result::Result::Ok(matrix) = matrix_result {
|
||||
assert_eq!(
|
||||
matrix["programId"],
|
||||
ks_program_ids::METADATA_SOLANA_PROGRAM_METADATA_PROGRAM_ID
|
||||
);
|
||||
assert_eq!(
|
||||
matrix["operations"].as_array().map(std::vec::Vec::len),
|
||||
std::option::Option::Some(ks_lib::EX_METADATA_SPM_SUPPORTED_OPERATION_CODES.len())
|
||||
);
|
||||
assert_eq!(matrix["pipelineCrate"], "ks-pipeline");
|
||||
assert_eq!(matrix["demoScenarioCrate"], "ks-pipeline-demo-scenarios");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_stable_operation_has_one_explicit_postcondition_path() {
|
||||
let account = pubkey(4);
|
||||
let authority = pubkey(5);
|
||||
let program = pubkey(6);
|
||||
let destination = pubkey(7);
|
||||
let operations = vec![
|
||||
ks_lib::ExMetadataSpmOperation::Write {
|
||||
buffer: account.clone(),
|
||||
authority: authority.clone(),
|
||||
offset: 0,
|
||||
source: ks_lib::ExMetadataSpmWriteSource::Inline { data: vec![1] },
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::Initialize {
|
||||
metadata: account.clone(),
|
||||
authority: authority.clone(),
|
||||
program: program.clone(),
|
||||
program_data: std::option::Option::None,
|
||||
canonical: false,
|
||||
seed: vec![0_u8; ks_lib::DC_METADATA_SPM_SEED_BYTES],
|
||||
encoding: ks_lib::ExMetadataSpmEncoding::Utf8,
|
||||
compression: ks_lib::ExMetadataSpmCompression::None,
|
||||
format: ks_lib::ExMetadataSpmFormat::Json,
|
||||
data_source: ks_lib::ExMetadataSpmDataSource::Direct,
|
||||
data: std::option::Option::Some(ks_lib::ExMetadataSpmDataInput::Direct {
|
||||
bytes: vec![1],
|
||||
}),
|
||||
allocate_account: true,
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::SetAuthority {
|
||||
account: account.clone(),
|
||||
authority: authority.clone(),
|
||||
new_authority: std::option::Option::Some(pubkey(8)),
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::SetData {
|
||||
metadata: account.clone(),
|
||||
authority: authority.clone(),
|
||||
encoding: ks_lib::ExMetadataSpmEncoding::Utf8,
|
||||
compression: ks_lib::ExMetadataSpmCompression::None,
|
||||
format: ks_lib::ExMetadataSpmFormat::Json,
|
||||
source: ks_lib::ExMetadataSpmSetDataSource::PreserveExisting,
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::SetImmutable {
|
||||
metadata: account.clone(),
|
||||
authority: authority.clone(),
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::Trim {
|
||||
account: account.clone(),
|
||||
authority: authority.clone(),
|
||||
destination: destination.clone(),
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::Close {
|
||||
account: account.clone(),
|
||||
authority: authority.clone(),
|
||||
destination,
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::Allocate {
|
||||
buffer: account.clone(),
|
||||
authority: authority.clone(),
|
||||
seed: std::option::Option::None,
|
||||
program_context: std::option::Option::None,
|
||||
canonical: false,
|
||||
allocate_account: true,
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::Extend {
|
||||
account,
|
||||
authority,
|
||||
length: 1,
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
];
|
||||
let mut codes = operations
|
||||
.iter()
|
||||
.map(|operation| return operation.operation_code())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
codes.sort();
|
||||
let mut expected = ks_lib::EX_METADATA_SPM_SUPPORTED_OPERATION_CODES.to_vec();
|
||||
expected.sort();
|
||||
assert_eq!(codes, expected);
|
||||
for operation in operations {
|
||||
let report = crate::inspect_solana_program_metadata_post_execution(
|
||||
&crate::SolanaProgramMetadataPostExecutionRequest {
|
||||
operation,
|
||||
before: vec![],
|
||||
after: vec![],
|
||||
},
|
||||
);
|
||||
assert!(report.is_ok());
|
||||
if let std::result::Result::Ok(value) = report {
|
||||
assert_eq!(
|
||||
value.status,
|
||||
crate::SolanaProgramMetadataPostconditionStatus::NotApplicable
|
||||
);
|
||||
assert_eq!(value.postconditions.len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postcondition_summary_never_invents_success() {
|
||||
let account = pubkey(3);
|
||||
let item = crate::SolanaProgramMetadataPostcondition {
|
||||
code: "spm.none".to_string(),
|
||||
account,
|
||||
status: crate::SolanaProgramMetadataPostconditionStatus::NotApplicable,
|
||||
diagnostic: "missing after state".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
crate::summarize_solana_program_metadata_postconditions(&[item]),
|
||||
crate::SolanaProgramMetadataPostconditionStatus::NotApplicable
|
||||
);
|
||||
}
|
||||
}
|
||||
761
ks-pipeline/src/metadata_solana_program_preflight.rs
Normal file
761
ks-pipeline/src/metadata_solana_program_preflight.rs
Normal file
@@ -0,0 +1,761 @@
|
||||
// file: ks-pipeline/src/metadata_solana_program_preflight.rs
|
||||
// version: 3
|
||||
|
||||
//! Stateful preflight for Solana Program Metadata execution plans.
|
||||
|
||||
/// Maximum number of bounded account snapshots accepted by one preflight.
|
||||
pub const MAX_SOLANA_PROGRAM_METADATA_PREFLIGHT_ACCOUNTS: usize = 16;
|
||||
|
||||
/// One rent-exemption observation supplied by the caller for a growth operation.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct SolanaProgramMetadataRentObservation {
|
||||
/// Account whose allocation or growth is being validated.
|
||||
pub account: ks_lib::MdPubkey,
|
||||
/// Target account-data size used for the rent query.
|
||||
pub target_space: u64,
|
||||
/// Lamports observed on the account before execution.
|
||||
pub observed_lamports: u64,
|
||||
/// Minimum lamports returned for `target_space`.
|
||||
pub required_lamports: u64,
|
||||
}
|
||||
|
||||
impl crate::SolanaProgramMetadataRentObservation {
|
||||
/// Returns whether the account is sufficiently pre-funded.
|
||||
pub const fn is_satisfied(&self) -> bool {
|
||||
return self.observed_lamports >= self.required_lamports;
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete stateful preflight request for one prepared Program Metadata plan.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolanaProgramMetadataPreflightRequest {
|
||||
/// Exact typed intent used to build the plan.
|
||||
pub intent: ks_lib::ExMetadataSpmExecutionIntent,
|
||||
/// Exact prepared plan produced by `ks-lib`.
|
||||
pub plan: ks_lib::ExApiPreparedExecutionPlan,
|
||||
/// Confirmed account snapshots observed before simulation.
|
||||
pub before: std::vec::Vec<crate::SolanaProgramMetadataStatefulReadResult>,
|
||||
/// Explicit rent observations required by account growth operations.
|
||||
pub rent_observations: std::vec::Vec<crate::SolanaProgramMetadataRentObservation>,
|
||||
}
|
||||
|
||||
/// Deterministic Program Metadata preflight report.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct SolanaProgramMetadataPreflightReport {
|
||||
/// Stable operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Primary account mutated by the instruction.
|
||||
pub target_account: ks_lib::MdPubkey,
|
||||
/// Highest confirmed context slot across supplied snapshots.
|
||||
pub context_slot: u64,
|
||||
/// Canonical accounts inspected by the preflight.
|
||||
pub inspected_accounts: std::vec::Vec<ks_lib::MdPubkey>,
|
||||
/// Safety decision applied before simulation.
|
||||
pub safety: ks_lib::ExSafetyEvaluation,
|
||||
/// Whether the on-chain program must validate an upgrade authority at runtime.
|
||||
pub runtime_authority_validation_required: bool,
|
||||
/// Ordered successful checks.
|
||||
pub checks: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// Validates one Program Metadata plan against confirmed pre-execution state.
|
||||
pub fn inspect_solana_program_metadata_preflight(
|
||||
request: &crate::SolanaProgramMetadataPreflightRequest,
|
||||
) -> ks_core::Result<crate::SolanaProgramMetadataPreflightReport> {
|
||||
if request.before.len() > crate::MAX_SOLANA_PROGRAM_METADATA_PREFLIGHT_ACCOUNTS {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_account_limit_exceeded",
|
||||
"Solana Program Metadata preflight account limit exceeded",
|
||||
));
|
||||
}
|
||||
let operation_code = request.intent.operation.operation_code();
|
||||
if request.plan.operation_code != operation_code {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_operation_mismatch",
|
||||
"Program Metadata intent and plan operation codes must match",
|
||||
));
|
||||
}
|
||||
if request.plan.instructions.len() != 1 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_instruction_count_invalid",
|
||||
"Program Metadata plans must contain exactly one instruction",
|
||||
));
|
||||
}
|
||||
let instruction = match request.plan.instructions.first() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_instruction_missing",
|
||||
"Program Metadata plan instruction is missing",
|
||||
));
|
||||
},
|
||||
};
|
||||
if instruction.program_id.0 != ks_program_ids::METADATA_SOLANA_PROGRAM_METADATA_PROGRAM_ID
|
||||
|| instruction.operation_code != operation_code
|
||||
|| !ks_lib::EX_METADATA_SPM_SUPPORTED_OPERATION_CODES.contains(&operation_code)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_instruction_contract_mismatch",
|
||||
"Program Metadata plan does not preserve the exact program and operation contract",
|
||||
));
|
||||
}
|
||||
if request.intent.operation.requires_explicit_approval()
|
||||
&& !request.intent.allow_destructive_operation
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_destructive_approval_required",
|
||||
"destructive Program Metadata execution requires explicit approval",
|
||||
));
|
||||
}
|
||||
let safety = match ks_lib::ExSafetyChecker.evaluate_prepared_plan(&request.plan) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if safety.decision == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_safety_denied",
|
||||
safety_violation_codes(safety.violations.as_slice()),
|
||||
));
|
||||
}
|
||||
let mut snapshots = std::collections::BTreeMap::<
|
||||
std::string::String,
|
||||
&crate::SolanaProgramMetadataStatefulReadResult,
|
||||
>::new();
|
||||
let mut context_slot = 0_u64;
|
||||
for snapshot in &request.before {
|
||||
if snapshot.commitment != "confirmed" {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_commitment_mismatch",
|
||||
"Program Metadata stateful snapshots must use confirmed commitment",
|
||||
));
|
||||
}
|
||||
if snapshots.insert(snapshot.account.0.clone(), snapshot).is_some() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_duplicate_account",
|
||||
format!("duplicate Program Metadata snapshot for {}", snapshot.account.0),
|
||||
));
|
||||
}
|
||||
context_slot = context_slot.max(snapshot.context_slot);
|
||||
}
|
||||
let target_account = operation_target(&request.intent.operation).clone();
|
||||
let target = match snapshots.get(target_account.0.as_str()) {
|
||||
std::option::Option::Some(value) => *value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_target_snapshot_missing",
|
||||
format!("missing preflight snapshot for {}", target_account.0),
|
||||
));
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_target_state(&request.intent.operation, target)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_source_state(&request.intent.operation, &snapshots, target)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let runtime_authority_validation_required =
|
||||
match validate_authority_state(&request.intent.operation, target) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = validate_rent_observations(
|
||||
&request.intent.operation,
|
||||
target,
|
||||
&snapshots,
|
||||
request.rent_observations.as_slice(),
|
||||
) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let inspected_accounts = snapshots
|
||||
.values()
|
||||
.map(|value| return value.account.clone())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
tracing::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "inspect_solana_program_metadata_preflight",
|
||||
operation_code,
|
||||
target_account = %target_account.0,
|
||||
context_slot,
|
||||
inspected_account_count = inspected_accounts.len(),
|
||||
runtime_authority_validation_required,
|
||||
safety_decision = ?safety.decision,
|
||||
"validated Solana Program Metadata stateful preflight"
|
||||
);
|
||||
return std::result::Result::Ok(crate::SolanaProgramMetadataPreflightReport {
|
||||
operation_code: operation_code.to_string(),
|
||||
target_account,
|
||||
context_slot,
|
||||
inspected_accounts,
|
||||
safety,
|
||||
runtime_authority_validation_required,
|
||||
checks: vec![
|
||||
"program_id_exact".to_string(),
|
||||
"operation_code_exact".to_string(),
|
||||
"single_instruction_plan".to_string(),
|
||||
"confirmed_state_snapshots".to_string(),
|
||||
"target_state_compatible".to_string(),
|
||||
"source_state_compatible".to_string(),
|
||||
"authority_contract_checked".to_string(),
|
||||
"rent_prefunding_checked_when_required".to_string(),
|
||||
"common_safety_checked_before_simulation".to_string(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
fn operation_target(operation: &ks_lib::ExMetadataSpmOperation) -> &ks_lib::MdPubkey {
|
||||
return match operation {
|
||||
ks_lib::ExMetadataSpmOperation::Write { buffer, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::Allocate { buffer, .. } => buffer,
|
||||
ks_lib::ExMetadataSpmOperation::Initialize { metadata, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::SetData { metadata, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::SetImmutable { metadata, .. } => metadata,
|
||||
ks_lib::ExMetadataSpmOperation::SetAuthority { account, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::Trim { account, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::Close { account, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::Extend { account, .. } => account,
|
||||
};
|
||||
}
|
||||
|
||||
fn validate_target_state(
|
||||
operation: &ks_lib::ExMetadataSpmOperation,
|
||||
target: &crate::SolanaProgramMetadataStatefulReadResult,
|
||||
) -> ks_core::Result<()> {
|
||||
let valid = match operation {
|
||||
ks_lib::ExMetadataSpmOperation::Write { .. } => {
|
||||
matches!(&target.state, crate::SolanaProgramMetadataObservedAccountState::Buffer(_))
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::Initialize { data, .. } => {
|
||||
if data.is_some() {
|
||||
is_prefunded_uninitialized(target)
|
||||
} else {
|
||||
matches!(&target.state, crate::SolanaProgramMetadataObservedAccountState::Buffer(_))
|
||||
}
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::SetData { .. }
|
||||
| ks_lib::ExMetadataSpmOperation::SetImmutable { .. } => {
|
||||
matches!(&target.state, crate::SolanaProgramMetadataObservedAccountState::Metadata(_))
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::Allocate { .. } => is_prefunded_uninitialized(target),
|
||||
ks_lib::ExMetadataSpmOperation::SetAuthority { .. }
|
||||
| ks_lib::ExMetadataSpmOperation::Trim { .. }
|
||||
| ks_lib::ExMetadataSpmOperation::Close { .. }
|
||||
| ks_lib::ExMetadataSpmOperation::Extend { .. } => matches!(
|
||||
&target.state,
|
||||
crate::SolanaProgramMetadataObservedAccountState::Buffer(_)
|
||||
| crate::SolanaProgramMetadataObservedAccountState::Metadata(_)
|
||||
),
|
||||
};
|
||||
if !valid {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_target_state_invalid",
|
||||
format!(
|
||||
"operation {} cannot use target state {}",
|
||||
operation.operation_code(),
|
||||
target.state.code()
|
||||
),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn is_prefunded_uninitialized(target: &crate::SolanaProgramMetadataStatefulReadResult) -> bool {
|
||||
return match &target.state {
|
||||
crate::SolanaProgramMetadataObservedAccountState::Uninitialized {
|
||||
owner,
|
||||
lamports,
|
||||
space: _,
|
||||
} => {
|
||||
*lamports > 0
|
||||
&& (owner.0 == ks_program_ids::SYSTEM_PROGRAM_ID
|
||||
|| owner.0 == ks_program_ids::METADATA_SOLANA_PROGRAM_METADATA_PROGRAM_ID)
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn validate_source_state(
|
||||
operation: &ks_lib::ExMetadataSpmOperation,
|
||||
snapshots: &std::collections::BTreeMap<
|
||||
std::string::String,
|
||||
&crate::SolanaProgramMetadataStatefulReadResult,
|
||||
>,
|
||||
target: &crate::SolanaProgramMetadataStatefulReadResult,
|
||||
) -> ks_core::Result<()> {
|
||||
match operation {
|
||||
ks_lib::ExMetadataSpmOperation::Write { offset, source, .. } => {
|
||||
let target_buffer = match &target.state {
|
||||
crate::SolanaProgramMetadataObservedAccountState::Buffer(value) => value,
|
||||
_ => return std::result::Result::Ok(()),
|
||||
};
|
||||
let source_length = match source {
|
||||
ks_lib::ExMetadataSpmWriteSource::Inline { data } => data.len(),
|
||||
ks_lib::ExMetadataSpmWriteSource::Buffer { source_buffer } => {
|
||||
let source_snapshot = match snapshots.get(source_buffer.0.as_str()) {
|
||||
std::option::Option::Some(value) => *value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_source_buffer_missing",
|
||||
format!("missing source Buffer snapshot for {}", source_buffer.0),
|
||||
));
|
||||
},
|
||||
};
|
||||
match &source_snapshot.state {
|
||||
crate::SolanaProgramMetadataObservedAccountState::Buffer(value) => {
|
||||
value.data.len()
|
||||
},
|
||||
_ => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_source_buffer_invalid",
|
||||
"Write source account must be an initialized Buffer",
|
||||
));
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
let offset = match usize::try_from(*offset) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_write_offset_invalid",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
if offset.saturating_add(source_length) > target_buffer.allocated_data_bytes {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_write_out_of_bounds",
|
||||
"Write source exceeds the allocated Buffer capacity",
|
||||
));
|
||||
}
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::SetData {
|
||||
source: ks_lib::ExMetadataSpmSetDataSource::Buffer { buffer, .. },
|
||||
..
|
||||
} => {
|
||||
let source_snapshot = match snapshots.get(buffer.0.as_str()) {
|
||||
std::option::Option::Some(value) => *value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_source_buffer_missing",
|
||||
format!("missing source Buffer snapshot for {}", buffer.0),
|
||||
));
|
||||
},
|
||||
};
|
||||
if !matches!(
|
||||
&source_snapshot.state,
|
||||
crate::SolanaProgramMetadataObservedAccountState::Buffer(_)
|
||||
) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_source_buffer_invalid",
|
||||
"SetData source account must be an initialized Buffer",
|
||||
));
|
||||
}
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_authority_state(
|
||||
operation: &ks_lib::ExMetadataSpmOperation,
|
||||
target: &crate::SolanaProgramMetadataStatefulReadResult,
|
||||
) -> ks_core::Result<bool> {
|
||||
let (authority, has_program_context) = match operation {
|
||||
ks_lib::ExMetadataSpmOperation::Write { authority: _, .. } => {
|
||||
return std::result::Result::Ok(false);
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::Initialize { canonical, .. } => {
|
||||
return std::result::Result::Ok(*canonical);
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::Allocate { seed, canonical, .. } => {
|
||||
return std::result::Result::Ok(seed.is_some() && *canonical);
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::SetAuthority { authority, program_context, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::SetData { authority, program_context, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::SetImmutable { authority, program_context, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::Trim { authority, program_context, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::Close { authority, program_context, .. }
|
||||
| ks_lib::ExMetadataSpmOperation::Extend { authority, program_context, .. } => {
|
||||
(authority, program_context.is_some())
|
||||
},
|
||||
};
|
||||
let (current_authority, canonical) = match &target.state {
|
||||
crate::SolanaProgramMetadataObservedAccountState::Buffer(value) => {
|
||||
(value.authority.as_deref(), value.canonical)
|
||||
},
|
||||
crate::SolanaProgramMetadataObservedAccountState::Metadata(value) => {
|
||||
(value.authority.as_deref(), value.canonical)
|
||||
},
|
||||
_ => return std::result::Result::Ok(false),
|
||||
};
|
||||
if current_authority == std::option::Option::Some(authority.0.as_str()) {
|
||||
return std::result::Result::Ok(false);
|
||||
}
|
||||
if canonical && has_program_context {
|
||||
return std::result::Result::Ok(true);
|
||||
}
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_authority_mismatch",
|
||||
"the supplied authority does not match account state and no canonical program context was provided",
|
||||
));
|
||||
}
|
||||
|
||||
fn validate_rent_observations(
|
||||
operation: &ks_lib::ExMetadataSpmOperation,
|
||||
target: &crate::SolanaProgramMetadataStatefulReadResult,
|
||||
snapshots: &std::collections::BTreeMap<
|
||||
std::string::String,
|
||||
&crate::SolanaProgramMetadataStatefulReadResult,
|
||||
>,
|
||||
observations: &[crate::SolanaProgramMetadataRentObservation],
|
||||
) -> ks_core::Result<()> {
|
||||
let expected_space = match expected_rent_target_space(operation, target, snapshots) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let expected_space = match expected_space {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Ok(()),
|
||||
};
|
||||
let observation = match observations.iter().find(|value| return value.account == target.account)
|
||||
{
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_rent_observation_missing",
|
||||
format!("missing rent observation for {}", target.account.0),
|
||||
));
|
||||
},
|
||||
};
|
||||
let observed_lamports = match target.lamports {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => 0,
|
||||
};
|
||||
if observation.target_space != expected_space
|
||||
|| observation.observed_lamports != observed_lamports
|
||||
|| !observation.is_satisfied()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_rent_not_satisfied",
|
||||
format!(
|
||||
"account {} has {} lamports but requires {} for exact target space {}",
|
||||
observation.account.0,
|
||||
observation.observed_lamports,
|
||||
observation.required_lamports,
|
||||
expected_space
|
||||
),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn expected_rent_target_space(
|
||||
operation: &ks_lib::ExMetadataSpmOperation,
|
||||
target: &crate::SolanaProgramMetadataStatefulReadResult,
|
||||
snapshots: &std::collections::BTreeMap<
|
||||
std::string::String,
|
||||
&crate::SolanaProgramMetadataStatefulReadResult,
|
||||
>,
|
||||
) -> ks_core::Result<std::option::Option<u64>> {
|
||||
let header = match u64::try_from(ks_lib::DC_METADATA_SPM_METADATA_HEADER_BYTES) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_header_size_overflow",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
return match operation {
|
||||
ks_lib::ExMetadataSpmOperation::Initialize {
|
||||
data: std::option::Option::Some(data),
|
||||
..
|
||||
} => add_metadata_header(header, metadata_input_wire_len(data)),
|
||||
ks_lib::ExMetadataSpmOperation::SetData {
|
||||
source: ks_lib::ExMetadataSpmSetDataSource::PreserveExisting,
|
||||
..
|
||||
} => std::result::Result::Ok(std::option::Option::None),
|
||||
ks_lib::ExMetadataSpmOperation::SetData {
|
||||
source: ks_lib::ExMetadataSpmSetDataSource::Inline { data },
|
||||
..
|
||||
} => add_metadata_header(header, metadata_input_wire_len(data)),
|
||||
ks_lib::ExMetadataSpmOperation::SetData {
|
||||
source: ks_lib::ExMetadataSpmSetDataSource::Buffer { buffer, .. },
|
||||
..
|
||||
} => {
|
||||
let source = match snapshots.get(buffer.0.as_str()) {
|
||||
std::option::Option::Some(value) => *value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_source_buffer_missing",
|
||||
format!("missing source Buffer snapshot for {}", buffer.0),
|
||||
));
|
||||
},
|
||||
};
|
||||
let length = match &source.state {
|
||||
crate::SolanaProgramMetadataObservedAccountState::Buffer(value) => {
|
||||
match u64::try_from(value.data.len()) {
|
||||
std::result::Result::Ok(length) => length,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_source_length_overflow",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_source_buffer_invalid",
|
||||
"SetData source account must be an initialized Buffer",
|
||||
));
|
||||
},
|
||||
};
|
||||
return add_metadata_header(header, std::result::Result::Ok(length));
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::Allocate { .. } => {
|
||||
let current = match target.space {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => 0,
|
||||
};
|
||||
std::result::Result::Ok(std::option::Option::Some(current.max(header)))
|
||||
},
|
||||
ks_lib::ExMetadataSpmOperation::Extend { length, .. } => {
|
||||
let current = match target.space {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_target_space_missing",
|
||||
"Extend requires the current account space",
|
||||
));
|
||||
},
|
||||
};
|
||||
return match current.checked_add(u64::from(*length)) {
|
||||
std::option::Option::Some(value) => {
|
||||
std::result::Result::Ok(std::option::Option::Some(value))
|
||||
},
|
||||
std::option::Option::None => std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_target_space_overflow",
|
||||
"Extend target space overflowed u64",
|
||||
)),
|
||||
};
|
||||
},
|
||||
_ => std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
}
|
||||
|
||||
fn metadata_input_wire_len(data: &ks_lib::ExMetadataSpmDataInput) -> ks_core::Result<u64> {
|
||||
let length = match data {
|
||||
ks_lib::ExMetadataSpmDataInput::Direct { bytes } => bytes.len(),
|
||||
ks_lib::ExMetadataSpmDataInput::Url { url } => url.len(),
|
||||
ks_lib::ExMetadataSpmDataInput::External { .. } => {
|
||||
ks_lib::DC_METADATA_SPM_EXTERNAL_DATA_BYTES
|
||||
},
|
||||
};
|
||||
return match u64::try_from(length) {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_data_length_overflow",
|
||||
error.to_string(),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn add_metadata_header(
|
||||
header: u64,
|
||||
data_length: ks_core::Result<u64>,
|
||||
) -> ks_core::Result<std::option::Option<u64>> {
|
||||
let data_length = match data_length {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return match header.checked_add(data_length) {
|
||||
std::option::Option::Some(value) => {
|
||||
std::result::Result::Ok(std::option::Option::Some(value))
|
||||
},
|
||||
std::option::Option::None => std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_preflight_target_space_overflow",
|
||||
"Program Metadata target space overflowed u64",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn safety_violation_codes(violations: &[ks_lib::ExSafetyViolation]) -> std::string::String {
|
||||
if violations.is_empty() {
|
||||
return "Program Metadata execution safety denied without a diagnostic".to_string();
|
||||
}
|
||||
return violations
|
||||
.iter()
|
||||
.map(|value| return value.code.as_str())
|
||||
.collect::<std::vec::Vec<_>>()
|
||||
.join(",");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn pubkey(value: u8) -> ks_lib::MdPubkey {
|
||||
return ks_lib::MdPubkey(solana_pubkey::Pubkey::new_from_array([value; 32]).to_string());
|
||||
}
|
||||
|
||||
fn policy(authority: &ks_lib::MdPubkey) -> ks_lib::ExApiExecutionPolicy {
|
||||
return ks_lib::ExApiExecutionPolicy {
|
||||
cost_limit: ks_lib::ExApiExecutionCostLimit {
|
||||
max_spend_lamports: std::option::Option::Some(0),
|
||||
max_fee_lamports: std::option::Option::Some(20_000),
|
||||
max_compute_unit_price_micro_lamports: std::option::Option::None,
|
||||
},
|
||||
authorized_signers: vec![authority.clone()],
|
||||
post_execution_validation: ks_lib::ExApiPostExecutionValidationPolicy {
|
||||
canonical_insert_required: true,
|
||||
core_extraction_required: true,
|
||||
decode_replay_required: true,
|
||||
materialization_required: true,
|
||||
},
|
||||
..ks_lib::ExApiExecutionPolicy::default()
|
||||
};
|
||||
}
|
||||
|
||||
fn buffer_read(
|
||||
account: ks_lib::MdPubkey,
|
||||
authority: ks_lib::MdPubkey,
|
||||
) -> crate::SolanaProgramMetadataStatefulReadResult {
|
||||
return crate::SolanaProgramMetadataStatefulReadResult {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot: 42,
|
||||
account: account.clone(),
|
||||
lamports: std::option::Option::Some(1_000),
|
||||
owner: std::option::Option::Some(ks_lib::MdProgramId(
|
||||
ks_program_ids::METADATA_SOLANA_PROGRAM_METADATA_PROGRAM_ID.to_string(),
|
||||
)),
|
||||
space: std::option::Option::Some(100),
|
||||
state: crate::SolanaProgramMetadataObservedAccountState::Buffer(
|
||||
ks_lib::DcMetadataSpmBufferAccountSnapshot {
|
||||
account: account.0,
|
||||
program: std::option::Option::None,
|
||||
authority: std::option::Option::Some(authority.0),
|
||||
canonical: false,
|
||||
seed: ks_lib::DcMetadataSpmSeed::default(),
|
||||
data: vec![0_u8; 4],
|
||||
allocated_data_bytes: 4,
|
||||
},
|
||||
),
|
||||
materialized_output: std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_preflight_requires_buffer_capacity_and_common_safety() {
|
||||
let authority = pubkey(2);
|
||||
let buffer = pubkey(3);
|
||||
let intent = ks_lib::ExMetadataSpmExecutionIntent {
|
||||
intent_id: "spm-preflight-write".to_string(),
|
||||
fee_payer: authority.clone(),
|
||||
policy: policy(&authority),
|
||||
allow_destructive_operation: true,
|
||||
operation: ks_lib::ExMetadataSpmOperation::Write {
|
||||
buffer: buffer.clone(),
|
||||
authority: authority.clone(),
|
||||
offset: 2,
|
||||
source: ks_lib::ExMetadataSpmWriteSource::Inline { data: vec![8, 9] },
|
||||
},
|
||||
};
|
||||
let plan = ks_lib::ExApiTypedInstructionExecutor::build_prepared_plan(
|
||||
&ks_lib::ExMetadataSolanaProgramMetadataExecutor,
|
||||
&intent,
|
||||
);
|
||||
assert!(plan.is_ok());
|
||||
let plan = match plan {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let report = crate::inspect_solana_program_metadata_preflight(
|
||||
&crate::SolanaProgramMetadataPreflightRequest {
|
||||
intent,
|
||||
plan,
|
||||
before: vec![buffer_read(buffer, authority)],
|
||||
rent_observations: vec![],
|
||||
},
|
||||
);
|
||||
assert!(report.is_ok());
|
||||
if let std::result::Result::Ok(value) = report {
|
||||
assert_eq!(value.operation_code, ks_lib::EX_METADATA_SPM_WRITE_OPERATION);
|
||||
assert_eq!(value.safety.decision, ks_lib::ExSafetyDecision::Allow);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocate_requires_vacancy_and_explicit_rent_evidence() {
|
||||
let authority = pubkey(4);
|
||||
let buffer = authority.clone();
|
||||
let intent = ks_lib::ExMetadataSpmExecutionIntent {
|
||||
intent_id: "spm-preflight-allocate".to_string(),
|
||||
fee_payer: authority.clone(),
|
||||
policy: policy(&authority),
|
||||
allow_destructive_operation: false,
|
||||
operation: ks_lib::ExMetadataSpmOperation::Allocate {
|
||||
buffer: buffer.clone(),
|
||||
authority: authority.clone(),
|
||||
seed: std::option::Option::None,
|
||||
program_context: std::option::Option::None,
|
||||
canonical: false,
|
||||
allocate_account: true,
|
||||
},
|
||||
};
|
||||
let plan = ks_lib::ExApiTypedInstructionExecutor::build_prepared_plan(
|
||||
&ks_lib::ExMetadataSolanaProgramMetadataExecutor,
|
||||
&intent,
|
||||
);
|
||||
assert!(plan.is_ok());
|
||||
let plan = match plan {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let before = crate::SolanaProgramMetadataStatefulReadResult {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot: 9,
|
||||
account: buffer.clone(),
|
||||
lamports: std::option::Option::Some(1_000),
|
||||
owner: std::option::Option::Some(ks_lib::MdProgramId(
|
||||
ks_program_ids::SYSTEM_PROGRAM_ID.to_string(),
|
||||
)),
|
||||
space: std::option::Option::Some(0),
|
||||
state: crate::SolanaProgramMetadataObservedAccountState::Uninitialized {
|
||||
owner: ks_lib::MdProgramId(ks_program_ids::SYSTEM_PROGRAM_ID.to_string()),
|
||||
lamports: 1_000,
|
||||
space: 0,
|
||||
},
|
||||
materialized_output: std::option::Option::None,
|
||||
};
|
||||
let missing_rent = crate::inspect_solana_program_metadata_preflight(
|
||||
&crate::SolanaProgramMetadataPreflightRequest {
|
||||
intent: intent.clone(),
|
||||
plan: plan.clone(),
|
||||
before: vec![before.clone()],
|
||||
rent_observations: vec![],
|
||||
},
|
||||
);
|
||||
assert!(missing_rent.is_err());
|
||||
let target_space = match u64::try_from(ks_lib::DC_METADATA_SPM_BUFFER_HEADER_BYTES) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let report = crate::inspect_solana_program_metadata_preflight(
|
||||
&crate::SolanaProgramMetadataPreflightRequest {
|
||||
intent,
|
||||
plan,
|
||||
before: vec![before],
|
||||
rent_observations: vec![crate::SolanaProgramMetadataRentObservation {
|
||||
account: buffer,
|
||||
target_space,
|
||||
observed_lamports: 1_000,
|
||||
required_lamports: 900,
|
||||
}],
|
||||
},
|
||||
);
|
||||
assert!(report.is_ok());
|
||||
}
|
||||
}
|
||||
442
ks-pipeline/src/metadata_solana_program_stateful.rs
Normal file
442
ks-pipeline/src/metadata_solana_program_stateful.rs
Normal file
@@ -0,0 +1,442 @@
|
||||
// file: ks-pipeline/src/metadata_solana_program_stateful.rs
|
||||
// version: 5
|
||||
|
||||
//! Bounded Solana Program Metadata account reads and authoritative state projections.
|
||||
|
||||
/// Maximum complete Solana Program Metadata account data accepted by one RPC read.
|
||||
///
|
||||
/// The decoder can validate larger account byte arrays supplied by offline or chunked sources, but
|
||||
/// the current execution RPC adapter intentionally bounds one complete `getAccountInfo` response.
|
||||
pub const MAX_SOLANA_PROGRAM_METADATA_STATEFUL_ACCOUNT_BYTES: usize =
|
||||
ks_onchain_transport::MAX_COMPLETE_ACCOUNT_DATA_BYTES;
|
||||
|
||||
/// Expected state of one Solana Program Metadata account read.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SolanaProgramMetadataExpectedAccountState {
|
||||
/// Accept every supported state.
|
||||
Any,
|
||||
/// Accept an absent or zeroed account before initialization.
|
||||
Vacant,
|
||||
/// Require an absent account.
|
||||
Missing,
|
||||
/// Require an initialized Buffer account.
|
||||
Buffer,
|
||||
/// Require an initialized Metadata account.
|
||||
Metadata,
|
||||
}
|
||||
|
||||
/// Supported state observed for one Solana Program Metadata account.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(tag = "state", content = "snapshot", rename_all = "snake_case")]
|
||||
pub enum SolanaProgramMetadataObservedAccountState {
|
||||
/// The address does not currently exist.
|
||||
Missing,
|
||||
/// The address exists but contains only zeroed or empty data and is not initialized.
|
||||
Uninitialized {
|
||||
/// Current owner before Program Metadata assignment.
|
||||
owner: ks_lib::MdProgramId,
|
||||
/// Current lamports retained by the account.
|
||||
lamports: u64,
|
||||
/// Current allocated account-data bytes.
|
||||
space: u64,
|
||||
},
|
||||
/// Decoded Program Metadata Buffer account.
|
||||
Buffer(ks_lib::DcMetadataSpmBufferAccountSnapshot),
|
||||
/// Decoded Program Metadata Metadata account.
|
||||
Metadata(ks_lib::DcMetadataSpmMetadataAccountSnapshot),
|
||||
}
|
||||
|
||||
impl crate::SolanaProgramMetadataObservedAccountState {
|
||||
/// Returns the stable lowercase state code.
|
||||
pub const fn code(&self) -> &'static str {
|
||||
return match self {
|
||||
Self::Missing => "missing",
|
||||
Self::Uninitialized { owner: _, lamports: _, space: _ } => "uninitialized",
|
||||
Self::Buffer(_) => "buffer",
|
||||
Self::Metadata(_) => "metadata",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns whether the state is absent or uninitialized.
|
||||
pub const fn is_vacant(&self) -> bool {
|
||||
return matches!(
|
||||
self,
|
||||
Self::Missing | Self::Uninitialized { owner: _, lamports: _, space: _ }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One bounded Solana Program Metadata account read request.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct SolanaProgramMetadataStatefulReadRequest {
|
||||
/// Endpoint role used for the HTTP RPC request.
|
||||
pub query_role: std::string::String,
|
||||
/// Account address to inspect.
|
||||
pub account: ks_lib::MdPubkey,
|
||||
/// Expected account state.
|
||||
pub expected_state: crate::SolanaProgramMetadataExpectedAccountState,
|
||||
/// Optional minimum RPC context slot.
|
||||
pub min_context_slot: std::option::Option<u64>,
|
||||
/// Maximum decoded account bytes accepted from the endpoint.
|
||||
pub max_data_bytes: usize,
|
||||
}
|
||||
|
||||
impl crate::SolanaProgramMetadataStatefulReadRequest {
|
||||
/// Validates the bounded request.
|
||||
pub fn validate(&self) -> ks_core::Result<()> {
|
||||
if self.query_role.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata stateful query_role must not be empty",
|
||||
));
|
||||
}
|
||||
let account_result = ks_onchain_transport::validate_solana_pubkey_text(
|
||||
self.account.0.as_str(),
|
||||
"Solana Program Metadata stateful account",
|
||||
);
|
||||
if let std::result::Result::Err(error) = account_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if self.max_data_bytes == 0
|
||||
|| self.max_data_bytes > crate::MAX_SOLANA_PROGRAM_METADATA_STATEFUL_ACCOUNT_BYTES
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Solana Program Metadata stateful max_data_bytes must be between 1 and {}",
|
||||
crate::MAX_SOLANA_PROGRAM_METADATA_STATEFUL_ACCOUNT_BYTES
|
||||
)));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
/// One bounded RPC read and its validated Program Metadata state.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct SolanaProgramMetadataStatefulReadResult {
|
||||
/// Commitment used by the read.
|
||||
pub commitment: std::string::String,
|
||||
/// Context slot returned by the endpoint.
|
||||
pub context_slot: u64,
|
||||
/// Account address requested by the caller.
|
||||
pub account: ks_lib::MdPubkey,
|
||||
/// Current lamports when the account exists.
|
||||
pub lamports: std::option::Option<u64>,
|
||||
/// Current owner when the account exists.
|
||||
pub owner: std::option::Option<ks_lib::MdProgramId>,
|
||||
/// Current account-data allocation when the account exists.
|
||||
pub space: std::option::Option<u64>,
|
||||
/// Validated account state.
|
||||
pub state: crate::SolanaProgramMetadataObservedAccountState,
|
||||
/// Authoritative materialized projection for Buffer or Metadata state.
|
||||
pub materialized_output: std::option::Option<ks_lib::MtApiMaterializedOutput>,
|
||||
}
|
||||
|
||||
/// Reads one bounded Solana Program Metadata account.
|
||||
pub async fn read_solana_program_metadata_stateful_snapshot(
|
||||
pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
request: &crate::SolanaProgramMetadataStatefulReadRequest,
|
||||
) -> ks_core::Result<crate::SolanaProgramMetadataStatefulReadResult> {
|
||||
if let std::result::Result::Err(error) = request.validate() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let config = match ks_onchain_transport::GetAccountInfoConfig::new_with_data(
|
||||
ks_onchain_transport::RpcCommitmentLevel::Confirmed,
|
||||
request.min_context_slot,
|
||||
request.max_data_bytes,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = match pool
|
||||
.get_account_info_for_role(request.query_role.as_str(), &request.account, &config)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return crate::materialize_solana_program_metadata_account_info_result(request, &result);
|
||||
}
|
||||
|
||||
/// Validates one RPC account response, decodes its state and emits the authoritative projection.
|
||||
pub fn materialize_solana_program_metadata_account_info_result(
|
||||
request: &crate::SolanaProgramMetadataStatefulReadRequest,
|
||||
result: &ks_onchain_transport::AccountInfoResult,
|
||||
) -> ks_core::Result<crate::SolanaProgramMetadataStatefulReadResult> {
|
||||
if let std::result::Result::Err(error) = request.validate() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::option::Option::Some(minimum) = request.min_context_slot {
|
||||
if result.context.slot < minimum {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_stateful_context_slot_too_old",
|
||||
"Solana Program Metadata account context slot is below the requested minimum",
|
||||
));
|
||||
}
|
||||
}
|
||||
let output = match result.account.as_ref() {
|
||||
std::option::Option::None => crate::SolanaProgramMetadataStatefulReadResult {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot: result.context.slot,
|
||||
account: request.account.clone(),
|
||||
lamports: std::option::Option::None,
|
||||
owner: std::option::Option::None,
|
||||
space: std::option::Option::None,
|
||||
state: crate::SolanaProgramMetadataObservedAccountState::Missing,
|
||||
materialized_output: std::option::Option::None,
|
||||
},
|
||||
std::option::Option::Some(account) => {
|
||||
if account.executable {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_stateful_account_executable",
|
||||
"Solana Program Metadata state accounts must not be executable",
|
||||
));
|
||||
}
|
||||
let data_len = match u64::try_from(account.data.len()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_stateful_account_length_overflow",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
if account.space != data_len || account.data.len() > request.max_data_bytes {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_stateful_account_data_invalid",
|
||||
"Solana Program Metadata account data is incomplete or above the configured bound",
|
||||
));
|
||||
}
|
||||
let zeroed = account.data.iter().all(|byte| return *byte == 0);
|
||||
if account.data.is_empty() || zeroed {
|
||||
crate::SolanaProgramMetadataStatefulReadResult {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot: result.context.slot,
|
||||
account: request.account.clone(),
|
||||
lamports: std::option::Option::Some(account.lamports),
|
||||
owner: std::option::Option::Some(account.owner.clone()),
|
||||
space: std::option::Option::Some(account.space),
|
||||
state: crate::SolanaProgramMetadataObservedAccountState::Uninitialized {
|
||||
owner: account.owner.clone(),
|
||||
lamports: account.lamports,
|
||||
space: account.space,
|
||||
},
|
||||
materialized_output: std::option::Option::None,
|
||||
}
|
||||
} else {
|
||||
if account.owner.0 != ks_program_ids::METADATA_SOLANA_PROGRAM_METADATA_PROGRAM_ID {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_stateful_owner_mismatch",
|
||||
"initialized Solana Program Metadata state must be owned by ProgM6",
|
||||
));
|
||||
}
|
||||
let snapshot = match ks_lib::decoder_metadata_solana_program_metadata_decode_account(
|
||||
request.account.0.as_str(),
|
||||
account.owner.0.as_str(),
|
||||
account.data.as_slice(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_stateful_decode_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
let materialized = match ks_lib::materializer_metadata_materialize_solana_program_metadata_account_snapshot(
|
||||
result.context.slot,
|
||||
&snapshot,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_stateful_materialization_failed",
|
||||
error,
|
||||
));
|
||||
},
|
||||
};
|
||||
let state = match snapshot {
|
||||
ks_lib::DcMetadataSpmAccountSnapshot::Buffer(value) => {
|
||||
crate::SolanaProgramMetadataObservedAccountState::Buffer(value)
|
||||
},
|
||||
ks_lib::DcMetadataSpmAccountSnapshot::Metadata(value) => {
|
||||
crate::SolanaProgramMetadataObservedAccountState::Metadata(value)
|
||||
},
|
||||
};
|
||||
crate::SolanaProgramMetadataStatefulReadResult {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot: result.context.slot,
|
||||
account: request.account.clone(),
|
||||
lamports: std::option::Option::Some(account.lamports),
|
||||
owner: std::option::Option::Some(account.owner.clone()),
|
||||
space: std::option::Option::Some(account.space),
|
||||
state,
|
||||
materialized_output: std::option::Option::Some(materialized),
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
if !expected_state_matches(request.expected_state, &output.state) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metadata_solana_program_stateful_expected_state_mismatch",
|
||||
format!(
|
||||
"expected {:?}, observed {} for {}",
|
||||
request.expected_state,
|
||||
output.state.code(),
|
||||
request.account.0
|
||||
),
|
||||
));
|
||||
}
|
||||
tracing::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "read_solana_program_metadata_state",
|
||||
account = %request.account.0,
|
||||
context_slot = result.context.slot,
|
||||
observed_state = output.state.code(),
|
||||
materialized = output.materialized_output.is_some(),
|
||||
"validated Solana Program Metadata account state"
|
||||
);
|
||||
return std::result::Result::Ok(output);
|
||||
}
|
||||
|
||||
fn expected_state_matches(
|
||||
expected: crate::SolanaProgramMetadataExpectedAccountState,
|
||||
observed: &crate::SolanaProgramMetadataObservedAccountState,
|
||||
) -> bool {
|
||||
return match expected {
|
||||
crate::SolanaProgramMetadataExpectedAccountState::Any => true,
|
||||
crate::SolanaProgramMetadataExpectedAccountState::Vacant => observed.is_vacant(),
|
||||
crate::SolanaProgramMetadataExpectedAccountState::Missing => {
|
||||
matches!(observed, crate::SolanaProgramMetadataObservedAccountState::Missing)
|
||||
},
|
||||
crate::SolanaProgramMetadataExpectedAccountState::Buffer => {
|
||||
matches!(observed, crate::SolanaProgramMetadataObservedAccountState::Buffer(_))
|
||||
},
|
||||
crate::SolanaProgramMetadataExpectedAccountState::Metadata => {
|
||||
matches!(observed, crate::SolanaProgramMetadataObservedAccountState::Metadata(_))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn request(
|
||||
expected_state: crate::SolanaProgramMetadataExpectedAccountState,
|
||||
) -> crate::SolanaProgramMetadataStatefulReadRequest {
|
||||
return crate::SolanaProgramMetadataStatefulReadRequest {
|
||||
query_role: "http_queries".to_string(),
|
||||
account: ks_lib::MdPubkey(
|
||||
solana_pubkey::Pubkey::new_from_array([3_u8; 32]).to_string(),
|
||||
),
|
||||
expected_state,
|
||||
min_context_slot: std::option::Option::None,
|
||||
max_data_bytes: 1024,
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_zeroed_accounts_are_distinguished_and_bounded() {
|
||||
let missing = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 10,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::None,
|
||||
};
|
||||
let missing_result = crate::materialize_solana_program_metadata_account_info_result(
|
||||
&request(crate::SolanaProgramMetadataExpectedAccountState::Vacant),
|
||||
&missing,
|
||||
);
|
||||
assert!(matches!(
|
||||
missing_result.as_ref().map(|value| return &value.state),
|
||||
std::result::Result::Ok(crate::SolanaProgramMetadataObservedAccountState::Missing)
|
||||
));
|
||||
let zeroed = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 11,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::Some(ks_onchain_transport::AccountInfoValue {
|
||||
lamports: 100,
|
||||
owner: ks_lib::MdProgramId(ks_program_ids::SYSTEM_PROGRAM_ID.to_string()),
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
space: 96,
|
||||
data: vec![0_u8; 96],
|
||||
}),
|
||||
};
|
||||
let zeroed_result = crate::materialize_solana_program_metadata_account_info_result(
|
||||
&request(crate::SolanaProgramMetadataExpectedAccountState::Vacant),
|
||||
&zeroed,
|
||||
);
|
||||
assert!(matches!(
|
||||
zeroed_result.as_ref().map(|value| return &value.state),
|
||||
std::result::Result::Ok(
|
||||
crate::SolanaProgramMetadataObservedAccountState::Uninitialized { .. }
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_state_is_decoded_and_materialized_authoritatively() {
|
||||
let program = solana_pubkey::Pubkey::new_from_array([4_u8; 32]);
|
||||
let authority = solana_pubkey::Pubkey::new_from_array([5_u8; 32]);
|
||||
let mut data = vec![0_u8; ks_lib::DC_METADATA_SPM_BUFFER_HEADER_BYTES];
|
||||
data[0] = ks_lib::DcMetadataSpmAccountDiscriminator::Buffer.wire_value();
|
||||
data[1..33].copy_from_slice(&program.to_bytes());
|
||||
data[33..65].copy_from_slice(&authority.to_bytes());
|
||||
data[65] = 1;
|
||||
data[66..82].fill(7);
|
||||
data.extend_from_slice(&[8_u8, 9_u8]);
|
||||
let space = match u64::try_from(data.len()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let result = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 12,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::Some(ks_onchain_transport::AccountInfoValue {
|
||||
lamports: 1_000,
|
||||
owner: ks_lib::MdProgramId(
|
||||
ks_program_ids::METADATA_SOLANA_PROGRAM_METADATA_PROGRAM_ID.to_string(),
|
||||
),
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
space,
|
||||
data,
|
||||
}),
|
||||
};
|
||||
let output = crate::materialize_solana_program_metadata_account_info_result(
|
||||
&request(crate::SolanaProgramMetadataExpectedAccountState::Buffer),
|
||||
&result,
|
||||
);
|
||||
assert!(output.is_ok());
|
||||
if let std::result::Result::Ok(value) = output {
|
||||
assert!(matches!(
|
||||
value.state,
|
||||
crate::SolanaProgramMetadataObservedAccountState::Buffer(_)
|
||||
));
|
||||
assert_eq!(
|
||||
value
|
||||
.materialized_output
|
||||
.as_ref()
|
||||
.map(|item| return item.payload_json["projectionKind"].clone()),
|
||||
std::option::Option::Some(serde_json::json!("buffer_state"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stateful_complete_read_bound_matches_the_transport_contract() {
|
||||
assert_eq!(
|
||||
crate::MAX_SOLANA_PROGRAM_METADATA_STATEFUL_ACCOUNT_BYTES,
|
||||
ks_onchain_transport::MAX_COMPLETE_ACCOUNT_DATA_BYTES
|
||||
);
|
||||
const {
|
||||
assert!(
|
||||
crate::MAX_SOLANA_PROGRAM_METADATA_STATEFUL_ACCOUNT_BYTES
|
||||
< ks_lib::DC_METADATA_SPM_MAX_ACCOUNT_BYTES
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
ks-pipeline/src/plan.rs
Normal file
44
ks-pipeline/src/plan.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
// file: ks-pipeline/src/plan.rs
|
||||
// version: 3
|
||||
|
||||
//! Pipeline planning primitives.
|
||||
|
||||
/// Pipeline stage identifier.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PipelineStage {
|
||||
/// Ingest raw transactions.
|
||||
Ingest,
|
||||
/// Extract generic Solana structures.
|
||||
Extract,
|
||||
/// Build program observations.
|
||||
Observe,
|
||||
/// Decode protocol events.
|
||||
Decode,
|
||||
/// Materialize business events.
|
||||
Materialize,
|
||||
/// Aggregate materialized events.
|
||||
Aggregate,
|
||||
/// Validate outputs.
|
||||
Validate,
|
||||
}
|
||||
|
||||
/// Replay selection scope.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ReplayScope {
|
||||
/// Optional module name.
|
||||
pub module_name: std::option::Option<std::string::String>,
|
||||
/// Optional module version.
|
||||
pub module_version: std::option::Option<std::string::String>,
|
||||
/// Optional program id.
|
||||
pub program_id: std::option::Option<std::string::String>,
|
||||
/// Optional surface code.
|
||||
pub surface_code: std::option::Option<std::string::String>,
|
||||
/// Optional 8-byte discriminator in hexadecimal.
|
||||
pub discriminator_8: std::option::Option<std::string::String>,
|
||||
/// Optional inclusive start slot.
|
||||
pub slot_start: std::option::Option<u64>,
|
||||
/// Optional inclusive end slot.
|
||||
pub slot_end: std::option::Option<u64>,
|
||||
/// Whether to ignore existing ledger rows.
|
||||
pub force: bool,
|
||||
}
|
||||
1623
ks-pipeline/src/solana_stateful.rs
Normal file
1623
ks-pipeline/src/solana_stateful.rs
Normal file
File diff suppressed because it is too large
Load Diff
1177
ks-pipeline/src/spl_ata_stateful.rs
Normal file
1177
ks-pipeline/src/spl_ata_stateful.rs
Normal file
File diff suppressed because it is too large
Load Diff
393
ks-pipeline/src/spl_elgamal_registry_stateful.rs
Normal file
393
ks-pipeline/src/spl_elgamal_registry_stateful.rs
Normal file
@@ -0,0 +1,393 @@
|
||||
// file: ks-pipeline/src/spl_elgamal_registry_stateful.rs
|
||||
// version: 6
|
||||
|
||||
//! Contextual SPL ElGamal registry account-state validation and materialization routing.
|
||||
|
||||
use std::str::FromStr; // rust-rules: trait-import
|
||||
|
||||
/// Exact byte length of one SPL ElGamal registry account.
|
||||
pub const ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES: usize = 64;
|
||||
|
||||
/// One bounded SPL ElGamal registry RPC read request.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ElGamalRegistryStatefulReadRequest {
|
||||
/// Endpoint role used for the HTTP RPC request.
|
||||
pub query_role: std::string::String,
|
||||
/// Canonical registry PDA.
|
||||
pub registry_account: ks_lib::MdPubkey,
|
||||
/// Optional minimum RPC context slot.
|
||||
pub min_context_slot: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
/// One bounded registry RPC read and validated administrative projection.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct ElGamalRegistryStatefulReadResult {
|
||||
/// Commitment used for the RPC read.
|
||||
pub commitment: std::string::String,
|
||||
/// Context slot returned by the endpoint.
|
||||
pub context_slot: u64,
|
||||
/// Complete validated registry snapshot.
|
||||
pub snapshot: crate::ElGamalRegistryStatefulSnapshot,
|
||||
}
|
||||
|
||||
/// Reads, validates, and routes one exact SPL ElGamal registry account.
|
||||
pub async fn read_elgamal_registry_stateful_snapshot(
|
||||
pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
request: &crate::ElGamalRegistryStatefulReadRequest,
|
||||
) -> ks_core::Result<crate::ElGamalRegistryStatefulReadResult> {
|
||||
if request.query_role.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"SPL ElGamal registry stateful read query_role must not be empty",
|
||||
));
|
||||
}
|
||||
let config = match ks_onchain_transport::GetAccountInfoConfig::new_with_data(
|
||||
ks_onchain_transport::RpcCommitmentLevel::Confirmed,
|
||||
request.min_context_slot,
|
||||
crate::ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = match pool
|
||||
.get_account_info_for_role(request.query_role.as_str(), &request.registry_account, &config)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return crate::materialize_elgamal_registry_account_info_result(request, &result);
|
||||
}
|
||||
|
||||
/// Validates one complete RPC registry response before parsing and routing.
|
||||
pub fn materialize_elgamal_registry_account_info_result(
|
||||
request: &crate::ElGamalRegistryStatefulReadRequest,
|
||||
result: &ks_onchain_transport::AccountInfoResult,
|
||||
) -> ks_core::Result<crate::ElGamalRegistryStatefulReadResult> {
|
||||
if let std::option::Option::Some(min_context_slot) = request.min_context_slot {
|
||||
if result.context.slot < min_context_slot {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"elgamal_registry_stateful_context_slot_too_old",
|
||||
format!(
|
||||
"SPL ElGamal registry context slot {} is below requested minimum {min_context_slot}",
|
||||
result.context.slot
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
let account = match result.account.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"elgamal_registry_stateful_account_missing",
|
||||
format!(
|
||||
"SPL ElGamal registry account {} does not exist",
|
||||
request.registry_account.0
|
||||
),
|
||||
));
|
||||
},
|
||||
};
|
||||
if account.executable {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"elgamal_registry_stateful_account_executable",
|
||||
format!(
|
||||
"SPL ElGamal registry state account {} must not be executable",
|
||||
request.registry_account.0
|
||||
),
|
||||
));
|
||||
}
|
||||
if account.owner.0 != ks_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"elgamal_registry_stateful_owner_mismatch",
|
||||
format!(
|
||||
"SPL ElGamal registry owner must be {}, got {}",
|
||||
ks_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID,
|
||||
account.owner.0
|
||||
),
|
||||
));
|
||||
}
|
||||
if account.space != crate::ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES as u64
|
||||
|| account.data.len() != crate::ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"elgamal_registry_stateful_account_length_invalid",
|
||||
format!(
|
||||
"SPL ElGamal registry account must report and return exactly {} bytes, got space {} and data {}",
|
||||
crate::ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES,
|
||||
account.space,
|
||||
account.data.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
let snapshot = match crate::materialize_elgamal_registry_stateful_snapshot(
|
||||
request.registry_account.0.as_str(),
|
||||
account.owner.0.as_str(),
|
||||
result.context.slot,
|
||||
account.data.as_slice(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"elgamal_registry_stateful_projection_failed",
|
||||
error,
|
||||
));
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(crate::ElGamalRegistryStatefulReadResult {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot: result.context.slot,
|
||||
snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
/// One contextually validated SPL ElGamal registry state snapshot.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct ElGamalRegistryStatefulSnapshot {
|
||||
/// Canonical registry PDA identity.
|
||||
pub registry_account_key: std::string::String,
|
||||
/// Wallet address stored as the registry owner.
|
||||
pub owner: std::string::String,
|
||||
/// Context slot associated with the account read.
|
||||
pub slot: u64,
|
||||
/// Processor-owned administrative projection.
|
||||
pub output: ks_lib::MtApiMaterializedOutput,
|
||||
}
|
||||
|
||||
/// Parse, validate the official owner-derived PDA, and materialize one registry snapshot.
|
||||
pub fn materialize_elgamal_registry_stateful_snapshot(
|
||||
registry_account_key: &str,
|
||||
owner_program_id: &str,
|
||||
slot: u64,
|
||||
data: &[u8],
|
||||
) -> std::result::Result<crate::ElGamalRegistryStatefulSnapshot, String> {
|
||||
if owner_program_id != ks_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID {
|
||||
return std::result::Result::Err(format!(
|
||||
"SPL ElGamal registry snapshot owner must be {}, got {owner_program_id}",
|
||||
ks_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID
|
||||
));
|
||||
}
|
||||
let state = match ks_lib::decoder_spl_elgamal_registry_parse_elgamal_registry_state(data) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let owner = match solana_pubkey::Pubkey::from_str(state.owner.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(format!(
|
||||
"SPL ElGamal registry owner is not a valid address: {error}"
|
||||
));
|
||||
},
|
||||
};
|
||||
let program_id = match solana_pubkey::Pubkey::from_str(
|
||||
ks_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(format!(
|
||||
"Configured SPL ElGamal registry program ID is invalid: {error}"
|
||||
));
|
||||
},
|
||||
};
|
||||
let expected =
|
||||
spl_elgamal_registry_interface::get_elgamal_registry_address(&owner, &program_id);
|
||||
if expected.to_string() != registry_account_key {
|
||||
return std::result::Result::Err(format!(
|
||||
"SPL ElGamal registry account {registry_account_key} does not match owner-derived PDA {expected}"
|
||||
));
|
||||
}
|
||||
let output = match ks_lib::materializer_admin_materialize_elgamal_registry_state_snapshot(
|
||||
registry_account_key,
|
||||
slot,
|
||||
&state,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::ElGamalRegistryStatefulSnapshot {
|
||||
registry_account_key: registry_account_key.to_string(),
|
||||
owner: state.owner,
|
||||
slot,
|
||||
output,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::FromStr; // rust-rules: trait-import
|
||||
|
||||
fn registry_fixture(owner: &solana_pubkey::Pubkey) -> (std::string::String, [u8; 64]) {
|
||||
let program_id = match solana_pubkey::Pubkey::from_str(
|
||||
ks_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => solana_pubkey::Pubkey::default(),
|
||||
};
|
||||
let registry =
|
||||
spl_elgamal_registry_interface::get_elgamal_registry_address(owner, &program_id);
|
||||
let mut data = [0u8; 64];
|
||||
data[..32].copy_from_slice(owner.as_ref());
|
||||
data[32..].copy_from_slice(&[9u8; 32]);
|
||||
return (registry.to_string(), data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_owner_derived_registry_pda_routes_one_admin_snapshot() {
|
||||
let owner = solana_pubkey::Pubkey::new_from_array([7u8; 32]);
|
||||
let (registry, data) = registry_fixture(&owner);
|
||||
let snapshot = crate::materialize_elgamal_registry_stateful_snapshot(
|
||||
registry.as_str(),
|
||||
ks_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID,
|
||||
55,
|
||||
&data,
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.as_ref().map(|value| return value.owner.clone()),
|
||||
std::result::Result::Ok(owner.to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.as_ref()
|
||||
.map(|value| return value.output.payload_json["finalAccountStateCaptured"].clone()),
|
||||
std::result::Result::Ok(serde_json::json!(true))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_owner_program_wrong_pda_and_invalid_length_fail_closed() {
|
||||
let owner = solana_pubkey::Pubkey::new_from_array([8u8; 32]);
|
||||
let (registry, data) = registry_fixture(&owner);
|
||||
let foreign = crate::materialize_elgamal_registry_stateful_snapshot(
|
||||
registry.as_str(),
|
||||
ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID,
|
||||
55,
|
||||
&data,
|
||||
);
|
||||
assert!(foreign.is_err());
|
||||
let wrong = crate::materialize_elgamal_registry_stateful_snapshot(
|
||||
solana_pubkey::Pubkey::new_from_array([10u8; 32]).to_string().as_str(),
|
||||
ks_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID,
|
||||
55,
|
||||
&data,
|
||||
);
|
||||
assert!(wrong.is_err());
|
||||
let invalid = crate::materialize_elgamal_registry_stateful_snapshot(
|
||||
registry.as_str(),
|
||||
ks_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID,
|
||||
55,
|
||||
&data[..63],
|
||||
);
|
||||
assert!(invalid.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_rpc_registry_routes_after_context_owner_and_length_validation() {
|
||||
let owner = solana_pubkey::Pubkey::new_from_array([13u8; 32]);
|
||||
let (registry, data) = registry_fixture(&owner);
|
||||
let request = crate::ElGamalRegistryStatefulReadRequest {
|
||||
query_role: "execution".to_string(),
|
||||
registry_account: ks_lib::MdPubkey(registry),
|
||||
min_context_slot: std::option::Option::Some(70),
|
||||
};
|
||||
let result = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 71,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::Some(ks_onchain_transport::AccountInfoValue {
|
||||
lamports: 1,
|
||||
owner: ks_lib::MdProgramId(
|
||||
ks_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID.to_string(),
|
||||
),
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
space: 64,
|
||||
data: data.to_vec(),
|
||||
}),
|
||||
};
|
||||
let materialized =
|
||||
crate::materialize_elgamal_registry_account_info_result(&request, &result);
|
||||
assert_eq!(
|
||||
materialized.as_ref().map(|value| return value.context_slot),
|
||||
std::result::Result::Ok(71)
|
||||
);
|
||||
assert_eq!(
|
||||
materialized.as_ref().map(|value| return value.snapshot.owner.clone()),
|
||||
std::result::Result::Ok(owner.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_missing_foreign_executable_and_wrong_length_registry_reads_fail_closed() {
|
||||
let owner = solana_pubkey::Pubkey::new_from_array([14u8; 32]);
|
||||
let (registry, data) = registry_fixture(&owner);
|
||||
let request = crate::ElGamalRegistryStatefulReadRequest {
|
||||
query_role: "execution".to_string(),
|
||||
registry_account: ks_lib::MdPubkey(registry),
|
||||
min_context_slot: std::option::Option::Some(80),
|
||||
};
|
||||
let base = ks_onchain_transport::AccountInfoValue {
|
||||
lamports: 1,
|
||||
owner: ks_lib::MdProgramId(
|
||||
ks_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID.to_string(),
|
||||
),
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
space: 64,
|
||||
data: data.to_vec(),
|
||||
};
|
||||
let stale = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 79,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::Some(base.clone()),
|
||||
};
|
||||
assert!(crate::materialize_elgamal_registry_account_info_result(&request, &stale).is_err());
|
||||
let missing = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 80,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::None,
|
||||
};
|
||||
assert!(
|
||||
crate::materialize_elgamal_registry_account_info_result(&request, &missing).is_err()
|
||||
);
|
||||
let mut foreign = base.clone();
|
||||
foreign.owner = ks_lib::MdProgramId(ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID.to_string());
|
||||
let foreign = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 80,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::Some(foreign),
|
||||
};
|
||||
assert!(
|
||||
crate::materialize_elgamal_registry_account_info_result(&request, &foreign).is_err()
|
||||
);
|
||||
let mut executable = base.clone();
|
||||
executable.executable = true;
|
||||
let executable = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 80,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::Some(executable),
|
||||
};
|
||||
assert!(
|
||||
crate::materialize_elgamal_registry_account_info_result(&request, &executable).is_err()
|
||||
);
|
||||
let mut wrong_length = base;
|
||||
wrong_length.data.pop();
|
||||
let wrong_length = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 80,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::Some(wrong_length),
|
||||
};
|
||||
assert!(
|
||||
crate::materialize_elgamal_registry_account_info_result(&request, &wrong_length)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
242
ks-pipeline/src/spl_token_2022_correlation.rs
Normal file
242
ks-pipeline/src/spl_token_2022_correlation.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
// file: ks-pipeline/src/spl_token_2022_correlation.rs
|
||||
// version: 4
|
||||
|
||||
//! Deterministic Token-2022 instruction-to-state correlation.
|
||||
|
||||
/// Correlation outcome between one committed instruction fact and one final state snapshot.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub enum Token2022CorrelationStatus {
|
||||
/// The final snapshot contains the extension expected by the instruction fact.
|
||||
Confirmed,
|
||||
/// The final snapshot does not contain the extension expected by the instruction fact.
|
||||
Contradicted,
|
||||
/// The instruction output is outside the supported correlation inventory.
|
||||
NotApplicable,
|
||||
}
|
||||
|
||||
/// Deterministic correlation report for one instruction output and one final snapshot.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022StateCorrelationReport {
|
||||
/// Stable caller-provided correlation identity.
|
||||
pub correlation_key: std::string::String,
|
||||
/// Canonical account identity checked against the snapshot.
|
||||
pub account: ks_lib::MdPubkey,
|
||||
/// Instruction operation or risk fact used for correlation.
|
||||
pub fact_code: std::string::String,
|
||||
/// Extension expected from the fact when the fact is supported.
|
||||
pub expected_extension: std::option::Option<std::string::String>,
|
||||
/// Final correlation outcome.
|
||||
pub status: Token2022CorrelationStatus,
|
||||
/// Snapshot context slot.
|
||||
pub snapshot_slot: u64,
|
||||
/// Whether the snapshot contains the expected extension.
|
||||
pub extension_present: std::option::Option<bool>,
|
||||
/// Explicit semantic limitation of this correlation.
|
||||
pub fact_only: bool,
|
||||
}
|
||||
|
||||
/// Correlates one materialized instruction fact with one authoritative Token-2022 snapshot.
|
||||
pub fn correlate_token_2022_instruction_with_snapshot(
|
||||
correlation_key: &str,
|
||||
account: &ks_lib::MdPubkey,
|
||||
instruction_output: &ks_lib::MtApiMaterializedOutput,
|
||||
snapshot: &crate::Token2022StatefulSnapshotBundle,
|
||||
) -> ks_core::Result<Token2022StateCorrelationReport> {
|
||||
if correlation_key.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 correlation key must not be empty",
|
||||
));
|
||||
}
|
||||
if snapshot.account_key != account.0 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_correlation_account_mismatch",
|
||||
format!(
|
||||
"Token-2022 correlation expected account {}, got snapshot {}",
|
||||
account.0, snapshot.account_key
|
||||
),
|
||||
));
|
||||
}
|
||||
let fact_code = match instruction_output.payload_json.get("riskKind") {
|
||||
std::option::Option::Some(value) => value.as_str().unwrap_or("").to_string(),
|
||||
std::option::Option::None => instruction_output
|
||||
.payload_json
|
||||
.get("operation")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
};
|
||||
if fact_code.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_correlation_fact_missing",
|
||||
"Token-2022 correlation requires riskKind or operation in the instruction output",
|
||||
));
|
||||
}
|
||||
let expected_extension = expected_extension(fact_code.as_str());
|
||||
let extension_present = expected_extension.map(|extension| {
|
||||
return snapshot.extension_names.iter().any(|candidate| return candidate == extension);
|
||||
});
|
||||
let status = match extension_present {
|
||||
std::option::Option::Some(true) => Token2022CorrelationStatus::Confirmed,
|
||||
std::option::Option::Some(false) => Token2022CorrelationStatus::Contradicted,
|
||||
std::option::Option::None => Token2022CorrelationStatus::NotApplicable,
|
||||
};
|
||||
return std::result::Result::Ok(Token2022StateCorrelationReport {
|
||||
correlation_key: correlation_key.to_string(),
|
||||
account: account.clone(),
|
||||
fact_code,
|
||||
expected_extension: expected_extension.map(str::to_string),
|
||||
status,
|
||||
snapshot_slot: snapshot.slot,
|
||||
extension_present,
|
||||
fact_only: true,
|
||||
});
|
||||
}
|
||||
|
||||
fn expected_extension(fact_code: &str) -> std::option::Option<&'static str> {
|
||||
return match fact_code {
|
||||
"default_account_state_configured"
|
||||
| "initialize_default_account_state"
|
||||
| "update_default_account_state" => std::option::Option::Some("default_account_state"),
|
||||
"required_transfer_memos_enabled"
|
||||
| "required_transfer_memos_disabled"
|
||||
| "enable_required_transfer_memos"
|
||||
| "disable_required_transfer_memos" => std::option::Option::Some("memo_transfer"),
|
||||
"cpi_guard_enabled" | "cpi_guard_disabled" | "enable_cpi_guard" | "disable_cpi_guard" => {
|
||||
std::option::Option::Some("cpi_guard")
|
||||
},
|
||||
"non_transferable_mint_configured" | "initialize_non_transferable_mint" => {
|
||||
std::option::Option::Some("non_transferable")
|
||||
},
|
||||
"pausable_mint_configured"
|
||||
| "mint_activity_paused"
|
||||
| "mint_activity_resumed"
|
||||
| "initialize_pausable_config"
|
||||
| "pause"
|
||||
| "resume" => std::option::Option::Some("pausable"),
|
||||
"permissioned_burn_configured" | "initialize_permissioned_burn" => {
|
||||
std::option::Option::Some("permissioned_burn")
|
||||
},
|
||||
"confidential_credits_enabled"
|
||||
| "confidential_credits_disabled"
|
||||
| "non_confidential_credits_enabled"
|
||||
| "non_confidential_credits_disabled"
|
||||
| "enable_confidential_credits"
|
||||
| "disable_confidential_credits"
|
||||
| "enable_non_confidential_credits"
|
||||
| "disable_non_confidential_credits" => {
|
||||
std::option::Option::Some("confidential_transfer_account")
|
||||
},
|
||||
"initialize_transfer_fee_config"
|
||||
| "set_transfer_fee"
|
||||
| "withdraw_withheld_tokens_from_mint"
|
||||
| "harvest_withheld_tokens_to_mint" => std::option::Option::Some("transfer_fee_config"),
|
||||
"withdraw_withheld_tokens_from_accounts" | "transfer_checked_with_fee" => {
|
||||
std::option::Option::Some("transfer_fee_amount")
|
||||
},
|
||||
"initialize_confidential_transfer_fee_config"
|
||||
| "withdraw_confidential_withheld_tokens_from_mint"
|
||||
| "harvest_confidential_withheld_tokens_to_mint"
|
||||
| "enable_confidential_harvest_to_mint"
|
||||
| "disable_confidential_harvest_to_mint" => {
|
||||
std::option::Option::Some("confidential_transfer_fee_config")
|
||||
},
|
||||
"withdraw_confidential_withheld_tokens_from_accounts"
|
||||
| "transfer_confidential_tokens_with_fee" => {
|
||||
std::option::Option::Some("confidential_transfer_fee_amount")
|
||||
},
|
||||
_ => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn snapshot(extension_names: &[&str]) -> crate::Token2022StatefulSnapshotBundle {
|
||||
return crate::Token2022StatefulSnapshotBundle {
|
||||
account_key: solana_pubkey::Pubkey::new_from_array([7u8; 32]).to_string(),
|
||||
slot: 42,
|
||||
state_kind: "mint".to_string(),
|
||||
extension_names: extension_names
|
||||
.iter()
|
||||
.map(|value| return (*value).to_string())
|
||||
.collect(),
|
||||
outputs: std::vec::Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
fn output(field: &str, value: &str) -> ks_lib::MtApiMaterializedOutput {
|
||||
let mut payload = serde_json::Map::new();
|
||||
payload.insert(field.to_string(), serde_json::Value::String(value.to_string()));
|
||||
return ks_lib::MtApiMaterializedOutput {
|
||||
output_key: "fact:0".to_string(),
|
||||
family: ks_lib::MdMaterializedEventFamily::Risk,
|
||||
payload_json: serde_json::Value::Object(payload),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_fact_is_confirmed_or_contradicted_by_the_final_extension_inventory() {
|
||||
let snapshot = snapshot(&["pausable"]);
|
||||
let account = ks_lib::MdPubkey(snapshot.account_key.clone());
|
||||
let confirmed = super::correlate_token_2022_instruction_with_snapshot(
|
||||
"corr:pause",
|
||||
&account,
|
||||
&output("riskKind", "mint_activity_paused"),
|
||||
&snapshot,
|
||||
);
|
||||
assert!(confirmed.is_ok());
|
||||
if let std::result::Result::Ok(confirmed) = confirmed {
|
||||
assert_eq!(confirmed.status, super::Token2022CorrelationStatus::Confirmed);
|
||||
assert_eq!(confirmed.extension_present, std::option::Option::Some(true));
|
||||
}
|
||||
let contradicted = super::correlate_token_2022_instruction_with_snapshot(
|
||||
"corr:fee",
|
||||
&account,
|
||||
&output("operation", "initialize_transfer_fee_config"),
|
||||
&snapshot,
|
||||
);
|
||||
assert!(contradicted.is_ok());
|
||||
if let std::result::Result::Ok(contradicted) = contradicted {
|
||||
assert_eq!(contradicted.status, super::Token2022CorrelationStatus::Contradicted);
|
||||
assert_eq!(contradicted.extension_present, std::option::Option::Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_fact_is_explicitly_not_applicable_without_inventing_a_conclusion() {
|
||||
let snapshot = snapshot(&[]);
|
||||
let account = ks_lib::MdPubkey(snapshot.account_key.clone());
|
||||
let report = super::correlate_token_2022_instruction_with_snapshot(
|
||||
"corr:unknown",
|
||||
&account,
|
||||
&output("operation", "unknown_future_operation"),
|
||||
&snapshot,
|
||||
);
|
||||
assert!(report.is_ok());
|
||||
if let std::result::Result::Ok(report) = report {
|
||||
assert_eq!(report.status, super::Token2022CorrelationStatus::NotApplicable);
|
||||
assert_eq!(report.extension_present, std::option::Option::None);
|
||||
assert!(report.fact_only);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correlation_identity_and_account_mismatch_fail_closed() {
|
||||
let snapshot = snapshot(&["memo_transfer"]);
|
||||
let account = ks_lib::MdPubkey(snapshot.account_key.clone());
|
||||
let empty = super::correlate_token_2022_instruction_with_snapshot(
|
||||
"",
|
||||
&account,
|
||||
&output("riskKind", "required_transfer_memos_enabled"),
|
||||
&snapshot,
|
||||
);
|
||||
assert!(empty.is_err());
|
||||
let wrong = ks_lib::MdPubkey(solana_pubkey::Pubkey::new_from_array([8u8; 32]).to_string());
|
||||
let mismatch = super::correlate_token_2022_instruction_with_snapshot(
|
||||
"corr:mismatch",
|
||||
&wrong,
|
||||
&output("riskKind", "required_transfer_memos_enabled"),
|
||||
&snapshot,
|
||||
);
|
||||
assert!(mismatch.is_err());
|
||||
}
|
||||
}
|
||||
319
ks-pipeline/src/spl_token_2022_crypto_preflight.rs
Normal file
319
ks-pipeline/src/spl_token_2022_crypto_preflight.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
// file: ks-pipeline/src/spl_token_2022_crypto_preflight.rs
|
||||
// version: 5
|
||||
|
||||
//! Bounded cryptographic preflight for Token-2022 proof context-state accounts.
|
||||
|
||||
/// Maximum number of distinct proof context-state accounts accepted by one request.
|
||||
pub const MAX_TOKEN_2022_PROOF_CONTEXTS: usize = 8;
|
||||
/// Generic metadata bytes retained by every ZK ElGamal proof context-state account.
|
||||
pub const ZK_PROOF_CONTEXT_META_BYTES: usize = 33;
|
||||
|
||||
/// One exact pre-verified proof context-state requirement.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Token2022ProofContextRequirement {
|
||||
/// Stable semantic role used in diagnostics.
|
||||
pub role: std::string::String,
|
||||
/// Canonical proof context-state account.
|
||||
pub account: ks_lib::MdPubkey,
|
||||
/// Exact proof kind expected by the Token-2022 builder.
|
||||
pub proof_type: ks_lib::ExSolanaCoreZkElGamalProofType,
|
||||
/// Optional authority retained by the context-state account.
|
||||
pub expected_authority: std::option::Option<ks_lib::MdPubkey>,
|
||||
}
|
||||
|
||||
/// One bounded cryptographic preflight request.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Token2022CryptographicPreflightRequest {
|
||||
/// Endpoint role used by every account read.
|
||||
pub query_role: std::string::String,
|
||||
/// Optional minimum RPC context slot.
|
||||
pub min_context_slot: std::option::Option<u64>,
|
||||
/// Ordered proof context-state requirements.
|
||||
pub proof_contexts: std::vec::Vec<Token2022ProofContextRequirement>,
|
||||
}
|
||||
|
||||
/// One validated proof context-state result.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022ProofContextReport {
|
||||
/// Stable semantic role.
|
||||
pub role: std::string::String,
|
||||
/// Canonical proof context-state account.
|
||||
pub account: ks_lib::MdPubkey,
|
||||
/// Official one-byte proof discriminator.
|
||||
pub proof_discriminator: u8,
|
||||
/// Exact context-state size for the proof type.
|
||||
pub context_state_bytes: usize,
|
||||
/// Context slot returned by the endpoint.
|
||||
pub context_slot: u64,
|
||||
/// Ordered successful checks.
|
||||
pub checks: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// Complete cryptographic preflight report.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022CryptographicPreflightReport {
|
||||
/// Commitment used for all reads.
|
||||
pub commitment: std::string::String,
|
||||
/// Highest context slot observed across all reads.
|
||||
pub context_slot: u64,
|
||||
/// Ordered validated proof contexts.
|
||||
pub proof_contexts: std::vec::Vec<Token2022ProofContextReport>,
|
||||
}
|
||||
|
||||
/// Reads and validates all pre-verified proof context-state accounts required by one operation.
|
||||
pub async fn inspect_token_2022_cryptographic_preflight(
|
||||
pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
request: &crate::Token2022CryptographicPreflightRequest,
|
||||
) -> ks_core::Result<crate::Token2022CryptographicPreflightReport> {
|
||||
let requirements = match validate_proof_context_requirements(request) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut context_slot = request.min_context_slot.unwrap_or(0);
|
||||
let mut reports = std::vec::Vec::with_capacity(requirements.len());
|
||||
for requirement in requirements {
|
||||
let expected_size = requirement.proof_type.context_state_size();
|
||||
let config = match ks_onchain_transport::GetAccountInfoConfig::new_with_data(
|
||||
ks_onchain_transport::RpcCommitmentLevel::Confirmed,
|
||||
request.min_context_slot,
|
||||
expected_size,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = match pool
|
||||
.get_account_info_for_role(request.query_role.as_str(), &requirement.account, &config)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let report = match validate_proof_context_account(&requirement, &result) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
context_slot = context_slot.max(report.context_slot);
|
||||
reports.push(report);
|
||||
}
|
||||
return std::result::Result::Ok(crate::Token2022CryptographicPreflightReport {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot,
|
||||
proof_contexts: reports,
|
||||
});
|
||||
}
|
||||
|
||||
fn validate_proof_context_requirements(
|
||||
request: &crate::Token2022CryptographicPreflightRequest,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::Token2022ProofContextRequirement>> {
|
||||
if request.query_role.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 cryptographic preflight query_role must not be empty",
|
||||
));
|
||||
}
|
||||
if request.proof_contexts.len() > crate::MAX_TOKEN_2022_PROOF_CONTEXTS {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_proof_context_limit_exceeded",
|
||||
format!(
|
||||
"Token-2022 cryptographic preflight accepts at most {} proof contexts",
|
||||
crate::MAX_TOKEN_2022_PROOF_CONTEXTS
|
||||
),
|
||||
));
|
||||
}
|
||||
let mut indexes = std::collections::BTreeMap::<std::string::String, usize>::new();
|
||||
let mut unique = std::vec::Vec::<crate::Token2022ProofContextRequirement>::new();
|
||||
for requirement in &request.proof_contexts {
|
||||
if requirement.role.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 proof context role must not be empty",
|
||||
));
|
||||
}
|
||||
if let std::option::Option::Some(index) = indexes.get(requirement.account.0.as_str()) {
|
||||
if &unique[*index] != requirement {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_proof_context_conflicting_duplicate",
|
||||
format!(
|
||||
"Token-2022 proof context {} has conflicting requirements",
|
||||
requirement.account.0
|
||||
),
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
indexes.insert(requirement.account.0.clone(), unique.len());
|
||||
unique.push(requirement.clone());
|
||||
}
|
||||
return std::result::Result::Ok(unique);
|
||||
}
|
||||
|
||||
fn validate_proof_context_account(
|
||||
requirement: &crate::Token2022ProofContextRequirement,
|
||||
result: &ks_onchain_transport::AccountInfoResult,
|
||||
) -> ks_core::Result<crate::Token2022ProofContextReport> {
|
||||
let account = match result.account.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_proof_context_missing",
|
||||
format!(
|
||||
"Token-2022 proof context account {} does not exist",
|
||||
requirement.account.0
|
||||
),
|
||||
));
|
||||
},
|
||||
};
|
||||
if account.owner.0 != ks_program_ids::ZK_ELGAMAL_PROOF_PROGRAM_ID {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_proof_context_owner_mismatch",
|
||||
format!(
|
||||
"Token-2022 proof context owner must be {}, got {}",
|
||||
ks_program_ids::ZK_ELGAMAL_PROOF_PROGRAM_ID,
|
||||
account.owner.0
|
||||
),
|
||||
));
|
||||
}
|
||||
if account.executable {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_proof_context_executable",
|
||||
"Token-2022 proof context account must not be executable",
|
||||
));
|
||||
}
|
||||
let expected_size = requirement.proof_type.context_state_size();
|
||||
if account.space != expected_size as u64 || account.data.len() != expected_size {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_proof_context_size_mismatch",
|
||||
format!(
|
||||
"Token-2022 proof context {} must contain exactly {expected_size} bytes, got space {} and data {}",
|
||||
requirement.account.0,
|
||||
account.space,
|
||||
account.data.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
if account.data.len() < crate::ZK_PROOF_CONTEXT_META_BYTES {
|
||||
return std::result::Result::Err(ks_core::Error::invalid_state(
|
||||
"Token-2022 proof context is shorter than its generic metadata",
|
||||
));
|
||||
}
|
||||
let discriminator = account.data[32];
|
||||
if discriminator != requirement.proof_type.discriminator() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_proof_context_type_mismatch",
|
||||
format!(
|
||||
"Token-2022 proof context {} discriminator {} does not match expected {}",
|
||||
requirement.account.0,
|
||||
discriminator,
|
||||
requirement.proof_type.discriminator()
|
||||
),
|
||||
));
|
||||
}
|
||||
let mut checks = std::vec![
|
||||
"zk_program_owner".to_string(),
|
||||
"not_executable".to_string(),
|
||||
"exact_context_state_size".to_string(),
|
||||
"proof_type_discriminator".to_string(),
|
||||
];
|
||||
if let std::option::Option::Some(expected_authority) = requirement.expected_authority.as_ref() {
|
||||
let retained_authority = bs58::encode(&account.data[..32]).into_string();
|
||||
if retained_authority != expected_authority.0 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_proof_context_authority_mismatch",
|
||||
format!(
|
||||
"Token-2022 proof context {} retains authority {}, expected {}",
|
||||
requirement.account.0, retained_authority, expected_authority.0
|
||||
),
|
||||
));
|
||||
}
|
||||
checks.push("retained_authority".to_string());
|
||||
}
|
||||
return std::result::Result::Ok(crate::Token2022ProofContextReport {
|
||||
role: requirement.role.clone(),
|
||||
account: requirement.account.clone(),
|
||||
proof_discriminator: discriminator,
|
||||
context_state_bytes: expected_size,
|
||||
context_slot: result.context.slot,
|
||||
checks,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn pubkey(byte: u8) -> ks_lib::MdPubkey {
|
||||
return ks_lib::MdPubkey(bs58::encode([byte; 32]).into_string());
|
||||
}
|
||||
|
||||
fn requirement(
|
||||
account: ks_lib::MdPubkey,
|
||||
proof_type: ks_lib::ExSolanaCoreZkElGamalProofType,
|
||||
) -> crate::Token2022ProofContextRequirement {
|
||||
return crate::Token2022ProofContextRequirement {
|
||||
role: "equality_proof".to_string(),
|
||||
account,
|
||||
proof_type,
|
||||
expected_authority: std::option::Option::Some(pubkey(9)),
|
||||
};
|
||||
}
|
||||
|
||||
fn account_result(
|
||||
proof_type: ks_lib::ExSolanaCoreZkElGamalProofType,
|
||||
) -> ks_onchain_transport::AccountInfoResult {
|
||||
let mut data = std::vec![0u8; proof_type.context_state_size()];
|
||||
data[..32].copy_from_slice(&[9u8; 32]);
|
||||
data[32] = proof_type.discriminator();
|
||||
return ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 77,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::Some(ks_onchain_transport::AccountInfoValue {
|
||||
lamports: 1,
|
||||
owner: ks_lib::MdProgramId(ks_program_ids::ZK_ELGAMAL_PROOF_PROGRAM_ID.to_string()),
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
space: data.len() as u64,
|
||||
data,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_duplicates_are_deduplicated_and_conflicts_fail_closed() {
|
||||
let item = requirement(
|
||||
pubkey(1),
|
||||
ks_lib::ExSolanaCoreZkElGamalProofType::CiphertextCiphertextEquality,
|
||||
);
|
||||
let request = crate::Token2022CryptographicPreflightRequest {
|
||||
query_role: "query".to_string(),
|
||||
min_context_slot: std::option::Option::Some(7),
|
||||
proof_contexts: std::vec![item.clone(), item.clone()],
|
||||
};
|
||||
let unique = super::validate_proof_context_requirements(&request);
|
||||
assert_eq!(unique.as_ref().map(std::vec::Vec::len), std::result::Result::Ok(1));
|
||||
let conflict = crate::Token2022CryptographicPreflightRequest {
|
||||
query_role: "query".to_string(),
|
||||
min_context_slot: std::option::Option::None,
|
||||
proof_contexts: std::vec![
|
||||
item,
|
||||
requirement(
|
||||
pubkey(1),
|
||||
ks_lib::ExSolanaCoreZkElGamalProofType::BatchedRangeProofU128,
|
||||
),
|
||||
],
|
||||
};
|
||||
assert!(super::validate_proof_context_requirements(&conflict).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_context_validation_checks_owner_size_type_and_authority() {
|
||||
let proof_type = ks_lib::ExSolanaCoreZkElGamalProofType::CiphertextCiphertextEquality;
|
||||
let context_requirement = requirement(pubkey(1), proof_type);
|
||||
let result = account_result(proof_type);
|
||||
let report = super::validate_proof_context_account(&context_requirement, &result);
|
||||
assert_eq!(
|
||||
report.as_ref().map(|value| return value.checks.len()),
|
||||
std::result::Result::Ok(5)
|
||||
);
|
||||
let wrong_type =
|
||||
requirement(pubkey(1), ks_lib::ExSolanaCoreZkElGamalProofType::BatchedRangeProofU128);
|
||||
assert!(super::validate_proof_context_account(&wrong_type, &result,).is_err());
|
||||
}
|
||||
}
|
||||
331
ks-pipeline/src/spl_token_2022_execution_orchestration.rs
Normal file
331
ks-pipeline/src/spl_token_2022_execution_orchestration.rs
Normal file
@@ -0,0 +1,331 @@
|
||||
// file: ks-pipeline/src/spl_token_2022_execution_orchestration.rs
|
||||
// version: 5
|
||||
|
||||
//! Final Token-2022 execution-readiness and stateful postcondition contracts.
|
||||
|
||||
/// Maximum number of distinct transaction signers accepted by one Token-2022 execution envelope.
|
||||
pub const MAX_TOKEN_2022_EXECUTION_SIGNERS: usize = 16;
|
||||
|
||||
/// Result of one stateful postcondition checked after a confirmed Token-2022 transaction.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Token2022ExecutionPostconditionStatus {
|
||||
/// The observed final state confirms the expected operation effect.
|
||||
Confirmed,
|
||||
/// The observed final state contradicts the expected operation effect.
|
||||
Contradicted,
|
||||
/// The operation has no supported stateful postcondition in the current contract.
|
||||
NotApplicable,
|
||||
}
|
||||
|
||||
/// One explicit stateful postcondition retained by the execution orchestrator.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022ExecutionPostcondition {
|
||||
/// Stable semantic role such as `mint`, `source`, `destination`, or `registry`.
|
||||
pub role: std::string::String,
|
||||
/// Canonical account whose final state was inspected.
|
||||
pub account: ks_lib::MdPubkey,
|
||||
/// Explicit result of the postcondition.
|
||||
pub status: crate::Token2022ExecutionPostconditionStatus,
|
||||
/// Bounded diagnostic explaining the result without retaining complete account data.
|
||||
pub diagnostic: std::string::String,
|
||||
}
|
||||
|
||||
/// Complete deterministic request checked before Token-2022 transaction signing.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Token2022ExecutionReadinessRequest {
|
||||
/// Exact prepared executor plan.
|
||||
pub plan: ks_lib::ExApiPreparedExecutionPlan,
|
||||
/// Exact hash of the compiled Solana message.
|
||||
pub message_hash: std::string::String,
|
||||
/// Hash retained by the simulation evidence.
|
||||
pub simulated_message_hash: std::string::String,
|
||||
/// Whether the exact compiled message was simulated.
|
||||
pub simulated: bool,
|
||||
/// Whether the exact simulation succeeded.
|
||||
pub simulation_succeeded: bool,
|
||||
/// Stateful Token-2022 account preflight report.
|
||||
pub stateful_preflight: crate::Token2022PreflightReport,
|
||||
/// Cryptographic proof context-state preflight report.
|
||||
pub cryptographic_preflight: crate::Token2022CryptographicPreflightReport,
|
||||
/// Ordered proof orchestration report.
|
||||
pub proof_orchestration: crate::Token2022ProofOrchestrationReport,
|
||||
/// Public keys actually available to sign the transaction.
|
||||
pub resolved_signers: std::vec::Vec<ks_lib::MdPubkey>,
|
||||
/// Whether submission was explicitly requested.
|
||||
pub submit: bool,
|
||||
/// Whether submission received explicit operator confirmation.
|
||||
pub operator_confirmed: bool,
|
||||
}
|
||||
|
||||
/// Deterministic execution-readiness report produced before signing.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022ExecutionReadinessReport {
|
||||
/// Stable operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Exact compiled message hash bound to simulation.
|
||||
pub message_hash: std::string::String,
|
||||
/// Highest RPC context slot across stateful and cryptographic preflights.
|
||||
pub context_slot: u64,
|
||||
/// Ordered signer public keys required by the plan.
|
||||
pub required_signers: std::vec::Vec<ks_lib::MdPubkey>,
|
||||
/// Whether transaction signing and submission are authorized.
|
||||
pub send_authorized: bool,
|
||||
/// Ordered successful checks.
|
||||
pub checks: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// Validates the complete Token-2022 execution envelope before transaction signing.
|
||||
pub fn validate_token_2022_execution_readiness(
|
||||
request: &crate::Token2022ExecutionReadinessRequest,
|
||||
) -> ks_core::Result<crate::Token2022ExecutionReadinessReport> {
|
||||
if request.plan.operation_code.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 execution plan operation_code must not be empty",
|
||||
));
|
||||
}
|
||||
if request.plan.operation_code != request.proof_orchestration.operation_code {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_execution_operation_mismatch",
|
||||
"Token-2022 plan and proof orchestration operation codes must match",
|
||||
));
|
||||
}
|
||||
if request.message_hash.trim().is_empty()
|
||||
|| request.simulated_message_hash.trim().is_empty()
|
||||
|| request.message_hash != request.simulated_message_hash
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_execution_simulation_message_mismatch",
|
||||
"Token-2022 simulation must be bound to the exact compiled message hash",
|
||||
));
|
||||
}
|
||||
if !request.simulated || !request.simulation_succeeded {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_execution_simulation_required",
|
||||
"Token-2022 execution requires one successful exact-message simulation",
|
||||
));
|
||||
}
|
||||
if !request.proof_orchestration.simulation_required {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_execution_proof_policy_mismatch",
|
||||
"Token-2022 proof orchestration must require simulation",
|
||||
));
|
||||
}
|
||||
if request.stateful_preflight.commitment != "confirmed"
|
||||
|| request.cryptographic_preflight.commitment != "confirmed"
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_execution_preflight_commitment_mismatch",
|
||||
"Token-2022 stateful and cryptographic preflights must use confirmed commitment",
|
||||
));
|
||||
}
|
||||
let required_signers = match validate_signers(
|
||||
request.plan.required_signers.as_slice(),
|
||||
request.resolved_signers.as_slice(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if request.submit && !request.operator_confirmed {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_execution_confirmation_required",
|
||||
"Token-2022 submission requires explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
if request.submit && !request.proof_orchestration.send_authorized {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_execution_proof_send_not_authorized",
|
||||
"Token-2022 proof orchestration did not authorize submission",
|
||||
));
|
||||
}
|
||||
if request.submit && request.plan.policy.dry_run {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_execution_dry_run_blocks_submission",
|
||||
"Token-2022 plan remains dry-run and cannot be submitted",
|
||||
));
|
||||
}
|
||||
let context_slot = request
|
||||
.stateful_preflight
|
||||
.context_slot
|
||||
.max(request.cryptographic_preflight.context_slot);
|
||||
return std::result::Result::Ok(crate::Token2022ExecutionReadinessReport {
|
||||
operation_code: request.plan.operation_code.clone(),
|
||||
message_hash: request.message_hash.clone(),
|
||||
context_slot,
|
||||
required_signers,
|
||||
send_authorized: request.submit
|
||||
&& request.operator_confirmed
|
||||
&& request.proof_orchestration.send_authorized
|
||||
&& !request.plan.policy.dry_run,
|
||||
checks: vec![
|
||||
"operation_matches_proof_orchestration".to_string(),
|
||||
"simulation_bound_to_exact_message".to_string(),
|
||||
"confirmed_stateful_preflight".to_string(),
|
||||
"confirmed_cryptographic_preflight".to_string(),
|
||||
"all_required_signers_resolved".to_string(),
|
||||
"submission_policy_consistent".to_string(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/// Aggregates explicit postconditions without converting unsupported checks into success.
|
||||
pub fn summarize_token_2022_postconditions(
|
||||
postconditions: &[crate::Token2022ExecutionPostcondition],
|
||||
) -> crate::Token2022ExecutionPostconditionStatus {
|
||||
if postconditions.iter().any(|item| {
|
||||
return item.status == crate::Token2022ExecutionPostconditionStatus::Contradicted;
|
||||
}) {
|
||||
return crate::Token2022ExecutionPostconditionStatus::Contradicted;
|
||||
}
|
||||
if postconditions.iter().any(|item| {
|
||||
return item.status == crate::Token2022ExecutionPostconditionStatus::Confirmed;
|
||||
}) {
|
||||
return crate::Token2022ExecutionPostconditionStatus::Confirmed;
|
||||
}
|
||||
return crate::Token2022ExecutionPostconditionStatus::NotApplicable;
|
||||
}
|
||||
|
||||
fn validate_signers(
|
||||
required: &[ks_lib::ExApiRequiredSigner],
|
||||
resolved: &[ks_lib::MdPubkey],
|
||||
) -> ks_core::Result<std::vec::Vec<ks_lib::MdPubkey>> {
|
||||
if required.len() > crate::MAX_TOKEN_2022_EXECUTION_SIGNERS
|
||||
|| resolved.len() > crate::MAX_TOKEN_2022_EXECUTION_SIGNERS
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_execution_signer_limit_exceeded",
|
||||
format!(
|
||||
"Token-2022 execution accepts at most {} signers",
|
||||
crate::MAX_TOKEN_2022_EXECUTION_SIGNERS
|
||||
),
|
||||
));
|
||||
}
|
||||
let mut required_unique =
|
||||
std::collections::BTreeMap::<std::string::String, ks_lib::MdPubkey>::new();
|
||||
for signer in required {
|
||||
required_unique
|
||||
.entry(signer.pubkey.0.clone())
|
||||
.or_insert_with(|| return signer.pubkey.clone());
|
||||
}
|
||||
let resolved_set = resolved
|
||||
.iter()
|
||||
.map(|signer| return signer.0.clone())
|
||||
.collect::<std::collections::BTreeSet<std::string::String>>();
|
||||
for signer in required_unique.values() {
|
||||
if !resolved_set.contains(signer.0.as_str()) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_execution_signer_unresolved",
|
||||
format!("Token-2022 required signer {} is unresolved", signer.0),
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(required_unique.into_values().collect());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn pubkey(byte: u8) -> ks_lib::MdPubkey {
|
||||
return ks_lib::MdPubkey(bs58::encode([byte; 32]).into_string());
|
||||
}
|
||||
|
||||
fn request() -> crate::Token2022ExecutionReadinessRequest {
|
||||
let signer = pubkey(1);
|
||||
let policy = ks_lib::ExApiExecutionPolicy {
|
||||
dry_run: false,
|
||||
..std::default::Default::default()
|
||||
};
|
||||
let plan = ks_lib::ExApiPreparedExecutionPlan {
|
||||
executor_name: "kb-lib.executor.spl.token_2022".to_string(),
|
||||
executor_version: "0.4.6".to_string(),
|
||||
intent_id: "intent".to_string(),
|
||||
operation_code: "spl.token_2022.confidential_transfer".to_string(),
|
||||
fee_payer: signer.clone(),
|
||||
instructions: vec![],
|
||||
required_signers: vec![ks_lib::ExApiRequiredSigner {
|
||||
pubkey: signer.clone(),
|
||||
role: "authority".to_string(),
|
||||
}],
|
||||
policy,
|
||||
requested_spend_lamports: 0,
|
||||
requested_compute_unit_price_micro_lamports: std::option::Option::None,
|
||||
};
|
||||
return crate::Token2022ExecutionReadinessRequest {
|
||||
plan,
|
||||
message_hash: "message-hash".to_string(),
|
||||
simulated_message_hash: "message-hash".to_string(),
|
||||
simulated: true,
|
||||
simulation_succeeded: true,
|
||||
stateful_preflight: crate::Token2022PreflightReport {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot: 100,
|
||||
requested_data_bytes: 0,
|
||||
accounts: vec![],
|
||||
elgamal_registry_validated: false,
|
||||
},
|
||||
cryptographic_preflight: crate::Token2022CryptographicPreflightReport {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot: 101,
|
||||
proof_contexts: vec![],
|
||||
},
|
||||
proof_orchestration: crate::Token2022ProofOrchestrationReport {
|
||||
operation_code: "spl.token_2022.confidential_transfer".to_string(),
|
||||
instructions_sysvar_required: true,
|
||||
inline_offsets: vec![1],
|
||||
context_requirements: vec![],
|
||||
simulation_required: true,
|
||||
send_authorized: true,
|
||||
checks: vec![],
|
||||
},
|
||||
resolved_signers: vec![signer],
|
||||
submit: true,
|
||||
operator_confirmed: true,
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_message_preflights_and_signers_authorize_submission() {
|
||||
let report = super::validate_token_2022_execution_readiness(&request())
|
||||
.expect("complete Token-2022 readiness must succeed");
|
||||
assert!(report.send_authorized);
|
||||
assert_eq!(report.context_slot, 101);
|
||||
assert_eq!(report.required_signers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_mismatch_missing_signer_and_dry_run_fail_closed() {
|
||||
let mut mismatched = request();
|
||||
mismatched.simulated_message_hash = "other".to_string();
|
||||
assert!(super::validate_token_2022_execution_readiness(&mismatched).is_err());
|
||||
let mut missing = request();
|
||||
missing.resolved_signers.clear();
|
||||
assert!(super::validate_token_2022_execution_readiness(&missing).is_err());
|
||||
let mut dry_run = request();
|
||||
dry_run.plan.policy.dry_run = true;
|
||||
assert!(super::validate_token_2022_execution_readiness(&dry_run).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postcondition_summary_preserves_contradicted_and_not_applicable() {
|
||||
let account = pubkey(2);
|
||||
let not_applicable = crate::Token2022ExecutionPostcondition {
|
||||
role: "mint".to_string(),
|
||||
account: account.clone(),
|
||||
status: crate::Token2022ExecutionPostconditionStatus::NotApplicable,
|
||||
diagnostic: "no supported final-state assertion".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
super::summarize_token_2022_postconditions(&[not_applicable]),
|
||||
crate::Token2022ExecutionPostconditionStatus::NotApplicable
|
||||
);
|
||||
let contradicted = crate::Token2022ExecutionPostcondition {
|
||||
role: "source".to_string(),
|
||||
account,
|
||||
status: crate::Token2022ExecutionPostconditionStatus::Contradicted,
|
||||
diagnostic: "final extension inventory contradicts the expected effect".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
super::summarize_token_2022_postconditions(&[contradicted]),
|
||||
crate::Token2022ExecutionPostconditionStatus::Contradicted
|
||||
);
|
||||
}
|
||||
}
|
||||
376
ks-pipeline/src/spl_token_2022_metadata.rs
Normal file
376
ks-pipeline/src/spl_token_2022_metadata.rs
Normal file
@@ -0,0 +1,376 @@
|
||||
// file: ks-pipeline/src/spl_token_2022_metadata.rs
|
||||
// version: 4
|
||||
|
||||
//! Token-2022 Token Metadata return-data and authority postcondition contracts.
|
||||
|
||||
use base64::Engine; // rust-rules: trait-import
|
||||
|
||||
/// Maximum Token Metadata bytes accepted from Solana return data.
|
||||
pub const MAX_TOKEN_2022_METADATA_EMIT_BYTES: usize = 1_024;
|
||||
|
||||
/// Bounded evidence extracted from one successful Token Metadata `Emit` simulation.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022MetadataEmitEvidence {
|
||||
/// Program that produced the return data.
|
||||
pub program_id: std::string::String,
|
||||
/// Optional requested range start.
|
||||
pub start: std::option::Option<u64>,
|
||||
/// Optional requested range end.
|
||||
pub end: std::option::Option<u64>,
|
||||
/// Decoded return-data bytes.
|
||||
pub data: std::vec::Vec<u8>,
|
||||
/// Exact complete Token Metadata projection, only for an un-ranged emit.
|
||||
pub decoded_metadata: std::option::Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Validates one Token Metadata `Emit` range against Solana return-data bounds.
|
||||
pub fn validate_token_2022_metadata_emit_range(
|
||||
start: std::option::Option<u64>,
|
||||
end: std::option::Option<u64>,
|
||||
) -> ks_core::Result<()> {
|
||||
if let (std::option::Option::Some(start), std::option::Option::Some(end)) = (start, end) {
|
||||
if start > end {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_emit_range_invalid",
|
||||
format!("Token Metadata Emit start {start} exceeds end {end}"),
|
||||
));
|
||||
}
|
||||
if end - start > crate::MAX_TOKEN_2022_METADATA_EMIT_BYTES as u64 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_emit_range_too_large",
|
||||
format!(
|
||||
"Token Metadata Emit range exceeds {} bytes",
|
||||
crate::MAX_TOKEN_2022_METADATA_EMIT_BYTES
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let (std::option::Option::None, std::option::Option::Some(end)) = (start, end) {
|
||||
if end > crate::MAX_TOKEN_2022_METADATA_EMIT_BYTES as u64 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_emit_range_too_large",
|
||||
format!(
|
||||
"Token Metadata Emit range from zero exceeds {} bytes",
|
||||
crate::MAX_TOKEN_2022_METADATA_EMIT_BYTES
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let (std::option::Option::Some(_), std::option::Option::None) = (start, end) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_emit_range_end_required",
|
||||
"Token Metadata Emit with a start offset requires an explicit end to remain bounded",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
/// Extracts and validates Token Metadata return data from one exact simulation.
|
||||
pub fn inspect_token_2022_metadata_emit_simulation(
|
||||
simulation: &ks_lib::ExApiExecutionSimulationResult,
|
||||
start: std::option::Option<u64>,
|
||||
end: std::option::Option<u64>,
|
||||
) -> ks_core::Result<crate::Token2022MetadataEmitEvidence> {
|
||||
match crate::validate_token_2022_metadata_emit_range(start, end) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
if !simulation.simulated || !simulation.success {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_emit_simulation_required",
|
||||
"Token Metadata Emit evidence requires a successful simulation",
|
||||
));
|
||||
}
|
||||
let return_data = match simulation.return_data.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_emit_return_data_missing",
|
||||
"Token Metadata Emit simulation did not return program data",
|
||||
));
|
||||
},
|
||||
};
|
||||
let program_id = match return_data.get("programId").and_then(serde_json::Value::as_str) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_emit_program_id_missing",
|
||||
"Token Metadata Emit return data is missing programId",
|
||||
));
|
||||
},
|
||||
};
|
||||
if program_id != ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_emit_program_id_mismatch",
|
||||
format!("Token Metadata Emit return data came from {program_id}"),
|
||||
));
|
||||
}
|
||||
let data = match return_data.get("data").and_then(serde_json::Value::as_array) {
|
||||
std::option::Option::Some(value) if value.len() == 2 => value,
|
||||
_ => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_emit_data_invalid",
|
||||
"Token Metadata Emit return data must be [payload, encoding]",
|
||||
));
|
||||
},
|
||||
};
|
||||
let payload = match data.first().and_then(|value| return value.as_str()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_emit_payload_invalid",
|
||||
"Token Metadata Emit return payload must be a base64 string",
|
||||
));
|
||||
},
|
||||
};
|
||||
let encoding = match data.get(1).and_then(|value| return value.as_str()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_emit_encoding_invalid",
|
||||
"Token Metadata Emit return data encoding is missing",
|
||||
));
|
||||
},
|
||||
};
|
||||
if encoding != "base64" {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_emit_encoding_invalid",
|
||||
"Token Metadata Emit return data encoding must be base64",
|
||||
));
|
||||
}
|
||||
let decoded = match base64::engine::general_purpose::STANDARD.decode(payload) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_emit_base64_invalid",
|
||||
format!("Token Metadata Emit return payload is invalid base64: {error}"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if decoded.len() > crate::MAX_TOKEN_2022_METADATA_EMIT_BYTES {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_emit_data_too_large",
|
||||
format!(
|
||||
"Token Metadata Emit returned {} bytes, maximum is {}",
|
||||
decoded.len(),
|
||||
crate::MAX_TOKEN_2022_METADATA_EMIT_BYTES
|
||||
),
|
||||
));
|
||||
}
|
||||
let decoded_metadata = if start.is_none() && end.is_none() {
|
||||
match ks_lib::decoder_spl_token_2022_parse_token_metadata_value(decoded.as_slice()) {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_emit_payload_decode_failed",
|
||||
error,
|
||||
));
|
||||
},
|
||||
}
|
||||
} else {
|
||||
std::option::Option::None
|
||||
};
|
||||
return std::result::Result::Ok(crate::Token2022MetadataEmitEvidence {
|
||||
program_id: program_id.to_string(),
|
||||
start,
|
||||
end,
|
||||
data: decoded,
|
||||
decoded_metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/// Checks the final embedded metadata authority against one authoritative snapshot.
|
||||
pub fn inspect_token_2022_metadata_authority_postcondition(
|
||||
account: &ks_lib::MdPubkey,
|
||||
expected_authority: std::option::Option<&ks_lib::MdPubkey>,
|
||||
snapshot: &crate::Token2022StatefulSnapshotBundle,
|
||||
) -> crate::Token2022ExecutionPostcondition {
|
||||
if snapshot.account_key != account.0 {
|
||||
return crate::Token2022ExecutionPostcondition {
|
||||
role: "metadata".to_string(),
|
||||
account: account.clone(),
|
||||
status: crate::Token2022ExecutionPostconditionStatus::Contradicted,
|
||||
diagnostic: "Token-2022 metadata snapshot belongs to a different account".to_string(),
|
||||
};
|
||||
}
|
||||
let output = snapshot.outputs.iter().find(|output| {
|
||||
return output.family == ks_lib::MdMaterializedEventFamily::Metadata
|
||||
&& output.payload_json.get("projectionKind").and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some("token_metadata");
|
||||
});
|
||||
let observed = output.and_then(|value| {
|
||||
return value.payload_json.get("valueFields").and_then(|fields| {
|
||||
return fields.get("updateAuthority");
|
||||
});
|
||||
});
|
||||
let confirmed = match (expected_authority, observed) {
|
||||
(std::option::Option::Some(expected), std::option::Option::Some(value)) => {
|
||||
value.as_str() == std::option::Option::Some(expected.0.as_str())
|
||||
},
|
||||
(std::option::Option::None, std::option::Option::Some(value)) => value.is_null(),
|
||||
_ => false,
|
||||
};
|
||||
return crate::Token2022ExecutionPostcondition {
|
||||
role: "metadata".to_string(),
|
||||
account: account.clone(),
|
||||
status: if confirmed {
|
||||
crate::Token2022ExecutionPostconditionStatus::Confirmed
|
||||
} else {
|
||||
crate::Token2022ExecutionPostconditionStatus::Contradicted
|
||||
},
|
||||
diagnostic: if confirmed {
|
||||
"authoritative Token-2022 metadata snapshot matches the expected update authority"
|
||||
.to_string()
|
||||
} else {
|
||||
"authoritative Token-2022 metadata snapshot contradicts or omits the expected update authority"
|
||||
.to_string()
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::Engine; // rust-rules: trait-import
|
||||
|
||||
fn simulation(data: &[u8]) -> ks_lib::ExApiExecutionSimulationResult {
|
||||
return ks_lib::ExApiExecutionSimulationResult {
|
||||
simulated: true,
|
||||
success: true,
|
||||
cluster: ks_lib::ExApiExecutionCluster::Devnet,
|
||||
blockhash_kind: ks_lib::ExApiExecutionBlockhashKind::Latest,
|
||||
blockhash_age_slots: std::option::Option::Some(0),
|
||||
replacement_blockhash: std::option::Option::None,
|
||||
replacement_last_valid_block_height: std::option::Option::None,
|
||||
nonce_account: std::option::Option::None,
|
||||
nonce_authority: std::option::Option::None,
|
||||
units_consumed: std::option::Option::Some(1),
|
||||
estimated_fee_lamports: std::option::Option::Some(5_000),
|
||||
logs: std::vec::Vec::new(),
|
||||
return_data: std::option::Option::Some(serde_json::json!({
|
||||
"programId": ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID,
|
||||
"data": [base64::engine::general_purpose::STANDARD.encode(data), "base64"]
|
||||
})),
|
||||
error: std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
fn metadata_bytes(authority: std::option::Option<[u8; 32]>) -> std::vec::Vec<u8> {
|
||||
let mut value = std::vec::Vec::new();
|
||||
value.extend_from_slice(authority.unwrap_or([0_u8; 32]).as_slice());
|
||||
value.extend_from_slice([7_u8; 32].as_slice());
|
||||
for field in ["Token", "TKN", "https://example.invalid/token.json"] {
|
||||
value.extend_from_slice((field.len() as u32).to_le_bytes().as_slice());
|
||||
value.extend_from_slice(field.as_bytes());
|
||||
}
|
||||
value.extend_from_slice(0_u32.to_le_bytes().as_slice());
|
||||
return value;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_emit_decodes_exact_metadata_and_ranged_emit_keeps_only_bytes() {
|
||||
let complete = crate::inspect_token_2022_metadata_emit_simulation(
|
||||
&simulation(metadata_bytes(std::option::Option::Some([9_u8; 32])).as_slice()),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("complete Emit evidence failed: {error}"));
|
||||
assert_eq!(
|
||||
complete
|
||||
.decoded_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| return value.get("name"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("Token")
|
||||
);
|
||||
let ranged = crate::inspect_token_2022_metadata_emit_simulation(
|
||||
&simulation(&[1_u8, 2_u8, 3_u8]),
|
||||
std::option::Option::Some(0),
|
||||
std::option::Option::Some(3),
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("ranged Emit evidence failed: {error}"));
|
||||
assert!(ranged.decoded_metadata.is_none());
|
||||
assert_eq!(ranged.data, vec![1_u8, 2_u8, 3_u8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_range_and_provider_contract_fail_closed() {
|
||||
assert!(
|
||||
crate::validate_token_2022_metadata_emit_range(
|
||||
std::option::Option::Some(2),
|
||||
std::option::Option::Some(1),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
crate::validate_token_2022_metadata_emit_range(
|
||||
std::option::Option::Some(0),
|
||||
std::option::Option::Some(1_025),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
crate::validate_token_2022_metadata_emit_range(
|
||||
std::option::Option::Some(1),
|
||||
std::option::Option::None,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut invalid = simulation(&[1_u8]);
|
||||
invalid.return_data = std::option::Option::Some(serde_json::json!({
|
||||
"programId": ks_program_ids::SYSTEM_PROGRAM_ID,
|
||||
"data": ["AQ==", "base64"]
|
||||
}));
|
||||
assert!(
|
||||
crate::inspect_token_2022_metadata_emit_simulation(
|
||||
&invalid,
|
||||
std::option::Option::Some(0),
|
||||
std::option::Option::Some(1)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authority_postcondition_accepts_exact_value_and_null() {
|
||||
let account = ks_lib::MdPubkey(bs58::encode([3_u8; 32]).into_string());
|
||||
let authority = ks_lib::MdPubkey(bs58::encode([4_u8; 32]).into_string());
|
||||
let mut snapshot = crate::Token2022StatefulSnapshotBundle {
|
||||
account_key: account.0.clone(),
|
||||
slot: 9,
|
||||
state_kind: "mint".to_string(),
|
||||
extension_names: vec!["token_metadata".to_string()],
|
||||
outputs: vec![ks_lib::MtApiMaterializedOutput {
|
||||
output_key: "metadata".to_string(),
|
||||
family: ks_lib::MdMaterializedEventFamily::Metadata,
|
||||
payload_json: serde_json::json!({
|
||||
"projectionKind": "token_metadata",
|
||||
"valueFields": {"updateAuthority": authority.0}
|
||||
}),
|
||||
}],
|
||||
};
|
||||
let confirmed = crate::inspect_token_2022_metadata_authority_postcondition(
|
||||
&account,
|
||||
std::option::Option::Some(&authority),
|
||||
&snapshot,
|
||||
);
|
||||
assert_eq!(confirmed.status, crate::Token2022ExecutionPostconditionStatus::Confirmed);
|
||||
snapshot.outputs[0].payload_json["valueFields"]["updateAuthority"] =
|
||||
serde_json::Value::Null;
|
||||
let cleared = crate::inspect_token_2022_metadata_authority_postcondition(
|
||||
&account,
|
||||
std::option::Option::None,
|
||||
&snapshot,
|
||||
);
|
||||
assert_eq!(cleared.status, crate::Token2022ExecutionPostconditionStatus::Confirmed);
|
||||
snapshot.account_key = bs58::encode([8_u8; 32]).into_string();
|
||||
let wrong_account = crate::inspect_token_2022_metadata_authority_postcondition(
|
||||
&account,
|
||||
std::option::Option::None,
|
||||
&snapshot,
|
||||
);
|
||||
assert_eq!(
|
||||
wrong_account.status,
|
||||
crate::Token2022ExecutionPostconditionStatus::Contradicted
|
||||
);
|
||||
}
|
||||
}
|
||||
431
ks-pipeline/src/spl_token_2022_preflight.rs
Normal file
431
ks-pipeline/src/spl_token_2022_preflight.rs
Normal file
@@ -0,0 +1,431 @@
|
||||
// file: ks-pipeline/src/spl_token_2022_preflight.rs
|
||||
// version: 5
|
||||
|
||||
//! Bounded Token-2022 stateful preflight orchestration.
|
||||
|
||||
/// Maximum distinct Token-2022 accounts accepted by one preflight inspection.
|
||||
pub const MAX_TOKEN_2022_PREFLIGHT_ACCOUNTS: usize = 16;
|
||||
/// Maximum aggregate account-data budget accepted by one preflight inspection.
|
||||
pub const MAX_TOKEN_2022_PREFLIGHT_TOTAL_BYTES: usize = 262_144;
|
||||
|
||||
/// One exact Token-2022 account requirement for a stateful preflight.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Token2022PreflightRequirement {
|
||||
/// Stable semantic role used in diagnostics.
|
||||
pub role: std::string::String,
|
||||
/// Canonical account address.
|
||||
pub account: ks_lib::MdPubkey,
|
||||
/// Expected Token-2022 state category.
|
||||
pub kind: ks_lib::DcToken2022StateKind,
|
||||
/// Maximum complete account bytes accepted for this requirement.
|
||||
pub max_data_bytes: usize,
|
||||
/// Optional expected mint for a Token Account.
|
||||
pub expected_mint: std::option::Option<ks_lib::MdPubkey>,
|
||||
/// Optional expected owner for a Token Account.
|
||||
pub expected_owner: std::option::Option<ks_lib::MdPubkey>,
|
||||
/// Optional exact decimals expected for a Mint.
|
||||
pub expected_decimals: std::option::Option<u8>,
|
||||
/// Published extension names required on the account.
|
||||
pub required_extensions: std::vec::Vec<std::string::String>,
|
||||
/// Optional external identities needed by cross-account extensions.
|
||||
pub context: crate::Token2022StatefulContext,
|
||||
}
|
||||
|
||||
/// One bounded Token-2022 preflight request.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Token2022PreflightRequest {
|
||||
/// Endpoint role used by all RPC reads.
|
||||
pub query_role: std::string::String,
|
||||
/// Optional minimum context slot shared by all reads.
|
||||
pub min_context_slot: std::option::Option<u64>,
|
||||
/// Maximum distinct accounts accepted after deduplication.
|
||||
pub max_accounts: usize,
|
||||
/// Maximum aggregate requested account bytes.
|
||||
pub max_total_data_bytes: usize,
|
||||
/// Ordered account requirements.
|
||||
pub requirements: std::vec::Vec<Token2022PreflightRequirement>,
|
||||
/// Optional ElGamal registry read required by the operation.
|
||||
pub elgamal_registry: std::option::Option<crate::ElGamalRegistryStatefulReadRequest>,
|
||||
}
|
||||
|
||||
/// One validated account result in a Token-2022 preflight report.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022PreflightAccountReport {
|
||||
/// Stable semantic role.
|
||||
pub role: std::string::String,
|
||||
/// Canonical account address.
|
||||
pub account: ks_lib::MdPubkey,
|
||||
/// State category observed after parsing.
|
||||
pub state_kind: std::string::String,
|
||||
/// Context slot returned by the endpoint.
|
||||
pub context_slot: u64,
|
||||
/// Ordered extension names observed on the account.
|
||||
pub extension_names: std::vec::Vec<std::string::String>,
|
||||
/// Ordered successful semantic checks.
|
||||
pub checks: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// Complete bounded Token-2022 stateful preflight report.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022PreflightReport {
|
||||
/// Commitment used for all account reads.
|
||||
pub commitment: std::string::String,
|
||||
/// Highest context slot observed across all reads.
|
||||
pub context_slot: u64,
|
||||
/// Aggregate requested account-data budget.
|
||||
pub requested_data_bytes: usize,
|
||||
/// Ordered distinct Token-2022 account reports.
|
||||
pub accounts: std::vec::Vec<Token2022PreflightAccountReport>,
|
||||
/// Whether an ElGamal registry was required and validated.
|
||||
pub elgamal_registry_validated: bool,
|
||||
}
|
||||
|
||||
/// Inspects all bounded Token-2022 state required before simulation.
|
||||
pub async fn inspect_token_2022_preflight(
|
||||
pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
request: &crate::Token2022PreflightRequest,
|
||||
) -> ks_core::Result<crate::Token2022PreflightReport> {
|
||||
let requirements = match validate_and_deduplicate_requirements(request) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let requested_data_bytes = requirements
|
||||
.iter()
|
||||
.fold(0usize, |total, requirement| {
|
||||
return total.saturating_add(requirement.max_data_bytes);
|
||||
})
|
||||
.saturating_add(if request.elgamal_registry.is_some() {
|
||||
crate::ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES
|
||||
} else {
|
||||
0
|
||||
});
|
||||
let mut context_slot = request.min_context_slot.unwrap_or(0);
|
||||
let mut accounts = std::vec::Vec::with_capacity(requirements.len());
|
||||
for requirement in requirements {
|
||||
let read_request = crate::Token2022StatefulReadRequest {
|
||||
query_role: request.query_role.clone(),
|
||||
account: requirement.account.clone(),
|
||||
kind: requirement.kind,
|
||||
min_context_slot: request.min_context_slot,
|
||||
max_data_bytes: requirement.max_data_bytes,
|
||||
context: requirement.context.clone(),
|
||||
};
|
||||
let read_result = match crate::read_token_2022_stateful_snapshot(pool, &read_request).await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let account_report = match validate_snapshot_requirement(&requirement, &read_result) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
context_slot = context_slot.max(account_report.context_slot);
|
||||
accounts.push(account_report);
|
||||
}
|
||||
let mut elgamal_registry_validated = false;
|
||||
if let std::option::Option::Some(registry_request) = request.elgamal_registry.as_ref() {
|
||||
if registry_request.query_role != request.query_role {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 preflight ElGamal registry query role must match the shared query role",
|
||||
));
|
||||
}
|
||||
let registry_result =
|
||||
match crate::read_elgamal_registry_stateful_snapshot(pool, registry_request).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
context_slot = context_slot.max(registry_result.context_slot);
|
||||
elgamal_registry_validated = true;
|
||||
}
|
||||
return std::result::Result::Ok(crate::Token2022PreflightReport {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot,
|
||||
requested_data_bytes,
|
||||
accounts,
|
||||
elgamal_registry_validated,
|
||||
});
|
||||
}
|
||||
|
||||
fn validate_and_deduplicate_requirements(
|
||||
request: &crate::Token2022PreflightRequest,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::Token2022PreflightRequirement>> {
|
||||
if request.query_role.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 preflight query_role must not be empty",
|
||||
));
|
||||
}
|
||||
if request.max_accounts == 0 || request.max_accounts > crate::MAX_TOKEN_2022_PREFLIGHT_ACCOUNTS
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 preflight max_accounts must be between 1 and {}",
|
||||
crate::MAX_TOKEN_2022_PREFLIGHT_ACCOUNTS
|
||||
)));
|
||||
}
|
||||
if request.max_total_data_bytes == 0
|
||||
|| request.max_total_data_bytes > crate::MAX_TOKEN_2022_PREFLIGHT_TOTAL_BYTES
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 preflight max_total_data_bytes must be between 1 and {}",
|
||||
crate::MAX_TOKEN_2022_PREFLIGHT_TOTAL_BYTES
|
||||
)));
|
||||
}
|
||||
if request.requirements.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 preflight requires at least one account",
|
||||
));
|
||||
}
|
||||
let mut indexes = std::collections::BTreeMap::<std::string::String, usize>::new();
|
||||
let mut unique = std::vec::Vec::<crate::Token2022PreflightRequirement>::new();
|
||||
for requirement in &request.requirements {
|
||||
if requirement.role.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 preflight requirement role must not be empty",
|
||||
));
|
||||
}
|
||||
if requirement.max_data_bytes == 0
|
||||
|| requirement.max_data_bytes > crate::MAX_TOKEN_2022_STATEFUL_ACCOUNT_BYTES
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 preflight account {} has invalid max_data_bytes {}",
|
||||
requirement.account.0, requirement.max_data_bytes
|
||||
)));
|
||||
}
|
||||
if let std::option::Option::Some(index) = indexes.get(requirement.account.0.as_str()) {
|
||||
if &unique[*index] != requirement {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_preflight_conflicting_duplicate",
|
||||
format!(
|
||||
"Token-2022 preflight account {} has conflicting requirements",
|
||||
requirement.account.0
|
||||
),
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
indexes.insert(requirement.account.0.clone(), unique.len());
|
||||
unique.push(requirement.clone());
|
||||
}
|
||||
let total_accounts =
|
||||
unique
|
||||
.len()
|
||||
.saturating_add(if request.elgamal_registry.is_some() { 1 } else { 0 });
|
||||
if total_accounts > request.max_accounts {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_preflight_account_limit_exceeded",
|
||||
format!(
|
||||
"Token-2022 preflight requires {total_accounts} distinct accounts above limit {}",
|
||||
request.max_accounts
|
||||
),
|
||||
));
|
||||
}
|
||||
let requested_data_bytes = unique
|
||||
.iter()
|
||||
.fold(0usize, |total, requirement| {
|
||||
return total.saturating_add(requirement.max_data_bytes);
|
||||
})
|
||||
.saturating_add(if request.elgamal_registry.is_some() {
|
||||
crate::ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES
|
||||
} else {
|
||||
0
|
||||
});
|
||||
if requested_data_bytes > request.max_total_data_bytes {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_preflight_data_budget_exceeded",
|
||||
format!(
|
||||
"Token-2022 preflight requests {requested_data_bytes} bytes above aggregate limit {}",
|
||||
request.max_total_data_bytes
|
||||
),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(unique);
|
||||
}
|
||||
|
||||
fn validate_snapshot_requirement(
|
||||
requirement: &crate::Token2022PreflightRequirement,
|
||||
result: &crate::Token2022StatefulReadResult,
|
||||
) -> ks_core::Result<crate::Token2022PreflightAccountReport> {
|
||||
let token_output = result
|
||||
.snapshot
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|output| return output.payload_json["domain"] == "spl_token_2022_account_state");
|
||||
let token_output = match token_output {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::invalid_state(
|
||||
"Token-2022 preflight snapshot is missing its token account owner projection",
|
||||
));
|
||||
},
|
||||
};
|
||||
let base_fields = &token_output.payload_json["baseFields"];
|
||||
let mut checks = std::vec![
|
||||
"owner_program_id".to_string(),
|
||||
"complete_account_data".to_string(),
|
||||
"state_kind".to_string()
|
||||
];
|
||||
if let std::option::Option::Some(expected_mint) = requirement.expected_mint.as_ref() {
|
||||
if base_fields["mint"].as_str() != std::option::Option::Some(expected_mint.0.as_str()) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_preflight_mint_mismatch",
|
||||
format!(
|
||||
"Token-2022 account {} mint does not match expected {}",
|
||||
requirement.account.0, expected_mint.0
|
||||
),
|
||||
));
|
||||
}
|
||||
checks.push("mint_identity".to_string());
|
||||
}
|
||||
if let std::option::Option::Some(expected_owner) = requirement.expected_owner.as_ref() {
|
||||
if base_fields["owner"].as_str() != std::option::Option::Some(expected_owner.0.as_str()) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_preflight_owner_mismatch",
|
||||
format!(
|
||||
"Token-2022 account {} owner does not match expected {}",
|
||||
requirement.account.0, expected_owner.0
|
||||
),
|
||||
));
|
||||
}
|
||||
checks.push("token_account_owner".to_string());
|
||||
}
|
||||
if let std::option::Option::Some(expected_decimals) = requirement.expected_decimals {
|
||||
if base_fields["decimals"].as_u64() != std::option::Option::Some(expected_decimals as u64) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_preflight_decimals_mismatch",
|
||||
format!(
|
||||
"Token-2022 mint {} decimals do not match expected {expected_decimals}",
|
||||
requirement.account.0
|
||||
),
|
||||
));
|
||||
}
|
||||
checks.push("mint_decimals".to_string());
|
||||
}
|
||||
for required_extension in &requirement.required_extensions {
|
||||
if !result
|
||||
.snapshot
|
||||
.extension_names
|
||||
.iter()
|
||||
.any(|value| return value == required_extension)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_preflight_extension_missing",
|
||||
format!(
|
||||
"Token-2022 account {} is missing required extension {required_extension}",
|
||||
requirement.account.0
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !requirement.required_extensions.is_empty() {
|
||||
checks.push("required_extensions".to_string());
|
||||
}
|
||||
return std::result::Result::Ok(crate::Token2022PreflightAccountReport {
|
||||
role: requirement.role.clone(),
|
||||
account: requirement.account.clone(),
|
||||
state_kind: result.snapshot.state_kind.clone(),
|
||||
context_slot: result.context_slot,
|
||||
extension_names: result.snapshot.extension_names.clone(),
|
||||
checks,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn pubkey(byte: u8) -> ks_lib::MdPubkey {
|
||||
return ks_lib::MdPubkey(bs58::encode([byte; 32]).into_string());
|
||||
}
|
||||
|
||||
fn requirement(account: ks_lib::MdPubkey) -> crate::Token2022PreflightRequirement {
|
||||
return crate::Token2022PreflightRequirement {
|
||||
role: "source".to_string(),
|
||||
account,
|
||||
kind: ks_lib::DcToken2022StateKind::Account,
|
||||
max_data_bytes: 512,
|
||||
expected_mint: std::option::Option::None,
|
||||
expected_owner: std::option::Option::None,
|
||||
expected_decimals: std::option::Option::None,
|
||||
required_extensions: std::vec::Vec::new(),
|
||||
context: crate::Token2022StatefulContext::default(),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_duplicate_requirements_are_deduplicated_and_bounded() {
|
||||
let item = requirement(pubkey(1));
|
||||
let request = crate::Token2022PreflightRequest {
|
||||
query_role: "query".to_string(),
|
||||
min_context_slot: std::option::Option::Some(7),
|
||||
max_accounts: 1,
|
||||
max_total_data_bytes: 512,
|
||||
requirements: std::vec![item.clone(), item],
|
||||
elgamal_registry: std::option::Option::None,
|
||||
};
|
||||
let result = super::validate_and_deduplicate_requirements(&request);
|
||||
assert_eq!(result.as_ref().map(std::vec::Vec::len), std::result::Result::Ok(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_duplicates_and_aggregate_budget_fail_closed() {
|
||||
let first = requirement(pubkey(2));
|
||||
let mut conflicting = first.clone();
|
||||
conflicting.role = "destination".to_string();
|
||||
let conflict_request = crate::Token2022PreflightRequest {
|
||||
query_role: "query".to_string(),
|
||||
min_context_slot: std::option::Option::None,
|
||||
max_accounts: 2,
|
||||
max_total_data_bytes: 1024,
|
||||
requirements: std::vec![first, conflicting],
|
||||
elgamal_registry: std::option::Option::None,
|
||||
};
|
||||
assert!(super::validate_and_deduplicate_requirements(&conflict_request).is_err());
|
||||
let budget_request = crate::Token2022PreflightRequest {
|
||||
query_role: "query".to_string(),
|
||||
min_context_slot: std::option::Option::None,
|
||||
max_accounts: 2,
|
||||
max_total_data_bytes: 700,
|
||||
requirements: std::vec![requirement(pubkey(3)), requirement(pubkey(4))],
|
||||
elgamal_registry: std::option::Option::None,
|
||||
};
|
||||
assert!(super::validate_and_deduplicate_requirements(&budget_request).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requirement_validation_checks_mint_owner_decimals_and_extensions() {
|
||||
let account = pubkey(5);
|
||||
let mint = pubkey(6);
|
||||
let owner = pubkey(7);
|
||||
let requirement = crate::Token2022PreflightRequirement {
|
||||
role: "source".to_string(),
|
||||
account: account.clone(),
|
||||
kind: ks_lib::DcToken2022StateKind::Account,
|
||||
max_data_bytes: 512,
|
||||
expected_mint: std::option::Option::Some(mint.clone()),
|
||||
expected_owner: std::option::Option::Some(owner.clone()),
|
||||
expected_decimals: std::option::Option::None,
|
||||
required_extensions: std::vec!["memo_transfer".to_string()],
|
||||
context: crate::Token2022StatefulContext::default(),
|
||||
};
|
||||
let result = crate::Token2022StatefulReadResult {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot: 42,
|
||||
snapshot: crate::Token2022StatefulSnapshotBundle {
|
||||
account_key: account.0.clone(),
|
||||
slot: 42,
|
||||
state_kind: "account".to_string(),
|
||||
extension_names: std::vec!["memo_transfer".to_string()],
|
||||
outputs: std::vec![ks_lib::MtApiMaterializedOutput {
|
||||
output_key: "state".to_string(),
|
||||
family: ks_lib::MdMaterializedEventFamily::TokenAccount,
|
||||
payload_json: serde_json::json!({
|
||||
"domain":"spl_token_2022_account_state",
|
||||
"baseFields":{"mint":mint.0,"owner":owner.0}
|
||||
}),
|
||||
}],
|
||||
},
|
||||
};
|
||||
let report = super::validate_snapshot_requirement(&requirement, &result);
|
||||
assert_eq!(
|
||||
report.as_ref().map(|value| return value.checks.len()),
|
||||
std::result::Result::Ok(6)
|
||||
);
|
||||
}
|
||||
}
|
||||
373
ks-pipeline/src/spl_token_2022_proof_orchestration.rs
Normal file
373
ks-pipeline/src/spl_token_2022_proof_orchestration.rs
Normal file
@@ -0,0 +1,373 @@
|
||||
// file: ks-pipeline/src/spl_token_2022_proof_orchestration.rs
|
||||
// version: 6
|
||||
|
||||
//! Deterministic orchestration contract for mixed Token-2022 proof locations.
|
||||
|
||||
/// Maximum proof references accepted by one confidential Token-2022 operation.
|
||||
pub const MAX_TOKEN_2022_OPERATION_PROOFS: usize = 5;
|
||||
|
||||
/// One bounded proof-orchestration request prepared before transaction assembly.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Token2022ProofOrchestrationRequest {
|
||||
/// Stable executor operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Exact ordered proof references required by the operation.
|
||||
pub proofs: std::vec::Vec<ks_lib::ExSplTokenConfidentialProofReference>,
|
||||
/// Optional authority expected on every context-state account.
|
||||
pub expected_context_authority: std::option::Option<ks_lib::MdPubkey>,
|
||||
/// Maximum compute-unit limit accepted for the future transaction.
|
||||
pub compute_unit_limit: u32,
|
||||
/// Maximum total fee accepted for the future transaction.
|
||||
pub max_fee_lamports: u64,
|
||||
/// Whether a future submission was explicitly requested.
|
||||
pub submit: bool,
|
||||
/// Whether the operator explicitly confirmed the future submission.
|
||||
pub operator_confirmed: bool,
|
||||
}
|
||||
|
||||
/// One deterministic mixed-proof orchestration result.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Token2022ProofOrchestrationReport {
|
||||
/// Stable executor operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Whether the instructions sysvar must be present.
|
||||
pub instructions_sysvar_required: bool,
|
||||
/// Ordered non-zero inline offsets.
|
||||
pub inline_offsets: std::vec::Vec<i8>,
|
||||
/// Ordered validated context-state requirements.
|
||||
pub context_requirements: std::vec::Vec<crate::Token2022ProofContextRequirement>,
|
||||
/// Simulation is always mandatory.
|
||||
pub simulation_required: bool,
|
||||
/// Whether the future send path is authorized by request-local policy.
|
||||
pub send_authorized: bool,
|
||||
/// Ordered successful checks.
|
||||
pub checks: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// Validates mixed inline/context-state proof orchestration before transaction assembly.
|
||||
pub fn orchestrate_token_2022_proofs(
|
||||
request: &crate::Token2022ProofOrchestrationRequest,
|
||||
cryptographic_preflight: &crate::Token2022CryptographicPreflightReport,
|
||||
) -> ks_core::Result<crate::Token2022ProofOrchestrationReport> {
|
||||
if request.operation_code.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 proof orchestration operation_code must not be empty",
|
||||
));
|
||||
}
|
||||
if request.proofs.len() > crate::MAX_TOKEN_2022_OPERATION_PROOFS {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_operation_proof_limit_exceeded",
|
||||
format!(
|
||||
"Token-2022 proof orchestration accepts at most {} proofs",
|
||||
crate::MAX_TOKEN_2022_OPERATION_PROOFS
|
||||
),
|
||||
));
|
||||
}
|
||||
if request.compute_unit_limit == 0 || request.max_fee_lamports == 0 {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 proof orchestration requires non-zero compute and fee ceilings",
|
||||
));
|
||||
}
|
||||
if request.submit && !request.operator_confirmed {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_submission_confirmation_required",
|
||||
"Token-2022 submission requires explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
let mut kinds = std::collections::BTreeSet::<std::string::String>::new();
|
||||
let mut offsets = std::collections::BTreeSet::<i8>::new();
|
||||
let mut context_accounts = std::collections::BTreeSet::<std::string::String>::new();
|
||||
let mut inline_offsets = std::vec::Vec::<i8>::new();
|
||||
let mut context_requirements = std::vec::Vec::<crate::Token2022ProofContextRequirement>::new();
|
||||
for proof in &request.proofs {
|
||||
let kind_code = proof_kind_code(proof.kind).to_string();
|
||||
if !kinds.insert(kind_code.clone()) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_duplicate_proof_kind",
|
||||
format!(
|
||||
"Token-2022 operation {} contains duplicate proof kind {kind_code}",
|
||||
request.operation_code
|
||||
),
|
||||
));
|
||||
}
|
||||
if let std::result::Result::Err(error) = proof.location.validate() {
|
||||
return std::result::Result::Err(ks_core::Error::invalid_state(error));
|
||||
}
|
||||
match &proof.location {
|
||||
ks_lib::ExSplTokenConfidentialProofLocation::InstructionOffset { offset } => {
|
||||
if !offsets.insert(*offset) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_duplicate_inline_proof_offset",
|
||||
format!(
|
||||
"Token-2022 operation {} reuses inline proof offset {offset}",
|
||||
request.operation_code
|
||||
),
|
||||
));
|
||||
}
|
||||
inline_offsets.push(*offset);
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofLocation::ContextStateAccount { account } => {
|
||||
if !context_accounts.insert(account.0.clone()) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_duplicate_context_state_account",
|
||||
format!(
|
||||
"Token-2022 operation {} reuses context-state account {}",
|
||||
request.operation_code, account.0
|
||||
),
|
||||
));
|
||||
}
|
||||
context_requirements.push(crate::Token2022ProofContextRequirement {
|
||||
role: kind_code,
|
||||
account: account.clone(),
|
||||
proof_type: proof_kind_to_zk_type(proof.kind),
|
||||
expected_authority: request.expected_context_authority.clone(),
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_context_reports(context_requirements.as_slice(), cryptographic_preflight)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(crate::Token2022ProofOrchestrationReport {
|
||||
operation_code: request.operation_code.clone(),
|
||||
instructions_sysvar_required: !inline_offsets.is_empty(),
|
||||
inline_offsets,
|
||||
context_requirements,
|
||||
simulation_required: true,
|
||||
send_authorized: request.submit && request.operator_confirmed,
|
||||
checks: vec![
|
||||
"ordered_unique_proof_kinds".to_string(),
|
||||
"non_zero_unique_inline_offsets".to_string(),
|
||||
"unique_context_state_accounts".to_string(),
|
||||
"context_preflight_matches_operation".to_string(),
|
||||
"simulation_required".to_string(),
|
||||
"bounded_compute_and_fee".to_string(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
fn validate_context_reports(
|
||||
requirements: &[crate::Token2022ProofContextRequirement],
|
||||
report: &crate::Token2022CryptographicPreflightReport,
|
||||
) -> ks_core::Result<()> {
|
||||
if requirements.len() != report.proof_contexts.len() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_context_preflight_count_mismatch",
|
||||
format!(
|
||||
"Token-2022 operation requires {} context states, preflight contains {}",
|
||||
requirements.len(),
|
||||
report.proof_contexts.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
for (requirement, observed) in requirements.iter().zip(report.proof_contexts.iter()) {
|
||||
if requirement.account != observed.account
|
||||
|| requirement.proof_type.discriminator() != observed.proof_discriminator
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_context_preflight_mismatch",
|
||||
format!(
|
||||
"Token-2022 proof context {} does not match the ordered operation requirement",
|
||||
observed.account.0
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn proof_kind_code(kind: ks_lib::ExSplTokenConfidentialProofKind) -> &'static str {
|
||||
return match kind {
|
||||
ks_lib::ExSplTokenConfidentialProofKind::PubkeyValidity => "pubkey_validity",
|
||||
ks_lib::ExSplTokenConfidentialProofKind::ZeroCiphertext => "zero_ciphertext",
|
||||
ks_lib::ExSplTokenConfidentialProofKind::CiphertextCommitmentEquality => {
|
||||
"ciphertext_commitment_equality"
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofKind::CiphertextCiphertextEquality => {
|
||||
"ciphertext_ciphertext_equality"
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofKind::BatchedGroupedCiphertext3HandlesValidity => {
|
||||
"batched_grouped_ciphertext_3_handles_validity"
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofKind::BatchedGroupedCiphertext2HandlesValidity => {
|
||||
"batched_grouped_ciphertext_2_handles_validity"
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofKind::PercentageWithFee => "percentage_with_fee",
|
||||
ks_lib::ExSplTokenConfidentialProofKind::BatchedRangeProofU64 => "batched_range_proof_u64",
|
||||
ks_lib::ExSplTokenConfidentialProofKind::BatchedRangeProofU128 => {
|
||||
"batched_range_proof_u128"
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofKind::BatchedRangeProofU256 => {
|
||||
"batched_range_proof_u256"
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn proof_kind_to_zk_type(
|
||||
kind: ks_lib::ExSplTokenConfidentialProofKind,
|
||||
) -> ks_lib::ExSolanaCoreZkElGamalProofType {
|
||||
return match kind {
|
||||
ks_lib::ExSplTokenConfidentialProofKind::PubkeyValidity => {
|
||||
ks_lib::ExSolanaCoreZkElGamalProofType::PubkeyValidity
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofKind::ZeroCiphertext => {
|
||||
ks_lib::ExSolanaCoreZkElGamalProofType::ZeroCiphertext
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofKind::CiphertextCommitmentEquality => {
|
||||
ks_lib::ExSolanaCoreZkElGamalProofType::CiphertextCommitmentEquality
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofKind::CiphertextCiphertextEquality => {
|
||||
ks_lib::ExSolanaCoreZkElGamalProofType::CiphertextCiphertextEquality
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofKind::BatchedGroupedCiphertext3HandlesValidity => {
|
||||
ks_lib::ExSolanaCoreZkElGamalProofType::BatchedGroupedCiphertext3HandlesValidity
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofKind::BatchedGroupedCiphertext2HandlesValidity => {
|
||||
ks_lib::ExSolanaCoreZkElGamalProofType::BatchedGroupedCiphertext2HandlesValidity
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofKind::PercentageWithFee => {
|
||||
ks_lib::ExSolanaCoreZkElGamalProofType::PercentageWithCap
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofKind::BatchedRangeProofU64 => {
|
||||
ks_lib::ExSolanaCoreZkElGamalProofType::BatchedRangeProofU64
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofKind::BatchedRangeProofU128 => {
|
||||
ks_lib::ExSolanaCoreZkElGamalProofType::BatchedRangeProofU128
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofKind::BatchedRangeProofU256 => {
|
||||
ks_lib::ExSolanaCoreZkElGamalProofType::BatchedRangeProofU256
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn pubkey(byte: u8) -> ks_lib::MdPubkey {
|
||||
return ks_lib::MdPubkey(bs58::encode([byte; 32]).into_string());
|
||||
}
|
||||
|
||||
fn context_report(
|
||||
account: ks_lib::MdPubkey,
|
||||
proof_type: ks_lib::ExSolanaCoreZkElGamalProofType,
|
||||
) -> crate::Token2022ProofContextReport {
|
||||
return crate::Token2022ProofContextReport {
|
||||
role: "proof".to_string(),
|
||||
account,
|
||||
proof_discriminator: proof_type.discriminator(),
|
||||
context_state_bytes: proof_type.context_state_size(),
|
||||
context_slot: 100,
|
||||
checks: vec!["validated".to_string()],
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_proofs_require_sysvar_and_preserve_ordered_contexts() {
|
||||
let context = pubkey(1);
|
||||
let request = crate::Token2022ProofOrchestrationRequest {
|
||||
operation_code: "spl.token_2022.confidential_transfer".to_string(),
|
||||
proofs: vec![
|
||||
ks_lib::ExSplTokenConfidentialProofReference {
|
||||
kind: ks_lib::ExSplTokenConfidentialProofKind::CiphertextCommitmentEquality,
|
||||
location: ks_lib::ExSplTokenConfidentialProofLocation::InstructionOffset {
|
||||
offset: 1,
|
||||
},
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofReference {
|
||||
kind: ks_lib::ExSplTokenConfidentialProofKind::BatchedRangeProofU128,
|
||||
location: ks_lib::ExSplTokenConfidentialProofLocation::ContextStateAccount {
|
||||
account: context.clone(),
|
||||
},
|
||||
},
|
||||
],
|
||||
expected_context_authority: std::option::Option::None,
|
||||
compute_unit_limit: 400_000,
|
||||
max_fee_lamports: 50_000,
|
||||
submit: false,
|
||||
operator_confirmed: false,
|
||||
};
|
||||
let preflight = crate::Token2022CryptographicPreflightReport {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot: 100,
|
||||
proof_contexts: vec![context_report(
|
||||
context,
|
||||
ks_lib::ExSolanaCoreZkElGamalProofType::BatchedRangeProofU128,
|
||||
)],
|
||||
};
|
||||
let report = crate::orchestrate_token_2022_proofs(&request, &preflight)
|
||||
.expect("mixed proof orchestration must succeed");
|
||||
assert!(report.instructions_sysvar_required);
|
||||
assert_eq!(report.inline_offsets, vec![1]);
|
||||
assert_eq!(report.context_requirements.len(), 1);
|
||||
assert!(report.simulation_required);
|
||||
assert!(!report.send_authorized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_only_proofs_do_not_require_instructions_sysvar() {
|
||||
let context = pubkey(2);
|
||||
let request = crate::Token2022ProofOrchestrationRequest {
|
||||
operation_code: "spl.token_2022.empty_confidential_account".to_string(),
|
||||
proofs: vec![ks_lib::ExSplTokenConfidentialProofReference {
|
||||
kind: ks_lib::ExSplTokenConfidentialProofKind::ZeroCiphertext,
|
||||
location: ks_lib::ExSplTokenConfidentialProofLocation::ContextStateAccount {
|
||||
account: context.clone(),
|
||||
},
|
||||
}],
|
||||
expected_context_authority: std::option::Option::None,
|
||||
compute_unit_limit: 200_000,
|
||||
max_fee_lamports: 20_000,
|
||||
submit: true,
|
||||
operator_confirmed: true,
|
||||
};
|
||||
let preflight = crate::Token2022CryptographicPreflightReport {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot: 100,
|
||||
proof_contexts: vec![context_report(
|
||||
context,
|
||||
ks_lib::ExSolanaCoreZkElGamalProofType::ZeroCiphertext,
|
||||
)],
|
||||
};
|
||||
let report = crate::orchestrate_token_2022_proofs(&request, &preflight)
|
||||
.expect("context-only orchestration must succeed");
|
||||
assert!(!report.instructions_sysvar_required);
|
||||
assert!(report.send_authorized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_offsets_contexts_and_unconfirmed_submission_fail_closed() {
|
||||
let duplicate_offset = crate::Token2022ProofOrchestrationRequest {
|
||||
operation_code: "operation".to_string(),
|
||||
proofs: vec![
|
||||
ks_lib::ExSplTokenConfidentialProofReference {
|
||||
kind: ks_lib::ExSplTokenConfidentialProofKind::ZeroCiphertext,
|
||||
location: ks_lib::ExSplTokenConfidentialProofLocation::InstructionOffset {
|
||||
offset: 1,
|
||||
},
|
||||
},
|
||||
ks_lib::ExSplTokenConfidentialProofReference {
|
||||
kind: ks_lib::ExSplTokenConfidentialProofKind::BatchedRangeProofU64,
|
||||
location: ks_lib::ExSplTokenConfidentialProofLocation::InstructionOffset {
|
||||
offset: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
expected_context_authority: std::option::Option::None,
|
||||
compute_unit_limit: 1,
|
||||
max_fee_lamports: 1,
|
||||
submit: false,
|
||||
operator_confirmed: false,
|
||||
};
|
||||
let empty = crate::Token2022CryptographicPreflightReport {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot: 0,
|
||||
proof_contexts: vec![],
|
||||
};
|
||||
assert!(crate::orchestrate_token_2022_proofs(&duplicate_offset, &empty).is_err());
|
||||
let mut unconfirmed = duplicate_offset;
|
||||
unconfirmed.proofs.clear();
|
||||
unconfirmed.submit = true;
|
||||
assert!(crate::orchestrate_token_2022_proofs(&unconfirmed, &empty).is_err());
|
||||
}
|
||||
}
|
||||
751
ks-pipeline/src/spl_token_2022_stateful.rs
Normal file
751
ks-pipeline/src/spl_token_2022_stateful.rs
Normal file
@@ -0,0 +1,751 @@
|
||||
// file: ks-pipeline/src/spl_token_2022_stateful.rs
|
||||
// version: 9
|
||||
|
||||
//! Contextual Token-2022 account-state validation and materialization routing.
|
||||
|
||||
/// Optional external identities required to validate cross-account Token-2022 state.
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022StatefulContext {
|
||||
/// Expected group account for one TokenGroupMember extension.
|
||||
pub expected_group_address: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
/// Maximum complete Token-2022 account data accepted by one bounded RPC read.
|
||||
pub const MAX_TOKEN_2022_STATEFUL_ACCOUNT_BYTES: usize = 65_536;
|
||||
|
||||
/// One bounded Token-2022 account read request.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Token2022StatefulReadRequest {
|
||||
/// Endpoint role used for the HTTP RPC request.
|
||||
pub query_role: std::string::String,
|
||||
/// Canonical account address.
|
||||
pub account: ks_lib::MdPubkey,
|
||||
/// Expected Token-2022 base-state category.
|
||||
pub kind: ks_lib::DcToken2022StateKind,
|
||||
/// Optional minimum RPC context slot.
|
||||
pub min_context_slot: std::option::Option<u64>,
|
||||
/// Maximum decoded account bytes accepted from the endpoint.
|
||||
pub max_data_bytes: usize,
|
||||
/// Optional external identities needed by cross-account extensions.
|
||||
pub context: crate::Token2022StatefulContext,
|
||||
}
|
||||
|
||||
/// One bounded RPC read and its contextually validated projections.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022StatefulReadResult {
|
||||
/// Commitment used for the RPC read.
|
||||
pub commitment: std::string::String,
|
||||
/// Context slot returned by the endpoint.
|
||||
pub context_slot: u64,
|
||||
/// Complete validated snapshot bundle.
|
||||
pub snapshot: crate::Token2022StatefulSnapshotBundle,
|
||||
}
|
||||
|
||||
/// Reads, validates, parses, and routes one bounded Token-2022 account snapshot.
|
||||
pub async fn read_token_2022_stateful_snapshot(
|
||||
pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
request: &crate::Token2022StatefulReadRequest,
|
||||
) -> ks_core::Result<crate::Token2022StatefulReadResult> {
|
||||
if request.query_role.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 stateful read query_role must not be empty",
|
||||
));
|
||||
}
|
||||
if request.max_data_bytes == 0
|
||||
|| request.max_data_bytes > crate::MAX_TOKEN_2022_STATEFUL_ACCOUNT_BYTES
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 stateful read max_data_bytes must be between 1 and {}",
|
||||
crate::MAX_TOKEN_2022_STATEFUL_ACCOUNT_BYTES
|
||||
)));
|
||||
}
|
||||
let config = match ks_onchain_transport::GetAccountInfoConfig::new_with_data(
|
||||
ks_onchain_transport::RpcCommitmentLevel::Confirmed,
|
||||
request.min_context_slot,
|
||||
request.max_data_bytes,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = match pool
|
||||
.get_account_info_for_role(request.query_role.as_str(), &request.account, &config)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return crate::materialize_token_2022_account_info_result(request, &result);
|
||||
}
|
||||
|
||||
/// Validates one complete RPC account response before Token-2022 parsing and routing.
|
||||
pub fn materialize_token_2022_account_info_result(
|
||||
request: &crate::Token2022StatefulReadRequest,
|
||||
result: &ks_onchain_transport::AccountInfoResult,
|
||||
) -> ks_core::Result<crate::Token2022StatefulReadResult> {
|
||||
if request.max_data_bytes == 0
|
||||
|| request.max_data_bytes > crate::MAX_TOKEN_2022_STATEFUL_ACCOUNT_BYTES
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 stateful read max_data_bytes must be between 1 and {}",
|
||||
crate::MAX_TOKEN_2022_STATEFUL_ACCOUNT_BYTES
|
||||
)));
|
||||
}
|
||||
if let std::option::Option::Some(min_context_slot) = request.min_context_slot {
|
||||
if result.context.slot < min_context_slot {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_stateful_context_slot_too_old",
|
||||
format!(
|
||||
"Token-2022 account context slot {} is below requested minimum {min_context_slot}",
|
||||
result.context.slot
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
let account = match result.account.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_stateful_account_missing",
|
||||
format!("Token-2022 account {} does not exist", request.account.0),
|
||||
));
|
||||
},
|
||||
};
|
||||
if account.executable {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_stateful_account_executable",
|
||||
format!("Token-2022 state account {} must not be executable", request.account.0),
|
||||
));
|
||||
}
|
||||
if account.owner.0 != ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_stateful_owner_mismatch",
|
||||
format!(
|
||||
"Token-2022 state account {} owner must be {}, got {}",
|
||||
request.account.0,
|
||||
ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID,
|
||||
account.owner.0
|
||||
),
|
||||
));
|
||||
}
|
||||
if account.space > request.max_data_bytes as u64 || account.data.len() > request.max_data_bytes
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_stateful_account_too_large",
|
||||
format!(
|
||||
"Token-2022 account {} reports {} bytes and returned {} bytes above limit {}",
|
||||
request.account.0,
|
||||
account.space,
|
||||
account.data.len(),
|
||||
request.max_data_bytes
|
||||
),
|
||||
));
|
||||
}
|
||||
if account.space != account.data.len() as u64 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_stateful_account_data_incomplete",
|
||||
format!(
|
||||
"Token-2022 account {} reports {} bytes but returned {} decoded bytes",
|
||||
request.account.0,
|
||||
account.space,
|
||||
account.data.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
let state = match ks_lib::decoder_spl_token_2022_parse_token_2022_state(
|
||||
request.kind,
|
||||
account.data.as_slice(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_stateful_parse_failed",
|
||||
error,
|
||||
));
|
||||
},
|
||||
};
|
||||
let snapshot = match crate::materialize_parsed_token_2022_stateful_snapshot_with_context(
|
||||
request.account.0.as_str(),
|
||||
result.context.slot,
|
||||
&state,
|
||||
&request.context,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_stateful_projection_failed",
|
||||
error,
|
||||
));
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(crate::Token2022StatefulReadResult {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot: result.context.slot,
|
||||
snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
/// One contextually validated Token-2022 state snapshot and its owned projections.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022StatefulSnapshotBundle {
|
||||
/// Canonical account identity used for contextual checks and stable output keys.
|
||||
pub account_key: std::string::String,
|
||||
/// Context slot associated with the account read.
|
||||
pub slot: u64,
|
||||
/// Parsed base-state category.
|
||||
pub state_kind: std::string::String,
|
||||
/// Ordered published or future extension names retained by the parser.
|
||||
pub extension_names: std::vec::Vec<std::string::String>,
|
||||
/// Processor-owned projections routed without duplicate ownership.
|
||||
pub outputs: std::vec::Vec<ks_lib::MtApiMaterializedOutput>,
|
||||
}
|
||||
|
||||
/// Parse, contextually validate, and materialize one bounded Token-2022 account snapshot.
|
||||
pub fn materialize_token_2022_stateful_snapshot(
|
||||
account_key: &str,
|
||||
owner_program_id: &str,
|
||||
slot: u64,
|
||||
kind: ks_lib::DcToken2022StateKind,
|
||||
data: &[u8],
|
||||
) -> std::result::Result<crate::Token2022StatefulSnapshotBundle, String> {
|
||||
if owner_program_id != ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID {
|
||||
return std::result::Result::Err(format!(
|
||||
"Token-2022 state snapshot owner must be {}, got {owner_program_id}",
|
||||
ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID
|
||||
));
|
||||
}
|
||||
let state = match ks_lib::decoder_spl_token_2022_parse_token_2022_state(kind, data) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return crate::materialize_parsed_token_2022_stateful_snapshot_with_context(
|
||||
account_key,
|
||||
slot,
|
||||
&state,
|
||||
&crate::Token2022StatefulContext::default(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Contextually validate and materialize one already parsed Token-2022 account snapshot.
|
||||
pub fn materialize_parsed_token_2022_stateful_snapshot(
|
||||
account_key: &str,
|
||||
slot: u64,
|
||||
state: &ks_lib::DcToken2022State,
|
||||
) -> std::result::Result<crate::Token2022StatefulSnapshotBundle, String> {
|
||||
return crate::materialize_parsed_token_2022_stateful_snapshot_with_context(
|
||||
account_key,
|
||||
slot,
|
||||
state,
|
||||
&crate::Token2022StatefulContext::default(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Contextually validate one parsed Token-2022 snapshot with external cross-account identities.
|
||||
pub fn materialize_parsed_token_2022_stateful_snapshot_with_context(
|
||||
account_key: &str,
|
||||
slot: u64,
|
||||
state: &ks_lib::DcToken2022State,
|
||||
context: &crate::Token2022StatefulContext,
|
||||
) -> std::result::Result<crate::Token2022StatefulSnapshotBundle, String> {
|
||||
if account_key.trim().is_empty() {
|
||||
return std::result::Result::Err(
|
||||
"Token-2022 stateful snapshot requires a non-empty account key".to_string(),
|
||||
);
|
||||
}
|
||||
let decoded = match bs58::decode(account_key).into_vec() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(
|
||||
"Token-2022 stateful snapshot account key must be valid base58".to_string(),
|
||||
);
|
||||
},
|
||||
};
|
||||
if decoded.len() != 32 {
|
||||
return std::result::Result::Err(format!(
|
||||
"Token-2022 stateful snapshot account key must decode to 32 bytes, got {}",
|
||||
decoded.len()
|
||||
));
|
||||
}
|
||||
match validate_embedded_mint_identity(account_key, state) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
match validate_group_member_identity(state, context) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let account_output = match ks_lib::materializer_token_materialize_token_2022_state_snapshot(
|
||||
account_key,
|
||||
slot,
|
||||
state,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut outputs = std::vec![account_output];
|
||||
if state.kind == ks_lib::DcToken2022StateKind::Mint {
|
||||
let metadata_outputs = match ks_lib::materializer_metadata_materialize_token_2022_snapshot(
|
||||
account_key,
|
||||
slot,
|
||||
state,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
outputs.extend(metadata_outputs);
|
||||
}
|
||||
let fee_outputs = match ks_lib::materializer_fees_materialize_token_2022_state_snapshots(
|
||||
account_key,
|
||||
slot,
|
||||
state,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
outputs.extend(fee_outputs);
|
||||
let admin_outputs = match ks_lib::materializer_admin_materialize_token_2022_state_snapshots(
|
||||
account_key,
|
||||
slot,
|
||||
state,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
outputs.extend(admin_outputs);
|
||||
let state_kind = match state.kind {
|
||||
ks_lib::DcToken2022StateKind::Mint => "mint",
|
||||
ks_lib::DcToken2022StateKind::Account => "account",
|
||||
ks_lib::DcToken2022StateKind::Multisig => "multisig",
|
||||
};
|
||||
return std::result::Result::Ok(crate::Token2022StatefulSnapshotBundle {
|
||||
account_key: account_key.to_string(),
|
||||
slot,
|
||||
state_kind: state_kind.to_string(),
|
||||
extension_names: state
|
||||
.extensions
|
||||
.iter()
|
||||
.map(|entry| return entry.extension_name.to_string())
|
||||
.collect(),
|
||||
outputs,
|
||||
});
|
||||
}
|
||||
|
||||
fn validate_embedded_mint_identity(
|
||||
account_key: &str,
|
||||
state: &ks_lib::DcToken2022State,
|
||||
) -> std::result::Result<(), String> {
|
||||
for entry in &state.extensions {
|
||||
if entry.extension_name != "token_metadata"
|
||||
&& entry.extension_name != "token_group"
|
||||
&& entry.extension_name != "token_group_member"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let embedded_mint = entry.value_fields.get("mint").and_then(serde_json::Value::as_str);
|
||||
let embedded_mint = match embedded_mint {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(format!(
|
||||
"Token-2022 {} extension requires a structured mint field",
|
||||
entry.extension_name
|
||||
));
|
||||
},
|
||||
};
|
||||
if embedded_mint != account_key {
|
||||
return std::result::Result::Err(format!(
|
||||
"Token-2022 {} mint {} does not match account {account_key}",
|
||||
entry.extension_name, embedded_mint
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_group_member_identity(
|
||||
state: &ks_lib::DcToken2022State,
|
||||
context: &crate::Token2022StatefulContext,
|
||||
) -> std::result::Result<(), String> {
|
||||
for entry in &state.extensions {
|
||||
if entry.extension_name != "token_group_member" {
|
||||
continue;
|
||||
}
|
||||
let group = entry.value_fields.get("group").and_then(serde_json::Value::as_str);
|
||||
let group = match group {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
"Token-2022 token_group_member extension requires a structured group field"
|
||||
.to_string(),
|
||||
);
|
||||
},
|
||||
};
|
||||
let expected = match context.expected_group_address.as_deref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
"Token-2022 token_group_member validation requires an expected group address"
|
||||
.to_string(),
|
||||
);
|
||||
},
|
||||
};
|
||||
if group != expected {
|
||||
return std::result::Result::Err(format!(
|
||||
"Token-2022 token_group_member group {group} does not match expected group {expected}"
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn account_key(byte: u8) -> String {
|
||||
return bs58::encode([byte; 32]).into_string();
|
||||
}
|
||||
|
||||
fn mint_state(account_key: &str) -> ks_lib::DcToken2022State {
|
||||
return ks_lib::DcToken2022State {
|
||||
kind: ks_lib::DcToken2022StateKind::Mint,
|
||||
base_fields: serde_json::json!({"supply":"1","decimals":0,"initialized":true}),
|
||||
base_hex: "00".repeat(82),
|
||||
account_type: std::option::Option::Some(1),
|
||||
extensions: std::vec![
|
||||
ks_lib::DcToken2022TlvEntry {
|
||||
extension_type: 19,
|
||||
extension_name: "token_metadata",
|
||||
value_hex: "01".to_string(),
|
||||
value_fields: serde_json::json!({
|
||||
"mint": account_key,
|
||||
"name": "Token",
|
||||
"symbol": "TOK",
|
||||
"uri": "https://example.invalid/token.json"
|
||||
}),
|
||||
},
|
||||
ks_lib::DcToken2022TlvEntry {
|
||||
extension_type: 20,
|
||||
extension_name: "token_group",
|
||||
value_hex: "02".to_string(),
|
||||
value_fields: serde_json::json!({
|
||||
"mint": account_key,
|
||||
"size": "1",
|
||||
"maxSize": "10"
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mint_snapshot_routes_account_and_metadata_outputs_once() {
|
||||
let account_key = account_key(7);
|
||||
let state = mint_state(account_key.as_str());
|
||||
let bundle = crate::materialize_parsed_token_2022_stateful_snapshot(
|
||||
account_key.as_str(),
|
||||
42,
|
||||
&state,
|
||||
);
|
||||
assert_eq!(
|
||||
bundle.as_ref().map(|value| return value.outputs.len()),
|
||||
std::result::Result::Ok(3)
|
||||
);
|
||||
assert_eq!(
|
||||
bundle.as_ref().map(|value| return value.state_kind.clone()),
|
||||
std::result::Result::Ok("mint".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
bundle.as_ref().map(|value| return value.extension_names.clone()),
|
||||
std::result::Result::Ok(std::vec![
|
||||
"token_metadata".to_string(),
|
||||
"token_group".to_string()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_metadata_mint_must_match_the_account_identity() {
|
||||
let account_address = account_key(8);
|
||||
let mut state = mint_state(account_address.as_str());
|
||||
state.extensions[0].value_fields["mint"] = serde_json::json!(account_key(9));
|
||||
let result = crate::materialize_parsed_token_2022_stateful_snapshot(
|
||||
account_address.as_str(),
|
||||
42,
|
||||
&state,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_and_account_identity_fail_closed_before_projection() {
|
||||
let account_key = account_key(10);
|
||||
let data = [0u8; 82];
|
||||
let wrong_owner = crate::materialize_token_2022_stateful_snapshot(
|
||||
account_key.as_str(),
|
||||
ks_program_ids::SPL_TOKEN_PROGRAM_ID,
|
||||
1,
|
||||
ks_lib::DcToken2022StateKind::Mint,
|
||||
&data,
|
||||
);
|
||||
assert!(wrong_owner.is_err());
|
||||
let state = mint_state(account_key.as_str());
|
||||
let malformed_key =
|
||||
crate::materialize_parsed_token_2022_stateful_snapshot("not-base58-0", 1, &state);
|
||||
assert!(malformed_key.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_snapshot_has_no_metadata_projection() {
|
||||
let account_key = account_key(11);
|
||||
let state = ks_lib::DcToken2022State {
|
||||
kind: ks_lib::DcToken2022StateKind::Account,
|
||||
base_fields: serde_json::json!({"amount":"0","state":"initialized"}),
|
||||
base_hex: "00".repeat(165),
|
||||
account_type: std::option::Option::None,
|
||||
extensions: std::vec::Vec::new(),
|
||||
};
|
||||
let bundle =
|
||||
crate::materialize_parsed_token_2022_stateful_snapshot(account_key.as_str(), 9, &state);
|
||||
assert_eq!(
|
||||
bundle.as_ref().map(|value| return value.outputs.len()),
|
||||
std::result::Result::Ok(1)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn group_member_requires_and_matches_external_group_identity() {
|
||||
let account_address = account_key(12);
|
||||
let group_address = account_key(13);
|
||||
let state = ks_lib::DcToken2022State {
|
||||
kind: ks_lib::DcToken2022StateKind::Mint,
|
||||
base_fields: serde_json::json!({"supply":"1","decimals":0,"initialized":true}),
|
||||
base_hex: "00".repeat(82),
|
||||
account_type: std::option::Option::Some(1),
|
||||
extensions: std::vec![ks_lib::DcToken2022TlvEntry {
|
||||
extension_type: 23,
|
||||
extension_name: "token_group_member",
|
||||
value_hex: "03".to_string(),
|
||||
value_fields: serde_json::json!({
|
||||
"mint": account_address,
|
||||
"group": group_address,
|
||||
"memberNumber": "1"
|
||||
}),
|
||||
}],
|
||||
};
|
||||
let missing = crate::materialize_parsed_token_2022_stateful_snapshot(
|
||||
account_address.as_str(),
|
||||
44,
|
||||
&state,
|
||||
);
|
||||
assert!(missing.is_err());
|
||||
let wrong_context = crate::Token2022StatefulContext {
|
||||
expected_group_address: std::option::Option::Some(account_key(14)),
|
||||
};
|
||||
let wrong = crate::materialize_parsed_token_2022_stateful_snapshot_with_context(
|
||||
account_address.as_str(),
|
||||
44,
|
||||
&state,
|
||||
&wrong_context,
|
||||
);
|
||||
assert!(wrong.is_err());
|
||||
let context = crate::Token2022StatefulContext {
|
||||
expected_group_address: std::option::Option::Some(group_address),
|
||||
};
|
||||
let valid = crate::materialize_parsed_token_2022_stateful_snapshot_with_context(
|
||||
account_address.as_str(),
|
||||
44,
|
||||
&state,
|
||||
&context,
|
||||
);
|
||||
assert_eq!(
|
||||
valid.as_ref().map(|bundle| return bundle.outputs.len()),
|
||||
std::result::Result::Ok(2)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn mint_and_account_fee_extensions_route_to_the_fee_owner_once() {
|
||||
let mint_address = account_key(15);
|
||||
let mint = ks_lib::DcToken2022State {
|
||||
kind: ks_lib::DcToken2022StateKind::Mint,
|
||||
base_fields: serde_json::json!({"supply":"1","decimals":0,"initialized":true}),
|
||||
base_hex: "00".repeat(82),
|
||||
account_type: std::option::Option::Some(1),
|
||||
extensions: std::vec![ks_lib::DcToken2022TlvEntry {
|
||||
extension_type: 1,
|
||||
extension_name: "transfer_fee_config",
|
||||
value_hex: "01".to_string(),
|
||||
value_fields: serde_json::json!({"withheldAmount":"7"}),
|
||||
}],
|
||||
};
|
||||
let mint_bundle = crate::materialize_parsed_token_2022_stateful_snapshot(
|
||||
mint_address.as_str(),
|
||||
50,
|
||||
&mint,
|
||||
);
|
||||
assert_eq!(
|
||||
mint_bundle.as_ref().map(|bundle| return bundle.outputs.len()),
|
||||
std::result::Result::Ok(2)
|
||||
);
|
||||
assert_eq!(
|
||||
mint_bundle
|
||||
.as_ref()
|
||||
.map(|bundle| return bundle.outputs[1].payload_json["provenance"]["processorName"]
|
||||
.clone()),
|
||||
std::result::Result::Ok(serde_json::json!("materializer.fees"))
|
||||
);
|
||||
let account_address = account_key(16);
|
||||
let account = ks_lib::DcToken2022State {
|
||||
kind: ks_lib::DcToken2022StateKind::Account,
|
||||
base_fields: serde_json::json!({"amount":"0","state":"initialized"}),
|
||||
base_hex: "00".repeat(165),
|
||||
account_type: std::option::Option::Some(2),
|
||||
extensions: std::vec![ks_lib::DcToken2022TlvEntry {
|
||||
extension_type: 17,
|
||||
extension_name: "confidential_transfer_fee_amount",
|
||||
value_hex: "02".to_string(),
|
||||
value_fields: serde_json::json!({"withheldAmount":"ciphertext"}),
|
||||
}],
|
||||
};
|
||||
let account_bundle = crate::materialize_parsed_token_2022_stateful_snapshot(
|
||||
account_address.as_str(),
|
||||
51,
|
||||
&account,
|
||||
);
|
||||
assert_eq!(
|
||||
account_bundle.as_ref().map(|bundle| return bundle.outputs.len()),
|
||||
std::result::Result::Ok(2)
|
||||
);
|
||||
assert_eq!(
|
||||
account_bundle
|
||||
.as_ref()
|
||||
.map(|bundle| return bundle.outputs[1].payload_json["confidentialValuesDecrypted"]
|
||||
.clone()),
|
||||
std::result::Result::Ok(serde_json::json!(false))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mint_admin_extensions_route_to_the_admin_owner_once() {
|
||||
let account_key = account_key(12);
|
||||
let mut state = mint_state(account_key.as_str());
|
||||
state.extensions.push(ks_lib::DcToken2022TlvEntry {
|
||||
extension_type: 6,
|
||||
extension_name: "default_account_state",
|
||||
value_hex: "02".to_string(),
|
||||
value_fields: serde_json::json!({"state": 2}),
|
||||
});
|
||||
let bundle = crate::materialize_parsed_token_2022_stateful_snapshot(
|
||||
account_key.as_str(),
|
||||
17,
|
||||
&state,
|
||||
);
|
||||
assert_eq!(
|
||||
bundle.as_ref().map(|value| return value.outputs.len()),
|
||||
std::result::Result::Ok(4)
|
||||
);
|
||||
assert_eq!(
|
||||
bundle.as_ref().map(|value| {
|
||||
return value
|
||||
.outputs
|
||||
.iter()
|
||||
.filter(|output| {
|
||||
return output.payload_json["domain"]
|
||||
== serde_json::json!("token_2022_extension_admin_state");
|
||||
})
|
||||
.count();
|
||||
}),
|
||||
std::result::Result::Ok(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_rpc_account_routes_only_after_owner_size_and_context_validation() {
|
||||
let account = account_key(21);
|
||||
let request = crate::Token2022StatefulReadRequest {
|
||||
query_role: "execution".to_string(),
|
||||
account: ks_lib::MdPubkey(account.clone()),
|
||||
kind: ks_lib::DcToken2022StateKind::Mint,
|
||||
min_context_slot: std::option::Option::Some(40),
|
||||
max_data_bytes: 82,
|
||||
context: crate::Token2022StatefulContext::default(),
|
||||
};
|
||||
let result = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 41,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::Some(ks_onchain_transport::AccountInfoValue {
|
||||
lamports: 1,
|
||||
owner: ks_lib::MdProgramId(ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID.to_string()),
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
space: 82,
|
||||
data: std::vec![0; 82],
|
||||
}),
|
||||
};
|
||||
let materialized = crate::materialize_token_2022_account_info_result(&request, &result);
|
||||
assert_eq!(
|
||||
materialized.as_ref().map(|value| return value.context_slot),
|
||||
std::result::Result::Ok(41)
|
||||
);
|
||||
assert_eq!(
|
||||
materialized.as_ref().map(|value| return value.snapshot.outputs.len()),
|
||||
std::result::Result::Ok(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_foreign_executable_and_stale_rpc_accounts_fail_closed() {
|
||||
let account = account_key(22);
|
||||
let request = crate::Token2022StatefulReadRequest {
|
||||
query_role: "execution".to_string(),
|
||||
account: ks_lib::MdPubkey(account),
|
||||
kind: ks_lib::DcToken2022StateKind::Mint,
|
||||
min_context_slot: std::option::Option::Some(50),
|
||||
max_data_bytes: 82,
|
||||
context: crate::Token2022StatefulContext::default(),
|
||||
};
|
||||
let base = ks_onchain_transport::AccountInfoValue {
|
||||
lamports: 1,
|
||||
owner: ks_lib::MdProgramId(ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID.to_string()),
|
||||
executable: false,
|
||||
rent_epoch: 0,
|
||||
space: 82,
|
||||
data: std::vec![0; 82],
|
||||
};
|
||||
let stale = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 49,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::Some(base.clone()),
|
||||
};
|
||||
assert!(crate::materialize_token_2022_account_info_result(&request, &stale).is_err());
|
||||
let mut foreign = base.clone();
|
||||
foreign.owner = ks_lib::MdProgramId(ks_program_ids::SPL_TOKEN_PROGRAM_ID.to_string());
|
||||
let foreign = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 50,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::Some(foreign),
|
||||
};
|
||||
assert!(crate::materialize_token_2022_account_info_result(&request, &foreign).is_err());
|
||||
let mut executable = base.clone();
|
||||
executable.executable = true;
|
||||
let executable = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 50,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::Some(executable),
|
||||
};
|
||||
assert!(crate::materialize_token_2022_account_info_result(&request, &executable).is_err());
|
||||
let mut incomplete = base;
|
||||
incomplete.data.pop();
|
||||
let incomplete = ks_onchain_transport::AccountInfoResult {
|
||||
context: ks_onchain_transport::RpcResponseContext {
|
||||
slot: 50,
|
||||
api_version: std::option::Option::None,
|
||||
},
|
||||
account: std::option::Option::Some(incomplete),
|
||||
};
|
||||
assert!(crate::materialize_token_2022_account_info_result(&request, &incomplete).is_err());
|
||||
}
|
||||
}
|
||||
1696
ks-pipeline/src/spl_token_stateful.rs
Normal file
1696
ks-pipeline/src/spl_token_stateful.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user