v0.1.0-pre.033
This commit is contained in:
@@ -112,7 +112,9 @@
|
|||||||
- Portage des matérialisateurs natifs lifecycle, administration, compliance et staking.
|
- Portage des matérialisateurs natifs lifecycle, administration, compliance et staking.
|
||||||
- Conservation de leurs 50 tests historiques et ajout du premier test aval de matérialisation.
|
- Conservation de leurs 50 tests historiques et ajout du premier test aval de matérialisation.
|
||||||
|
|
||||||
## 0.1.0-pre.032
|
## 0.1.0-pre.033
|
||||||
|
|
||||||
- Migration de l’inventaire Devnet Token-2022/ElGamal et des contrats de validation machine-readable dans `kb-pipeline`.
|
- Migre les lectures stateful Token-2022 et ElGamal Registry dans `kb-pipeline`.
|
||||||
- Conservation des sept tests bot2 et de `docs/SPL_TOKEN_2022_VALIDATION_MATRIX.json`.
|
- Migre le préflight Token-2022 borné et ses validations croisées.
|
||||||
|
- Conserve les 16 tests bot2 associés sans réduction fonctionnelle.
|
||||||
|
- Normalise les noms de fichiers, modules et symboles internes sur `token2022`.
|
||||||
|
|||||||
@@ -104,4 +104,4 @@ La tranche `0.1.0-pre.029` migre la planification et l’extraction core de `kb-
|
|||||||
|
|
||||||
La tranche `0.1.0-pre.031` ajoute l’orchestration HTTP de backfill : signatures explicites ou historiques par adresse, pagination bornée, hydration via `getTransaction`, retries, annulation, reprise déterministe et persistance des observations.
|
La tranche `0.1.0-pre.031` ajoute l’orchestration HTTP de backfill : signatures explicites ou historiques par adresse, pagination bornée, hydration via `getTransaction`, retries, annulation, reprise déterministe et persistance des observations.
|
||||||
|
|
||||||
La migration `0.1.0-pre.032` ajoute à `kb-pipeline` l’inventaire stable des scénarios Devnet Token-2022/ElGamal et la validation machine-readable du jalon 0.4.6.
|
La migration de `kb-pipeline` couvre désormais le backfill, l'extraction core, le replay de décodage, la validation Token-2022 et les lectures stateful/préflight Token-2022 et ElGamal Registry.
|
||||||
|
|||||||
@@ -298,4 +298,10 @@
|
|||||||
- [ ] Valider avec Cargo sur la machine de développement.
|
- [ ] Valider avec Cargo sur la machine de développement.
|
||||||
- [ ] Porter ensuite les orchestrations Solana stateful et d’exécution.
|
- [ ] Porter ensuite les orchestrations Solana stateful et d’exécution.
|
||||||
|
|
||||||
- [x] `0.1.0-pre.032` — migrer les scénarios Devnet Token-2022/ElGamal et la matrice de validation machine-readable.
|
### 0.1.0-pre.033 — Pipeline stateful Token-2022
|
||||||
|
|
||||||
|
- [x] Lecture et validation stateful Token-2022.
|
||||||
|
- [x] Routage des projections matérialisées Token-2022.
|
||||||
|
- [x] Lecture et validation du registre ElGamal.
|
||||||
|
- [x] Préflight multi-comptes Token-2022 borné.
|
||||||
|
- [ ] Orchestration des preuves et de l'exécution Token-2022.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# file: kb-pipeline/Cargo.toml
|
# file: kb-pipeline/Cargo.toml
|
||||||
# version: 8
|
# version: 9
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "kb-pipeline"
|
name = "kb-pipeline"
|
||||||
@@ -10,6 +10,7 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
|
bs58.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
kb-core = { path = "../kb-core" }
|
kb-core = { path = "../kb-core" }
|
||||||
@@ -20,13 +21,12 @@ kb-store = { path = "../kb-store" }
|
|||||||
kb-wallet = { path = "../kb-wallet" }
|
kb-wallet = { path = "../kb-wallet" }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
solana-pubkey.workspace = true
|
||||||
|
spl-elgamal-registry-interface.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
bs58.workspace = true
|
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: kb-pipeline/src/lib.rs
|
// file: kb-pipeline/src/lib.rs
|
||||||
// version: 7
|
// version: 9
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![deny(unreachable_pub)]
|
#![deny(unreachable_pub)]
|
||||||
@@ -12,7 +12,10 @@ mod constants;
|
|||||||
mod core_extraction;
|
mod core_extraction;
|
||||||
mod decode_replay;
|
mod decode_replay;
|
||||||
mod plan;
|
mod plan;
|
||||||
|
mod solana_elgamal_registry_stateful;
|
||||||
mod solana_token2022_devnet_scenarios;
|
mod solana_token2022_devnet_scenarios;
|
||||||
|
mod solana_token2022_preflight;
|
||||||
|
mod solana_token2022_stateful;
|
||||||
mod solana_token2022_validation;
|
mod solana_token2022_validation;
|
||||||
|
|
||||||
/// Address category used by one targeted backfill campaign.
|
/// Address category used by one targeted backfill campaign.
|
||||||
@@ -83,6 +86,20 @@ pub use self::decode_replay::new_decode_campaign_id;
|
|||||||
pub use self::plan::PipelineStage;
|
pub use self::plan::PipelineStage;
|
||||||
/// Replay selection scope.
|
/// Replay selection scope.
|
||||||
pub use self::plan::ReplayScope;
|
pub use self::plan::ReplayScope;
|
||||||
|
/// Migrated ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES contract.
|
||||||
|
pub use self::solana_elgamal_registry_stateful::ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES;
|
||||||
|
/// Migrated ElGamalRegistryStatefulReadRequest contract.
|
||||||
|
pub use self::solana_elgamal_registry_stateful::ElGamalRegistryStatefulReadRequest;
|
||||||
|
/// Migrated ElGamalRegistryStatefulReadResult contract.
|
||||||
|
pub use self::solana_elgamal_registry_stateful::ElGamalRegistryStatefulReadResult;
|
||||||
|
/// Migrated ElGamalRegistryStatefulSnapshot contract.
|
||||||
|
pub use self::solana_elgamal_registry_stateful::ElGamalRegistryStatefulSnapshot;
|
||||||
|
/// Migrated materialize_elgamal_registry_account_info_result contract.
|
||||||
|
pub use self::solana_elgamal_registry_stateful::materialize_elgamal_registry_account_info_result;
|
||||||
|
/// Migrated materialize_elgamal_registry_stateful_snapshot contract.
|
||||||
|
pub use self::solana_elgamal_registry_stateful::materialize_elgamal_registry_stateful_snapshot;
|
||||||
|
/// Migrated read_elgamal_registry_stateful_snapshot contract.
|
||||||
|
pub use self::solana_elgamal_registry_stateful::read_elgamal_registry_stateful_snapshot;
|
||||||
/// Stable category of one independent Devnet validation scenario.
|
/// Stable category of one independent Devnet validation scenario.
|
||||||
pub use self::solana_token2022_devnet_scenarios::DevnetSplValidationFamily;
|
pub use self::solana_token2022_devnet_scenarios::DevnetSplValidationFamily;
|
||||||
/// Current implementation status of one Devnet validation scenario.
|
/// Current implementation status of one Devnet validation scenario.
|
||||||
@@ -91,6 +108,40 @@ pub use self::solana_token2022_devnet_scenarios::DevnetSplValidationImplementati
|
|||||||
pub use self::solana_token2022_devnet_scenarios::DevnetSplValidationScenario;
|
pub use self::solana_token2022_devnet_scenarios::DevnetSplValidationScenario;
|
||||||
/// Returns the complete ordered Devnet scenario inventory.
|
/// Returns the complete ordered Devnet scenario inventory.
|
||||||
pub use self::solana_token2022_devnet_scenarios::devnet_spl_validation_scenarios;
|
pub use self::solana_token2022_devnet_scenarios::devnet_spl_validation_scenarios;
|
||||||
|
/// Migrated MAX_TOKEN2022_PREFLIGHT_ACCOUNTS contract.
|
||||||
|
pub use self::solana_token2022_preflight::MAX_TOKEN2022_PREFLIGHT_ACCOUNTS;
|
||||||
|
/// Migrated MAX_TOKEN2022_PREFLIGHT_TOTAL_BYTES contract.
|
||||||
|
pub use self::solana_token2022_preflight::MAX_TOKEN2022_PREFLIGHT_TOTAL_BYTES;
|
||||||
|
/// Migrated Token2022PreflightAccountReport contract.
|
||||||
|
pub use self::solana_token2022_preflight::Token2022PreflightAccountReport;
|
||||||
|
/// Migrated Token2022PreflightReport contract.
|
||||||
|
pub use self::solana_token2022_preflight::Token2022PreflightReport;
|
||||||
|
/// Migrated Token2022PreflightRequest contract.
|
||||||
|
pub use self::solana_token2022_preflight::Token2022PreflightRequest;
|
||||||
|
/// Migrated Token2022PreflightRequirement contract.
|
||||||
|
pub use self::solana_token2022_preflight::Token2022PreflightRequirement;
|
||||||
|
/// Migrated inspect_token2022_preflight contract.
|
||||||
|
pub use self::solana_token2022_preflight::inspect_token2022_preflight;
|
||||||
|
/// Migrated MAX_TOKEN2022_STATEFUL_ACCOUNT_BYTES contract.
|
||||||
|
pub use self::solana_token2022_stateful::MAX_TOKEN2022_STATEFUL_ACCOUNT_BYTES;
|
||||||
|
/// Migrated Token2022StatefulContext contract.
|
||||||
|
pub use self::solana_token2022_stateful::Token2022StatefulContext;
|
||||||
|
/// Migrated Token2022StatefulReadRequest contract.
|
||||||
|
pub use self::solana_token2022_stateful::Token2022StatefulReadRequest;
|
||||||
|
/// Migrated Token2022StatefulReadResult contract.
|
||||||
|
pub use self::solana_token2022_stateful::Token2022StatefulReadResult;
|
||||||
|
/// Migrated Token2022StatefulSnapshotBundle contract.
|
||||||
|
pub use self::solana_token2022_stateful::Token2022StatefulSnapshotBundle;
|
||||||
|
/// Migrated materialize_parsed_token2022_stateful_snapshot contract.
|
||||||
|
pub use self::solana_token2022_stateful::materialize_parsed_token2022_stateful_snapshot;
|
||||||
|
/// Migrated materialize_parsed_token2022_stateful_snapshot_with_context contract.
|
||||||
|
pub use self::solana_token2022_stateful::materialize_parsed_token2022_stateful_snapshot_with_context;
|
||||||
|
/// Migrated materialize_token2022_account_info_result contract.
|
||||||
|
pub use self::solana_token2022_stateful::materialize_token2022_account_info_result;
|
||||||
|
/// Migrated materialize_token2022_stateful_snapshot contract.
|
||||||
|
pub use self::solana_token2022_stateful::materialize_token2022_stateful_snapshot;
|
||||||
|
/// Migrated read_token2022_stateful_snapshot contract.
|
||||||
|
pub use self::solana_token2022_stateful::read_token2022_stateful_snapshot;
|
||||||
/// Maximum number of evidence records accepted in one validation report.
|
/// Maximum number of evidence records accepted in one validation report.
|
||||||
pub use self::solana_token2022_validation::MAX_TOKEN2022_VALIDATION_EVIDENCE;
|
pub use self::solana_token2022_validation::MAX_TOKEN2022_VALIDATION_EVIDENCE;
|
||||||
/// Required validation environment for one Token-2022 scenario.
|
/// Required validation environment for one Token-2022 scenario.
|
||||||
|
|||||||
393
kb-pipeline/src/solana_elgamal_registry_stateful.rs
Normal file
393
kb-pipeline/src/solana_elgamal_registry_stateful.rs
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
// file: kb-pipeline/src/solana_elgamal_registry_stateful.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
//! 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: kb_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: &kb_onchain_transport::HttpEndpointPool,
|
||||||
|
request: &crate::ElGamalRegistryStatefulReadRequest,
|
||||||
|
) -> kb_core::Result<crate::ElGamalRegistryStatefulReadResult> {
|
||||||
|
if request.query_role.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::config(
|
||||||
|
"SPL ElGamal registry stateful read query_role must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let config = match kb_onchain_transport::GetAccountInfoConfig::new_with_data(
|
||||||
|
kb_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: &kb_onchain_transport::AccountInfoResult,
|
||||||
|
) -> kb_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(kb_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(kb_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(kb_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 != kb_program_ids::SPL_TOKEN2022_ELGAMAL_REGISTRY_PROGRAM_ID {
|
||||||
|
return std::result::Result::Err(kb_core::Error::new(
|
||||||
|
"elgamal_registry_stateful_owner_mismatch",
|
||||||
|
format!(
|
||||||
|
"SPL ElGamal registry owner must be {}, got {}",
|
||||||
|
kb_program_ids::SPL_TOKEN2022_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(kb_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(kb_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: kb_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 != kb_program_ids::SPL_TOKEN2022_ELGAMAL_REGISTRY_PROGRAM_ID {
|
||||||
|
return std::result::Result::Err(format!(
|
||||||
|
"SPL ElGamal registry snapshot owner must be {}, got {owner_program_id}",
|
||||||
|
kb_program_ids::SPL_TOKEN2022_ELGAMAL_REGISTRY_PROGRAM_ID
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let state = match kb_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(
|
||||||
|
kb_program_ids::SPL_TOKEN2022_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 kb_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(
|
||||||
|
kb_program_ids::SPL_TOKEN2022_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(),
|
||||||
|
kb_program_ids::SPL_TOKEN2022_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(),
|
||||||
|
kb_program_ids::SPL_TOKEN2022_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(),
|
||||||
|
kb_program_ids::SPL_TOKEN2022_ELGAMAL_REGISTRY_PROGRAM_ID,
|
||||||
|
55,
|
||||||
|
&data,
|
||||||
|
);
|
||||||
|
assert!(wrong.is_err());
|
||||||
|
let invalid = crate::materialize_elgamal_registry_stateful_snapshot(
|
||||||
|
registry.as_str(),
|
||||||
|
kb_program_ids::SPL_TOKEN2022_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: kb_lib::MdPubkey(registry),
|
||||||
|
min_context_slot: std::option::Option::Some(70),
|
||||||
|
};
|
||||||
|
let result = kb_onchain_transport::AccountInfoResult {
|
||||||
|
context: kb_onchain_transport::RpcResponseContext {
|
||||||
|
slot: 71,
|
||||||
|
api_version: std::option::Option::None,
|
||||||
|
},
|
||||||
|
account: std::option::Option::Some(kb_onchain_transport::AccountInfoValue {
|
||||||
|
lamports: 1,
|
||||||
|
owner: kb_lib::MdProgramId(
|
||||||
|
kb_program_ids::SPL_TOKEN2022_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: kb_lib::MdPubkey(registry),
|
||||||
|
min_context_slot: std::option::Option::Some(80),
|
||||||
|
};
|
||||||
|
let base = kb_onchain_transport::AccountInfoValue {
|
||||||
|
lamports: 1,
|
||||||
|
owner: kb_lib::MdProgramId(
|
||||||
|
kb_program_ids::SPL_TOKEN2022_ELGAMAL_REGISTRY_PROGRAM_ID.to_string(),
|
||||||
|
),
|
||||||
|
executable: false,
|
||||||
|
rent_epoch: 0,
|
||||||
|
space: 64,
|
||||||
|
data: data.to_vec(),
|
||||||
|
};
|
||||||
|
let stale = kb_onchain_transport::AccountInfoResult {
|
||||||
|
context: kb_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 = kb_onchain_transport::AccountInfoResult {
|
||||||
|
context: kb_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 = kb_lib::MdProgramId(kb_program_ids::SPL_TOKEN2022_PROGRAM_ID.to_string());
|
||||||
|
let foreign = kb_onchain_transport::AccountInfoResult {
|
||||||
|
context: kb_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 = kb_onchain_transport::AccountInfoResult {
|
||||||
|
context: kb_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 = kb_onchain_transport::AccountInfoResult {
|
||||||
|
context: kb_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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
429
kb-pipeline/src/solana_token2022_preflight.rs
Normal file
429
kb-pipeline/src/solana_token2022_preflight.rs
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
// file: kb-pipeline/src/solana_token2022_preflight.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! Bounded Token-2022 stateful preflight orchestration.
|
||||||
|
|
||||||
|
/// Maximum distinct Token-2022 accounts accepted by one preflight inspection.
|
||||||
|
pub const MAX_TOKEN2022_PREFLIGHT_ACCOUNTS: usize = 16;
|
||||||
|
/// Maximum aggregate account-data budget accepted by one preflight inspection.
|
||||||
|
pub const MAX_TOKEN2022_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_token2022_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_token2022_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_TOKEN2022_PREFLIGHT_ACCOUNTS {
|
||||||
|
return std::result::Result::Err(kb_core::Error::config(format!(
|
||||||
|
"Token-2022 preflight max_accounts must be between 1 and {}",
|
||||||
|
crate::MAX_TOKEN2022_PREFLIGHT_ACCOUNTS
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if request.max_total_data_bytes == 0
|
||||||
|
|| request.max_total_data_bytes > crate::MAX_TOKEN2022_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_TOKEN2022_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_TOKEN2022_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(
|
||||||
|
"token2022_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(
|
||||||
|
"token2022_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(
|
||||||
|
"token2022_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_token2022_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(
|
||||||
|
"token2022_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(
|
||||||
|
"token2022_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(
|
||||||
|
"token2022_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(
|
||||||
|
"token2022_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_token2022_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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
742
kb-pipeline/src/solana_token2022_stateful.rs
Normal file
742
kb-pipeline/src/solana_token2022_stateful.rs
Normal file
@@ -0,0 +1,742 @@
|
|||||||
|
// file: kb-pipeline/src/solana_token2022_stateful.rs
|
||||||
|
// version: 5
|
||||||
|
|
||||||
|
//! 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_TOKEN2022_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: kb_lib::MdPubkey,
|
||||||
|
/// Expected Token-2022 base-state category.
|
||||||
|
pub kind: kb_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_token2022_stateful_snapshot(
|
||||||
|
pool: &kb_onchain_transport::HttpEndpointPool,
|
||||||
|
request: &crate::Token2022StatefulReadRequest,
|
||||||
|
) -> kb_core::Result<crate::Token2022StatefulReadResult> {
|
||||||
|
if request.query_role.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_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_TOKEN2022_STATEFUL_ACCOUNT_BYTES
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(kb_core::Error::config(format!(
|
||||||
|
"Token-2022 stateful read max_data_bytes must be between 1 and {}",
|
||||||
|
crate::MAX_TOKEN2022_STATEFUL_ACCOUNT_BYTES
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let config = match kb_onchain_transport::GetAccountInfoConfig::new_with_data(
|
||||||
|
kb_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_token2022_account_info_result(request, &result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates one complete RPC account response before Token-2022 parsing and routing.
|
||||||
|
pub fn materialize_token2022_account_info_result(
|
||||||
|
request: &crate::Token2022StatefulReadRequest,
|
||||||
|
result: &kb_onchain_transport::AccountInfoResult,
|
||||||
|
) -> kb_core::Result<crate::Token2022StatefulReadResult> {
|
||||||
|
if request.max_data_bytes == 0
|
||||||
|
|| request.max_data_bytes > crate::MAX_TOKEN2022_STATEFUL_ACCOUNT_BYTES
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(kb_core::Error::config(format!(
|
||||||
|
"Token-2022 stateful read max_data_bytes must be between 1 and {}",
|
||||||
|
crate::MAX_TOKEN2022_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(kb_core::Error::new(
|
||||||
|
"token2022_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(kb_core::Error::new(
|
||||||
|
"token2022_stateful_account_missing",
|
||||||
|
format!("Token-2022 account {} does not exist", request.account.0),
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if account.executable {
|
||||||
|
return std::result::Result::Err(kb_core::Error::new(
|
||||||
|
"token2022_stateful_account_executable",
|
||||||
|
format!("Token-2022 state account {} must not be executable", request.account.0),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if account.owner.0 != kb_program_ids::SPL_TOKEN2022_PROGRAM_ID {
|
||||||
|
return std::result::Result::Err(kb_core::Error::new(
|
||||||
|
"token2022_stateful_owner_mismatch",
|
||||||
|
format!(
|
||||||
|
"Token-2022 state account {} owner must be {}, got {}",
|
||||||
|
request.account.0,
|
||||||
|
kb_program_ids::SPL_TOKEN2022_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(kb_core::Error::new(
|
||||||
|
"token2022_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(kb_core::Error::new(
|
||||||
|
"token2022_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 kb_lib::decoder_spl_token2022_parse_token2022_state(
|
||||||
|
request.kind,
|
||||||
|
account.data.as_slice(),
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::new(
|
||||||
|
"token2022_stateful_parse_failed",
|
||||||
|
error,
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let snapshot = match crate::materialize_parsed_token2022_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(kb_core::Error::new(
|
||||||
|
"token2022_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<kb_lib::MtApiMaterializedOutput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse, contextually validate, and materialize one bounded Token-2022 account snapshot.
|
||||||
|
pub fn materialize_token2022_stateful_snapshot(
|
||||||
|
account_key: &str,
|
||||||
|
owner_program_id: &str,
|
||||||
|
slot: u64,
|
||||||
|
kind: kb_lib::DcToken2022StateKind,
|
||||||
|
data: &[u8],
|
||||||
|
) -> std::result::Result<crate::Token2022StatefulSnapshotBundle, String> {
|
||||||
|
if owner_program_id != kb_program_ids::SPL_TOKEN2022_PROGRAM_ID {
|
||||||
|
return std::result::Result::Err(format!(
|
||||||
|
"Token-2022 state snapshot owner must be {}, got {owner_program_id}",
|
||||||
|
kb_program_ids::SPL_TOKEN2022_PROGRAM_ID
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let state = match kb_lib::decoder_spl_token2022_parse_token2022_state(kind, data) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return crate::materialize_parsed_token2022_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_token2022_stateful_snapshot(
|
||||||
|
account_key: &str,
|
||||||
|
slot: u64,
|
||||||
|
state: &kb_lib::DcToken2022State,
|
||||||
|
) -> std::result::Result<crate::Token2022StatefulSnapshotBundle, String> {
|
||||||
|
return crate::materialize_parsed_token2022_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_token2022_stateful_snapshot_with_context(
|
||||||
|
account_key: &str,
|
||||||
|
slot: u64,
|
||||||
|
state: &kb_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 kb_lib::materializer_token_materialize_token2022_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 == kb_lib::DcToken2022StateKind::Mint {
|
||||||
|
let metadata_outputs = match kb_lib::materializer_metadata_materialize_token2022_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 kb_lib::materializer_fees_materialize_token2022_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 kb_lib::materializer_admin_materialize_token2022_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 {
|
||||||
|
kb_lib::DcToken2022StateKind::Mint => "mint",
|
||||||
|
kb_lib::DcToken2022StateKind::Account => "account",
|
||||||
|
kb_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: &kb_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: &kb_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) -> kb_lib::DcToken2022State {
|
||||||
|
return kb_lib::DcToken2022State {
|
||||||
|
kind: kb_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![
|
||||||
|
kb_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"
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
kb_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_token2022_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_token2022_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_token2022_stateful_snapshot(
|
||||||
|
account_key.as_str(),
|
||||||
|
kb_program_ids::SPL_TOKEN_PROGRAM_ID,
|
||||||
|
1,
|
||||||
|
kb_lib::DcToken2022StateKind::Mint,
|
||||||
|
&data,
|
||||||
|
);
|
||||||
|
assert!(wrong_owner.is_err());
|
||||||
|
let state = mint_state(account_key.as_str());
|
||||||
|
let malformed_key =
|
||||||
|
crate::materialize_parsed_token2022_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 = kb_lib::DcToken2022State {
|
||||||
|
kind: kb_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_token2022_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 = kb_lib::DcToken2022State {
|
||||||
|
kind: kb_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![kb_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_token2022_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_token2022_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_token2022_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 = kb_lib::DcToken2022State {
|
||||||
|
kind: kb_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![kb_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_token2022_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!("fees"))
|
||||||
|
);
|
||||||
|
let account_address = account_key(16);
|
||||||
|
let account = kb_lib::DcToken2022State {
|
||||||
|
kind: kb_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![kb_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_token2022_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(kb_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_token2022_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!("token2022_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: kb_lib::MdPubkey(account.clone()),
|
||||||
|
kind: kb_lib::DcToken2022StateKind::Mint,
|
||||||
|
min_context_slot: std::option::Option::Some(40),
|
||||||
|
max_data_bytes: 82,
|
||||||
|
context: crate::Token2022StatefulContext::default(),
|
||||||
|
};
|
||||||
|
let result = kb_onchain_transport::AccountInfoResult {
|
||||||
|
context: kb_onchain_transport::RpcResponseContext {
|
||||||
|
slot: 41,
|
||||||
|
api_version: std::option::Option::None,
|
||||||
|
},
|
||||||
|
account: std::option::Option::Some(kb_onchain_transport::AccountInfoValue {
|
||||||
|
lamports: 1,
|
||||||
|
owner: kb_lib::MdProgramId(kb_program_ids::SPL_TOKEN2022_PROGRAM_ID.to_string()),
|
||||||
|
executable: false,
|
||||||
|
rent_epoch: 0,
|
||||||
|
space: 82,
|
||||||
|
data: std::vec![0; 82],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let materialized = crate::materialize_token2022_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: kb_lib::MdPubkey(account),
|
||||||
|
kind: kb_lib::DcToken2022StateKind::Mint,
|
||||||
|
min_context_slot: std::option::Option::Some(50),
|
||||||
|
max_data_bytes: 82,
|
||||||
|
context: crate::Token2022StatefulContext::default(),
|
||||||
|
};
|
||||||
|
let base = kb_onchain_transport::AccountInfoValue {
|
||||||
|
lamports: 1,
|
||||||
|
owner: kb_lib::MdProgramId(kb_program_ids::SPL_TOKEN2022_PROGRAM_ID.to_string()),
|
||||||
|
executable: false,
|
||||||
|
rent_epoch: 0,
|
||||||
|
space: 82,
|
||||||
|
data: std::vec![0; 82],
|
||||||
|
};
|
||||||
|
let stale = kb_onchain_transport::AccountInfoResult {
|
||||||
|
context: kb_onchain_transport::RpcResponseContext {
|
||||||
|
slot: 49,
|
||||||
|
api_version: std::option::Option::None,
|
||||||
|
},
|
||||||
|
account: std::option::Option::Some(base.clone()),
|
||||||
|
};
|
||||||
|
assert!(crate::materialize_token2022_account_info_result(&request, &stale).is_err());
|
||||||
|
let mut foreign = base.clone();
|
||||||
|
foreign.owner = kb_lib::MdProgramId(kb_program_ids::SPL_TOKEN_PROGRAM_ID.to_string());
|
||||||
|
let foreign = kb_onchain_transport::AccountInfoResult {
|
||||||
|
context: kb_onchain_transport::RpcResponseContext {
|
||||||
|
slot: 50,
|
||||||
|
api_version: std::option::Option::None,
|
||||||
|
},
|
||||||
|
account: std::option::Option::Some(foreign),
|
||||||
|
};
|
||||||
|
assert!(crate::materialize_token2022_account_info_result(&request, &foreign).is_err());
|
||||||
|
let mut executable = base.clone();
|
||||||
|
executable.executable = true;
|
||||||
|
let executable = kb_onchain_transport::AccountInfoResult {
|
||||||
|
context: kb_onchain_transport::RpcResponseContext {
|
||||||
|
slot: 50,
|
||||||
|
api_version: std::option::Option::None,
|
||||||
|
},
|
||||||
|
account: std::option::Option::Some(executable),
|
||||||
|
};
|
||||||
|
assert!(crate::materialize_token2022_account_info_result(&request, &executable).is_err());
|
||||||
|
let mut incomplete = base;
|
||||||
|
incomplete.data.pop();
|
||||||
|
let incomplete = kb_onchain_transport::AccountInfoResult {
|
||||||
|
context: kb_onchain_transport::RpcResponseContext {
|
||||||
|
slot: 50,
|
||||||
|
api_version: std::option::Option::None,
|
||||||
|
},
|
||||||
|
account: std::option::Option::Some(incomplete),
|
||||||
|
};
|
||||||
|
assert!(crate::materialize_token2022_account_info_result(&request, &incomplete).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user