This commit is contained in:
2026-07-23 16:37:12 +02:00
parent 99c345f2f2
commit 0da75c1311
2159 changed files with 230833 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
# file: kb_materializer_staking/Cargo.toml
# version: 3
[package]
name = "kb_materializer_staking"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
kb_core = { path = "../kb_core" }
kb_decoder_api = { path = "../kb_decoder_api" }
kb_materializer_api = { path = "../kb_materializer_api" }
kb_model = { path = "../kb_model" }
serde_json.workspace = true
tracing.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,56 @@
<!-- file: kb_materializer_staking/README.md -->
<!-- version: 3 -->
# kb_materializer_staking
Ce crate produit des projections instructionnelles stables pour les observations natives Stake et Vote décodées par `kb_decoder_solana_core`.
## Statut
`0.4.1-pre.021` active le matérialiseur sous l'identité :
```text
solana_native_staking
```
Le matérialiseur est enregistré par `kb_app_demo::demo_decode_replay` avec les autres matérialiseurs natifs. Il applique `SuccessfulCommittedOnly` : une transaction échouée ou une observation non commitée reste décodable, mais ne produit aucune sortie métier mutable.
## Surfaces couvertes
### Stake Program
Les projections sont instructionnelles et ne prétendent pas reconstruire l'état final du compte Stake.
| Domaine | Entrées |
|-------------------|-----------------------------------------------------------------------------------|
| `stake_account` | initialize, initialize_checked, delegate_stake, deactivate, deactivate_delinquent |
| `stake_value` | split, withdraw, merge, move_stake, move_lamports |
| `stake_authority` | authorize, authorize_with_seed, authorize_checked, authorize_checked_with_seed |
| `stake_lockup` | set_lockup, set_lockup_checked |
`redelegate` reste ignoré : l'instruction est conservée par le décodeur comme intention historique officiellement désactivée, mais elle ne doit pas produire une mutation staking réussie.
### Vote Program
| Domaine | Entrées |
|------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `vote_account` | initialize_account, initialize_account_v2 |
| `vote_authority` | authorize, authorize_checked, authorize_with_seed, authorize_checked_with_seed |
| `vote_admin` | update_validator_identity, update_commission, update_commission_collector, update_commission_bps |
| `vote_state` | vote, vote_switch, update_vote_state, update_vote_state_switch, compact_update_vote_state, compact_update_vote_state_switch, tower_sync, tower_sync_switch |
| `vote_value` | withdraw |
| `vote_reward` | deposit_delegator_rewards |
`deposit_delegator_rewards` produit une sortie de famille `Reward`; les autres sorties produisent une famille matérialisée `Staking`.
## Limites explicites
Le crate ne reconstruit pas :
- activation/désactivation effective d'un stake par epoch ;
- crédits Vote cumulés ;
- statut final exact d'un compte après withdraw ;
- état antérieur et état suivant du compte ;
- sysvars historiques non portées par le core replay.
Ces projections doivent donc être lues comme des événements instructionnels commités. Les snapshots d'état final seront un futur contrat distinct lorsque le replay core transportera les données de comptes et sysvars nécessaires.

View File

@@ -0,0 +1,7 @@
// file: kb_materializer_staking/src/constants.rs
// version: 1
//! Local constants for the `kb_materializer_staking` crate.
/// Canonical tracing target for this crate.
pub(crate) const TRACING_TARGET: &str = "kb_materializer_staking";

View File

@@ -0,0 +1,16 @@
// file: kb_materializer_staking/src/lib.rs
// version: 4
//! Materializer crate for instruction-level native Stake and Vote projections.
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
mod constants;
mod materializer;
/// Canonical tracing target for this crate.
pub(crate) use crate::constants::TRACING_TARGET;
/// Exposes the materializer type implemented by this crate.
pub use crate::materializer::StakingMaterializer;

View File

