432 lines
18 KiB
Rust
432 lines
18 KiB
Rust
// file: kb-pipeline/src/solana_token_2022_preflight.rs
|
|
// version: 3
|
|
|
|
//! 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: kb_lib::MdPubkey,
|
|
/// Expected Token-2022 state category.
|
|
pub kind: kb_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<kb_lib::MdPubkey>,
|
|
/// Optional expected owner for a Token Account.
|
|
pub expected_owner: std::option::Option<kb_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: kb_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: &kb_onchain_transport::HttpEndpointPool,
|
|
request: &crate::Token2022PreflightRequest,
|
|
) -> kb_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(kb_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,
|
|
) -> kb_core::Result<std::vec::Vec<crate::Token2022PreflightRequirement>> {
|
|
if request.query_role.trim().is_empty() {
|
|
return std::result::Result::Err(kb_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(kb_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(kb_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(kb_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(kb_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(kb_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(kb_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(kb_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(kb_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,
|
|
) -> kb_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(kb_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(kb_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(kb_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(kb_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(kb_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) -> kb_lib::MdPubkey {
|
|
return kb_lib::MdPubkey(bs58::encode([byte; 32]).into_string());
|
|
}
|
|
|
|
fn requirement(account: kb_lib::MdPubkey) -> crate::Token2022PreflightRequirement {
|
|
return crate::Token2022PreflightRequirement {
|
|
role: "source".to_string(),
|
|
account,
|
|
kind: kb_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: kb_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![kb_lib::MtApiMaterializedOutput {
|
|
output_key: "state".to_string(),
|
|
family: kb_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)
|
|
);
|
|
}
|
|
}
|