@@ -0,0 +1,886 @@
// file: kb_materializer_staking/src/materializer.rs
// version: 7
//! Instruction-level staking projections for native Stake and Vote observations.
const ACCEPTED_FAMILIES: &[kb_model::EventFamily] = &[
kb_model::EventFamily::Lifecycle,
kb_model::EventFamily::Admin,
kb_model::EventFamily::Staking,
kb_model::EventFamily::Reward,
];
const STAKE_SURFACE: &str = "solana_native_stake";
const VOTE_SURFACE: &str = "solana_native_vote";
const STAKE_ACCOUNT_ENTRIES: &[&str] = &[
"initialize",
"initialize_checked",
"delegate_stake",
"deactivate",
"deactivate_delinquent",
];
const STAKE_VALUE_ENTRIES: &[&str] = &["split", "withdraw", "merge", "move_stake", "move_lamports"];
const STAKE_AUTHORITY_ENTRIES: &[&str] = &[
"authorize",
"authorize_with_seed",
"authorize_checked",
"authorize_checked_with_seed",
];
const STAKE_LOCKUP_ENTRIES: &[&str] = &["set_lockup", "set_lockup_checked"];
const VOTE_ACCOUNT_ENTRIES: &[&str] = &["initialize_account", "initialize_account_v2"];
const VOTE_AUTHORITY_ENTRIES: &[&str] = &[
"authorize",
"authorize_checked",
"authorize_with_seed",
"authorize_checked_with_seed",
];
const VOTE_ADMIN_ENTRIES: &[&str] = &[
"update_validator_identity",
"update_commission",
"update_commission_collector",
"update_commission_bps",
];
const VOTE_STATE_ENTRIES: &[&str] = &[
"vote",
"vote_switch",
"update_vote_state",
"update_vote_state_switch",
"compact_update_vote_state",
"compact_update_vote_state_switch",
"tower_sync",
"tower_sync_switch",
];
const VOTE_VALUE_ENTRIES: &[&str] = &["withdraw"];
const VOTE_REWARD_ENTRIES: &[&str] = &["deposit_delegator_rewards"];
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ProjectionKind {
StakeAccount,
StakeValue,
StakeAuthority,
StakeLockup,
VoteAccount,
VoteAuthority,
VoteAdmin,
VoteState,
VoteValue,
VoteReward,
}
impl ProjectionKind {
fn domain(self) -> &'static str {
return match self {
Self::StakeAccount => "stake_account",
Self::StakeValue => "stake_value",
Self::StakeAuthority => "stake_authority",
Self::StakeLockup => "stake_lockup",
Self::VoteAccount => "vote_account",
Self::VoteAuthority => "vote_authority",
Self::VoteAdmin => "vote_admin",
Self::VoteState => "vote_state",
Self::VoteValue => "vote_value",
Self::VoteReward => "vote_reward",
};
}
fn family(self) -> kb_model::MaterializedEventFamily {
return match self {
Self::VoteReward => kb_model::MaterializedEventFamily::Reward,
_ => kb_model::MaterializedEventFamily::Staking,
};
}
fn output_key(self, operation: &str) -> std::string::String {
return format!("{}:{operation}:0", self.domain());
}
}
/// Stable staking materializer for committed native Stake and Vote observations.
#[derive(Clone, Debug, Default)]
pub struct StakingMaterializer;
impl kb_materializer_api::Materializer for crate::StakingMaterializer {
fn materializer_name(&self) -> &'static str {
return "kb_materializer_staking";
}
fn materializer_version(&self) -> &'static str {
return env!("CARGO_PKG_VERSION");
}
fn accepts_event(&self, event: &kb_model::DecodedProtocolEvent) -> bool {
return family_is_accepted(event.event_family)
&& projection(event.surface_code.0.as_str(), event.event_name.0.as_str()).is_some();
}
fn materialize_event(
&self,
event: &kb_model::DecodedProtocolEvent,
) -> kb_core::Result<std::vec::Vec<kb_model::MaterializedEvent>> {
if !kb_materializer_api::Materializer::accepts_event(self, event) {
return std::result::Result::Ok(std::vec::Vec::new());
}
return std::result::Result::Ok(std::vec::Vec::new());
}
}
impl kb_materializer_api::EventMaterializer for crate::StakingMaterializer {
fn identity(&self) -> kb_materializer_api::MaterializerIdentity {
return kb_materializer_api::MaterializerIdentity {
name: "solana_native_staking".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
};
}
fn accepted_families(&self) -> &'static [kb_model::EventFamily] {
return ACCEPTED_FAMILIES;
}
fn accepts_observation(&self, observation: &kb_decoder_api::DecodedObservation) -> bool {
return family_is_accepted(observation.event.event_family)
&& projection(
observation.event.surface_code.0.as_str(),
observation.event.event_name.0.as_str(),
)
.is_some();
}
fn transaction_policy(
&self,
_family: kb_model::EventFamily,
) -> kb_materializer_api::MaterializationTransactionPolicy {
return kb_materializer_api::MaterializationTransactionPolicy::SuccessfulCommittedOnly;
}
fn materialize(
&self,
observation: &kb_decoder_api::DecodedObservation,
) -> kb_materializer_api::MaterializerExecutionResult {
let projection = match projection(
observation.event.surface_code.0.as_str(),
observation.event.event_name.0.as_str(),
) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
tracing::debug!(
target: crate::TRACING_TARGET,
action = "materialize_staking",
surface_code = %observation.event.surface_code.0.as_str(),
entry_code = %observation.event.event_name.0.as_str(),
accepted = false,
"ignore observation outside supported native staking projections"
);
return kb_materializer_api::MaterializerExecutionResult::ignored();
},
};
if !family_is_accepted(observation.event.event_family) {
return kb_materializer_api::MaterializerExecutionResult::ignored();
}
if observation.transaction_failed || !observation.observation_committed {
tracing::debug!(
target: crate::TRACING_TARGET,
action = "materialize_staking",
surface_code = %observation.event.surface_code.0.as_str(),
entry_code = %observation.event.event_name.0.as_str(),
committed = observation.observation_committed,
transaction_failed = observation.transaction_failed,
"refuse uncommitted native staking observation"
);
return kb_materializer_api::MaterializerExecutionResult::refused(
"failed_transaction_staking_refused",
"failed or uncommitted native Stake/Vote observations cannot create staking outputs",
);
}
let accounts = observation
.payload_json
.get("accounts")
.cloned()
.unwrap_or(serde_json::Value::Null);
let parameters = observation
.payload_json
.get("parameters")
.cloned()
.unwrap_or(serde_json::Value::Null);
let operation = observation.event.event_name.0.clone();
let output = kb_materializer_api::MaterializedOutput {
output_key: projection.output_key(operation.as_str()),
family: projection.family(),
payload_json: serde_json::json!({
"projectionVersion": 1,
"projectionSemantics": "committed_instruction_staking_event",
"domain": projection.domain(),
"operation": operation,
"programId": observation.event.program_id.0.clone(),
"signature": observation.event.signature.0.clone(),
"slot": observation.event.slot.0,
"instructionPath": observation.event.instruction_path.0.clone(),
"transactionSucceeded": true,
"accounts": accounts,
"parameters": parameters,
"projection": projection_details(
projection,
&accounts,
&parameters,
observation.event.event_name.0.as_str(),
),
}),
};
tracing::debug!(
target: crate::TRACING_TARGET,
action = "materialize_staking",
surface_code = %observation.event.surface_code.0.as_str(),
entry_code = %observation.event.event_name.0.as_str(),
projection_domain = projection.domain(),
output_count = 1_usize,
"materialize committed native staking observation"
);
return kb_materializer_api::MaterializerExecutionResult {
status: kb_materializer_api::MaterializerOutcomeStatus::Inserted,
outputs: std::vec![output],
diagnostics: std::vec::Vec::new(),
};
}
}
fn family_is_accepted(family: kb_model::EventFamily) -> bool {
return ACCEPTED_FAMILIES.iter().any(|accepted| return *accepted == family);
}
fn projection(surface_code: &str, entry_code: &str) -> std::option::Option<ProjectionKind> {
if surface_code == STAKE_SURFACE {
return stake_projection(entry_code);
}
if surface_code == VOTE_SURFACE {
return vote_projection(entry_code);
}
return std::option::Option::None;
}
fn stake_projection(entry_code: &str) -> std::option::Option<ProjectionKind> {
if contains(STAKE_ACCOUNT_ENTRIES, entry_code) {
return std::option::Option::Some(ProjectionKind::StakeAccount);
}
if contains(STAKE_VALUE_ENTRIES, entry_code) {
return std::option::Option::Some(ProjectionKind::StakeValue);
}
if contains(STAKE_AUTHORITY_ENTRIES, entry_code) {
return std::option::Option::Some(ProjectionKind::StakeAuthority);
}
if contains(STAKE_LOCKUP_ENTRIES, entry_code) {
return std::option::Option::Some(ProjectionKind::StakeLockup);
}
return std::option::Option::None;
}
fn vote_projection(entry_code: &str) -> std::option::Option<ProjectionKind> {
if contains(VOTE_ACCOUNT_ENTRIES, entry_code) {
return std::option::Option::Some(ProjectionKind::VoteAccount);
}
if contains(VOTE_AUTHORITY_ENTRIES, entry_code) {
return std::option::Option::Some(ProjectionKind::VoteAuthority);
}
if contains(VOTE_ADMIN_ENTRIES, entry_code) {
return std::option::Option::Some(ProjectionKind::VoteAdmin);
}
if contains(VOTE_STATE_ENTRIES, entry_code) {
return std::option::Option::Some(ProjectionKind::VoteState);
}
if contains(VOTE_VALUE_ENTRIES, entry_code) {
return std::option::Option::Some(ProjectionKind::VoteValue);
}
if contains(VOTE_REWARD_ENTRIES, entry_code) {
return std::option::Option::Some(ProjectionKind::VoteReward);
}
return std::option::Option::None;
}
fn projection_details(
projection: ProjectionKind,
accounts: &serde_json::Value,
parameters: &serde_json::Value,
operation: &str,
) -> serde_json::Value {
return match projection {
ProjectionKind::StakeAccount => stake_account_details(accounts, parameters, operation),
ProjectionKind::StakeValue => stake_value_details(accounts, parameters, operation),
ProjectionKind::StakeAuthority => stake_authority_details(accounts, parameters, operation),
ProjectionKind::StakeLockup => stake_lockup_details(accounts, parameters, operation),
ProjectionKind::VoteAccount => vote_account_details(accounts, parameters, operation),
ProjectionKind::VoteAuthority => vote_authority_details(accounts, parameters, operation),
ProjectionKind::VoteAdmin => vote_admin_details(accounts, parameters, operation),
ProjectionKind::VoteState => vote_state_details(accounts, parameters, operation),
ProjectionKind::VoteValue => vote_value_details(accounts, parameters, operation),
ProjectionKind::VoteReward => vote_reward_details(accounts, parameters, operation),
};
}
fn stake_account_details(
accounts: &serde_json::Value,
parameters: &serde_json::Value,
operation: &str,
) -> serde_json::Value {
return serde_json::json!({
"stakeAccount": account_key_for_role(accounts, "stake_account"),
"voteAccount": account_key_for_role(accounts, "vote_account"),
"authorized": parameters.get("authorized").cloned().unwrap_or(serde_json::Value::Null),
"lockup": parameters.get("lockup").cloned().unwrap_or(serde_json::Value::Null),
"activationStateComputed": false,
"transactionFinalAccountStateCaptured": false,
"stateTransition": match operation {
"initialize" | "initialize_checked" => "stake_account_initialized",
"delegate_stake" => "stake_delegation_requested",
"deactivate" => "stake_deactivation_requested",
"deactivate_delinquent" => "stake_deactivation_requested_for_delinquent_vote_account",
_ => "stake_account_state_transition_requested",
},
});
}
fn stake_value_details(
accounts: &serde_json::Value,
parameters: &serde_json::Value,
operation: &str,
) -> serde_json::Value {
return serde_json::json!({
"stakeAccount": account_key_for_role(accounts, "stake_account"),
"splitAccount": account_key_for_role(accounts, "split_account"),
"destinationAccount": first_account_key_for_roles(accounts, &["destination", "recipient_account", "destination_stake_account"]),
"sourceStakeAccount": account_key_for_role(accounts, "source_stake_account"),
"lamports": parameters.get("lamports").cloned().unwrap_or(serde_json::Value::Null),
"lamportBalanceChangesDuplicated": false,
"activationStateComputed": false,
"transactionFinalAccountStateCaptured": false,
"stateTransition": match operation {
"split" => "stake_split_requested",
"withdraw" => "stake_lamports_withdrawn_or_account_closed_conditionally",
"merge" => "stake_accounts_merged",
"move_stake" => "active_stake_moved",
"move_lamports" => "inactive_lamports_moved",
_ => "stake_value_transition_requested",
},
});
}
fn stake_authority_details(
accounts: &serde_json::Value,
parameters: &serde_json::Value,
operation: &str,
) -> serde_json::Value {
return serde_json::json!({
"stakeAccount": account_key_for_role(accounts, "stake_account"),
"currentAuthority": first_account_key_for_roles(accounts, &["stake_or_withdraw_authority", "current_authority", "stake_authority", "withdraw_authority", "authority_base"]),
"newAuthority": first_parameter_or_account(
parameters,
&["newAuthority", "newAuthorizedPubkey"],
accounts,
"new_authority",
),
"authorizationType": parameters.get("authorizationType").cloned().unwrap_or(serde_json::Value::Null),
"authoritySeed": parameters.get("authoritySeed").cloned().unwrap_or(serde_json::Value::Null),
"authorityOwner": parameters.get("authorityOwner").cloned().unwrap_or(serde_json::Value::Null),
"stateTransition": match operation {
"authorize_with_seed" | "authorize_checked_with_seed" => "stake_authority_changed_with_seed",
"authorize_checked" => "stake_authority_changed_with_new_authority_signature",
_ => "stake_authority_changed",
},
"transactionFinalAccountStateCaptured": false,
});
}
fn stake_lockup_details(
accounts: &serde_json::Value,
parameters: &serde_json::Value,
operation: &str,
) -> serde_json::Value {
return serde_json::json!({
"stakeAccount": account_key_for_role(accounts, "stake_account"),
"custodian": first_account_key_for_roles(accounts, &["lockup_custodian", "lockup_or_withdraw_authority", "new_lockup_authority"]),
"lockup": parameters.clone(),
"checkedCustodianFromAccount": operation == "set_lockup_checked",
"stateTransition": "stake_lockup_updated",
"transactionFinalAccountStateCaptured": false,
});
}
fn vote_account_details(
accounts: &serde_json::Value,
parameters: &serde_json::Value,
operation: &str,
) -> serde_json::Value {
return serde_json::json!({
"voteAccount": account_key_for_role(accounts, "vote_account"),
"validatorIdentity": account_key_for_role(accounts, "validator_identity"),
"authorizedVoter": parameters.get("authorizedVoter").cloned().unwrap_or(serde_json::Value::Null),
"authorizedWithdrawer": parameters.get("authorizedWithdrawer").cloned().unwrap_or(serde_json::Value::Null),
"commissionPercent": parameters.get("commissionPercent").cloned().unwrap_or(serde_json::Value::Null),
"inflationRewardsCommissionBasisPoints": parameters.get("inflationRewardsCommissionBasisPoints").cloned().unwrap_or(serde_json::Value::Null),
"blockRevenueCommissionBasisPoints": parameters.get("blockRevenueCommissionBasisPoints").cloned().unwrap_or(serde_json::Value::Null),
"inflationRewardsCollector": account_key_for_role(accounts, "inflation_rewards_collector"),
"blockRevenueCollector": account_key_for_role(accounts, "block_revenue_collector"),
"stateTransition": if operation == "initialize_account_v2" {
"vote_account_initialized_v2"
} else {
"vote_account_initialized"
},
"transactionFinalAccountStateCaptured": false,
});
}
fn vote_authority_details(
accounts: &serde_json::Value,
parameters: &serde_json::Value,
operation: &str,
) -> serde_json::Value {
return serde_json::json!({
"voteAccount": account_key_for_role(accounts, "vote_account"),
"currentAuthority": first_account_key_for_roles(accounts, &["current_authority", "current_authority_base"]),
"newAuthority": first_parameter_or_account(
parameters,
&["newAuthority"],
accounts,
"new_authority",
),
"authorizationType": parameters.get("authorizationType").cloned().unwrap_or(serde_json::Value::Null),
"currentAuthorityDerivedKeyOwner": parameters.get("currentAuthorityDerivedKeyOwner").cloned().unwrap_or(serde_json::Value::Null),
"currentAuthorityDerivedKeySeed": parameters.get("currentAuthorityDerivedKeySeed").cloned().unwrap_or(serde_json::Value::Null),
"newAuthorityFromAccount": parameters.get("newAuthorityFromAccount").cloned().unwrap_or(serde_json::Value::Bool(false)),
"stateTransition": match operation {
"authorize_with_seed" | "authorize_checked_with_seed" => "vote_authority_changed_with_seed",
"authorize_checked" => "vote_authority_changed_with_new_authority_signature",
_ => "vote_authority_changed",
},
"transactionFinalAccountStateCaptured": false,
});
}
fn vote_admin_details(
accounts: &serde_json::Value,
parameters: &serde_json::Value,
operation: &str,
) -> serde_json::Value {
return serde_json::json!({
"voteAccount": account_key_for_role(accounts, "vote_account"),
"withdrawAuthority": account_key_for_role(accounts, "withdraw_authority"),
"newValidatorIdentity": account_key_for_role(accounts, "new_validator_identity"),
"newCommissionCollector": account_key_for_role(accounts, "new_commission_collector"),
"commissionPercent": parameters.get("commissionPercent").cloned().unwrap_or(serde_json::Value::Null),
"commissionBasisPoints": parameters.get("commissionBasisPoints").cloned().unwrap_or(serde_json::Value::Null),
"commissionKind": parameters.get("commissionKind").cloned().unwrap_or(serde_json::Value::Null),
"stateTransition": match operation {
"update_validator_identity" => "vote_validator_identity_updated",
"update_commission" | "update_commission_bps" => "vote_commission_updated",
"update_commission_collector" => "vote_commission_collector_updated",
_ => "vote_admin_updated",
},
"transactionFinalAccountStateCaptured": false,
});
}
fn vote_state_details(
accounts: &serde_json::Value,
parameters: &serde_json::Value,
operation: &str,
) -> serde_json::Value {
return serde_json::json!({
"voteAccount": account_key_for_role(accounts, "vote_account"),
"voteAuthority": account_key_for_role(accounts, "vote_authority"),
"lastVotedSlot": parameters.get("lastVotedSlot").cloned().unwrap_or(serde_json::Value::Null),
"slots": parameters.get("slots").cloned().unwrap_or(serde_json::Value::Null),
"root": parameters.get("root").cloned().unwrap_or(serde_json::Value::Null),
"hash": first_parameter_value(parameters, &["hash", "bankHash"]),
"timestamp": parameters.get("timestamp").cloned().unwrap_or(serde_json::Value::Null),
"switchProofHash": parameters.get("switchProofHash").cloned().unwrap_or(serde_json::Value::Null),
"stateTransition": if operation.contains("switch") {
"vote_state_updated_with_switch_proof"
} else if operation.starts_with("tower_sync") {
"vote_tower_synchronized"
} else {
"vote_state_updated"
},
"voteCreditsComputed": false,
"transactionFinalAccountStateCaptured": false,
});
}
fn vote_value_details(
accounts: &serde_json::Value,
parameters: &serde_json::Value,
_operation: &str,
) -> serde_json::Value {
return serde_json::json!({
"voteAccount": account_key_for_role(accounts, "vote_account"),
"recipientAccount": account_key_for_role(accounts, "recipient_account"),
"withdrawAuthority": account_key_for_role(accounts, "withdraw_authority"),
"lamports": parameters.get("lamports").cloned().unwrap_or(serde_json::Value::Null),
"lamportBalanceChangesDuplicated": false,
"stateTransition": "vote_lamports_withdrawn_or_account_closed_conditionally",
"transactionFinalAccountStateCaptured": false,
});
}
fn vote_reward_details(
accounts: &serde_json::Value,
parameters: &serde_json::Value,
_operation: &str,
) -> serde_json::Value {
return serde_json::json!({
"voteAccount": account_key_for_role(accounts, "vote_account"),
"depositSource": account_key_for_role(accounts, "deposit_source"),
"depositLamports": parameters.get("depositLamports").cloned().unwrap_or(serde_json::Value::Null),
"lamportBalanceChangesDuplicated": false,
"stateTransition": "delegator_rewards_deposited_into_vote_account",
"transactionFinalAccountStateCaptured": false,
});
}
fn first_parameter_or_account(
parameters: &serde_json::Value,
keys: &[&str],
accounts: &serde_json::Value,
fallback_role: &str,
) -> serde_json::Value {
let value = first_parameter_value(parameters, keys);
if !value.is_null() {
return value;
}
return account_key_for_role(accounts, fallback_role);
}
fn first_parameter_value(parameters: &serde_json::Value, keys: &[&str]) -> serde_json::Value {
for key in keys {
if let std::option::Option::Some(value) = parameters.get(*key) {
return value.clone();
}
}
return serde_json::Value::Null;
}
fn first_account_key_for_roles(accounts: &serde_json::Value, roles: &[&str]) -> serde_json::Value {
for role in roles {
let value = account_key_for_role(accounts, role);
if !value.is_null() {
return value;
}
}
return serde_json::Value::Null;
}
fn account_key_for_role(accounts: &serde_json::Value, role: &str) -> serde_json::Value {
let account_key = accounts.as_array().and_then(|values| {
return values.iter().find_map(|value| {
if value.get("role").and_then(serde_json::Value::as_str)
!= std::option::Option::Some(role)
{
return std::option::Option::None;
}
return value
.get("accountKey")
.and_then(serde_json::Value::as_str)
.map(|account_key| return account_key.to_string());
});
});
return match account_key {
std::option::Option::Some(value) => serde_json::Value::String(value),
std::option::Option::None => serde_json::Value::Null,
};
}
fn contains(entries: &[&str], entry_code: &str) -> bool {
return entries.iter().any(|entry| return *entry == entry_code);
}
#[cfg(test)]
mod tests {
fn observation(
surface_code: &str,
entry_code: &str,
family: kb_model::EventFamily,
transaction_failed: bool,
accounts: serde_json::Value,
parameters: serde_json::Value,
) -> kb_decoder_api::DecodedObservation {
return kb_decoder_api::DecodedObservation {
event_key: format!("{entry_code}:0"),
event: kb_model::DecodedProtocolEvent {
signature: kb_model::Signature("signature".to_string()),
slot: kb_model::Slot(42),
instruction_path: kb_model::InstructionPath("0".to_string()),
program_id: kb_model::ProgramId("program-id".to_string()),
protocol_code: kb_model::ProtocolCode("solana_native".to_string()),
surface_code: kb_model::SurfaceCode(surface_code.to_string()),
event_code: kb_model::EventCode(format!("{surface_code}.{entry_code}")),
event_name: kb_model::EventName(entry_code.to_string()),
event_family: family,
source_kind: kb_model::EventSourceKind::Instruction,
confidence: kb_model::DecoderConfidence::ManualExact,
},
payload_json: serde_json::json!({"accounts": accounts, "parameters": parameters}),
transaction_failed,
transaction_error: if transaction_failed {
std::option::Option::Some(serde_json::json!({"InstructionError": [0, "Custom"]}))
} else {
std::option::Option::None
},
observation_committed: !transaction_failed,
proof: kb_decoder_api::DecoderProof {
kind: kb_decoder_api::DecoderProofKind::Manual,
confidence: kb_model::DecoderConfidence::ManualExact,
evidence: std::vec!["fixture".to_string()],
},
};
}
fn account(role: &str, account_key: &str) -> serde_json::Value {
return serde_json::json!({"role": role, "accountKey": account_key});
}
#[test]
fn stake_initialize_creates_instructional_staking_projection() {
let materializer = crate::StakingMaterializer;
let observation = observation(
super::STAKE_SURFACE,
"initialize",
kb_model::EventFamily::Lifecycle,
false,
serde_json::json!([account("stake_account", "stake111")]),
serde_json::json!({"authorized": {"staker": "staker111"}}),
);
let result =
kb_materializer_api::EventMaterializer::materialize(&materializer, &observation);
assert_eq!(result.status, kb_materializer_api::MaterializerOutcomeStatus::Inserted);
assert_eq!(result.outputs[0].family, kb_model::MaterializedEventFamily::Staking);
assert_eq!(result.outputs[0].output_key, "stake_account:initialize:0");
assert_eq!(result.outputs[0].payload_json["projection"]["stakeAccount"], "stake111");
assert_eq!(
result.outputs[0].payload_json["projection"]["transactionFinalAccountStateCaptured"],
false
);
}
#[test]
fn stake_authority_and_lockup_events_are_projected() {
let materializer = crate::StakingMaterializer;
let authority = observation(
super::STAKE_SURFACE,
"authorize_with_seed",
kb_model::EventFamily::Admin,
false,
serde_json::json!([account("stake_account", "stake111")]),
serde_json::json!({
"newAuthorizedPubkey": "new111",
"authorizationType": "staker",
"authoritySeed": "seed",
"authorityOwner": "owner111",
}),
);
let lockup = observation(
super::STAKE_SURFACE,
"set_lockup_checked",
kb_model::EventFamily::Admin,
false,
serde_json::json!([
account("stake_account", "stake111"),
account("lockup_custodian", "custodian111"),
]),
serde_json::json!({"epoch": 42}),
);
let authority_result =
kb_materializer_api::EventMaterializer::materialize(&materializer, &authority);
let lockup_result =
kb_materializer_api::EventMaterializer::materialize(&materializer, &lockup);
assert_eq!(authority_result.outputs[0].output_key, "stake_authority:authorize_with_seed:0");
assert_eq!(
authority_result.outputs[0].payload_json["projection"]["newAuthority"],
"new111"
);
assert_eq!(lockup_result.outputs[0].output_key, "stake_lockup:set_lockup_checked:0");
assert_eq!(
lockup_result.outputs[0].payload_json["projection"]["checkedCustodianFromAccount"],
true
);
}
#[test]
fn stake_value_projection_does_not_duplicate_lamport_changes() {
let materializer = crate::StakingMaterializer;
let observation = observation(
super::STAKE_SURFACE,
"withdraw",
kb_model::EventFamily::Staking,
false,
serde_json::json!([
account("stake_account", "stake111"),
account("destination", "dest111"),
]),
serde_json::json!({"lamports": 99}),
);
let result =
kb_materializer_api::EventMaterializer::materialize(&materializer, &observation);
assert_eq!(result.outputs[0].output_key, "stake_value:withdraw:0");
assert_eq!(result.outputs[0].payload_json["projection"]["lamports"], 99);
assert_eq!(
result.outputs[0].payload_json["projection"]["lamportBalanceChangesDuplicated"],
false
);
}
#[test]
fn vote_account_and_authority_events_are_projected() {
let materializer = crate::StakingMaterializer;
let initialize = observation(
super::VOTE_SURFACE,
"initialize_account_v2",
kb_model::EventFamily::Lifecycle,
false,
serde_json::json!([
account("vote_account", "vote111"),
account("validator_identity", "validator111"),
]),
serde_json::json!({
"authorizedVoter": "voter111",
"authorizedWithdrawer": "withdrawer111",
"inflationRewardsCommissionBasisPoints": 500,
}),
);
let authorize = observation(
super::VOTE_SURFACE,
"authorize_checked",
kb_model::EventFamily::Admin,
false,
serde_json::json!([
account("vote_account", "vote111"),
account("new_authority", "new111"),
]),
serde_json::json!({"authorizationType": {"kind": "voter"}, "newAuthorityFromAccount": true}),
);
let initialize_result =
kb_materializer_api::EventMaterializer::materialize(&materializer, &initialize);
let authorize_result =
kb_materializer_api::EventMaterializer::materialize(&materializer, &authorize);
assert_eq!(initialize_result.outputs[0].output_key, "vote_account:initialize_account_v2:0");
assert_eq!(
initialize_result.outputs[0].payload_json["projection"]["validatorIdentity"],
"validator111"
);
assert_eq!(authorize_result.outputs[0].output_key, "vote_authority:authorize_checked:0");
assert_eq!(
authorize_result.outputs[0].payload_json["projection"]["newAuthority"],
"new111"
);
}
#[test]
fn vote_state_and_reward_outputs_are_distinguished() {
let materializer = crate::StakingMaterializer;
let vote_state = observation(
super::VOTE_SURFACE,
"tower_sync_switch",
kb_model::EventFamily::Staking,
false,
serde_json::json!([
account("vote_account", "vote111"),
account("vote_authority", "authority111"),
]),
serde_json::json!({"root": 10, "hash": "hash111", "switchProofHash": "switch111"}),
);
let reward = observation(
super::VOTE_SURFACE,
"deposit_delegator_rewards",
kb_model::EventFamily::Reward,
false,
serde_json::json!([
account("vote_account", "vote111"),
account("deposit_source", "source111"),
]),
serde_json::json!({"depositLamports": 123}),
);
let vote_result =
kb_materializer_api::EventMaterializer::materialize(&materializer, &vote_state);
let reward_result =
kb_materializer_api::EventMaterializer::materialize(&materializer, &reward);
assert_eq!(vote_result.outputs[0].output_key, "vote_state:tower_sync_switch:0");
assert_eq!(
vote_result.outputs[0].payload_json["projection"]["switchProofHash"],
"switch111"
);
assert_eq!(reward_result.outputs[0].family, kb_model::MaterializedEventFamily::Reward);
assert_eq!(reward_result.outputs[0].output_key, "vote_reward:deposit_delegator_rewards:0");
}
#[test]
fn failed_staking_mutation_is_refused() {
let materializer = crate::StakingMaterializer;
let observation = observation(
super::STAKE_SURFACE,
"delegate_stake",
kb_model::EventFamily::Staking,
true,
serde_json::json!([account("stake_account", "stake111")]),
serde_json::json!({}),
);
let result =
kb_materializer_api::EventMaterializer::materialize(&materializer, &observation);
assert_eq!(result.status, kb_materializer_api::MaterializerOutcomeStatus::Refused);
assert!(result.outputs.is_empty());
}
#[test]
fn unrelated_or_disabled_observations_are_ignored() {
let materializer = crate::StakingMaterializer;
let unrelated = observation(
"solana_native_system",
"create_account",
kb_model::EventFamily::Lifecycle,
false,
serde_json::json!([]),
serde_json::json!({}),
);
let redelegate = observation(
super::STAKE_SURFACE,
"redelegate",
kb_model::EventFamily::Staking,
false,
serde_json::json!([]),
serde_json::json!({"officiallyDisabled": true}),
);
let unrelated_result =
kb_materializer_api::EventMaterializer::materialize(&materializer, &unrelated);
let redelegate_result =
kb_materializer_api::EventMaterializer::materialize(&materializer, &redelegate);
assert_eq!(
unrelated_result.status,
kb_materializer_api::MaterializerOutcomeStatus::Ignored
);
assert_eq!(
redelegate_result.status,
kb_materializer_api::MaterializerOutcomeStatus::Ignored
);
}
#[test]
fn output_serialization_is_deterministic() {
let materializer = crate::StakingMaterializer;
let observation = observation(
super::VOTE_SURFACE,
"update_commission_bps",
kb_model::EventFamily::Admin,
false,
serde_json::json!([account("vote_account", "vote111")]),
serde_json::json!({"commissionBasisPoints": 250, "commissionKind": "inflation_rewards"}),
);
let first =
kb_materializer_api::EventMaterializer::materialize(&materializer, &observation);
let second =
kb_materializer_api::EventMaterializer::materialize(&materializer, &observation);
let first_json_result = serde_json::to_string(&first.outputs[0].payload_json);
let first_json = match first_json_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("unexpected serialization error: {error}"),
};
let second_json_result = serde_json::to_string(&second.outputs[0].payload_json);
let second_json = match second_json_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("unexpected serialization error: {error}"),
};
assert_eq!(first_json, second_json);
}
}