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,819 @@
// file: kb-lib/src/model/canonical_transaction.rs
// version: 7
//! Source-independent canonical Solana transaction contract.
use base64::Engine; // rust-rules: trait-import
use sha2::Digest; // rust-rules: trait-import
/// Current canonical transaction document version.
pub const CANONICAL_TRANSACTION_FORMAT_VERSION: u32 = 1;
/// Source-independent canonical Solana transaction.
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalTransaction {
/// Canonical contract version included in the hashed document.
pub format_version: u32,
/// Primary transaction signature as base58 text.
pub primary_signature: std::string::String,
/// Slot in which the transaction was processed.
pub slot: u64,
/// Optional block timestamp as Unix seconds.
pub block_time: std::option::Option<i64>,
/// Transaction message version.
pub version: CanonicalTransactionVersion,
/// All transaction signatures in message order.
pub signatures: std::vec::Vec<std::string::String>,
/// Canonical transaction message.
pub message: CanonicalTransactionMessage,
/// Optional execution metadata.
pub metadata: std::option::Option<CanonicalTransactionMetadata>,
}
impl CanonicalTransaction {
/// Validates the canonical transaction invariants.
pub fn validate(&self) -> kb_core::Result<()> {
if self.format_version != crate::CANONICAL_TRANSACTION_FORMAT_VERSION {
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
"unsupported canonical transaction format version: {}",
self.format_version
)));
}
let primary_result = validate_signature_text(
self.primary_signature.as_str(),
"primary transaction signature",
);
if let std::result::Result::Err(error) = primary_result {
return std::result::Result::Err(error);
}
if self.signatures.is_empty() {
return std::result::Result::Err(kb_core::Error::invalid_state(
"canonical transaction must contain at least one signature",
));
}
if self.signatures[0] != self.primary_signature {
return std::result::Result::Err(kb_core::Error::invalid_state(
"primary transaction signature must equal the first signature",
));
}
if self.signatures.len() != usize::from(self.message.header.num_required_signatures) {
return std::result::Result::Err(kb_core::Error::invalid_state(
"canonical signature count must match the message header",
));
}
for signature in &self.signatures {
let signature_result =
validate_signature_text(signature.as_str(), "transaction signature");
if let std::result::Result::Err(error) = signature_result {
return std::result::Result::Err(error);
}
}
let message_result = self.message.validate();
if let std::result::Result::Err(error) = message_result {
return std::result::Result::Err(error);
}
if self.version == CanonicalTransactionVersion::Legacy
&& (!self.message.address_table_lookups.is_empty()
|| !self.message.loaded_addresses.writable.is_empty()
|| !self.message.loaded_addresses.readonly.is_empty())
{
return std::result::Result::Err(kb_core::Error::invalid_state(
"legacy canonical transaction must not contain lookup table data",
));
}
if let std::option::Option::Some(metadata) = &self.metadata {
let metadata_result = metadata
.validate(self.message.resolved_account_count(), self.message.instructions.len());
if let std::result::Result::Err(error) = metadata_result {
return std::result::Result::Err(error);
}
}
return std::result::Result::Ok(());
}
/// Serializes the transaction into the canonical JSON value.
pub fn to_canonical_json(&self) -> kb_core::Result<serde_json::Value> {
let validation_result = self.validate();
if let std::result::Result::Err(error) = validation_result {
return std::result::Result::Err(error);
}
let serialization_result = serde_json::to_value(self);
let mut value = match serialization_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(kb_core::Error::json(format!(
"cannot serialize canonical transaction: {error}"
)));
},
};
sort_json_value(&mut value);
return std::result::Result::Ok(value);
}
/// Serializes the transaction into deterministic compact canonical JSON bytes.
pub fn to_canonical_json_bytes(&self) -> kb_core::Result<std::vec::Vec<u8>> {
let json_result = self.to_canonical_json();
let json = match json_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let serialization_result = serde_json::to_vec(&json);
return match serialization_result {
std::result::Result::Ok(bytes) => std::result::Result::Ok(bytes),
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::json(
format!("cannot serialize canonical transaction bytes: {error}"),
)),
};
}
/// Calculates the lowercase SHA-256 digest of the canonical JSON bytes.
pub fn canonical_json_hash(&self) -> kb_core::Result<std::string::String> {
let bytes_result = self.to_canonical_json_bytes();
let bytes = match bytes_result {
std::result::Result::Ok(bytes) => bytes,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let digest = sha2::Sha256::digest(bytes);
let mut text = std::string::String::with_capacity(64);
for byte in digest {
text.push_str(format!("{byte:02x}").as_str());
}
return std::result::Result::Ok(text);
}
}
/// Canonical Solana transaction version.
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(tag = "kind", content = "number", rename_all = "snake_case")]
pub enum CanonicalTransactionVersion {
/// Legacy transaction message.
Legacy,
/// Numbered versioned transaction message.
Number(u8),
}
/// Canonical Solana message header.
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalMessageHeader {
/// Number of required transaction signatures.
pub num_required_signatures: u8,
/// Number of readonly signed accounts.
pub num_readonly_signed_accounts: u8,
/// Number of readonly unsigned accounts.
pub num_readonly_unsigned_accounts: u8,
}
/// Canonical Solana transaction message.
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalTransactionMessage {
/// Message header.
pub header: CanonicalMessageHeader,
/// Static account keys in message order.
pub static_account_keys: std::vec::Vec<std::string::String>,
/// Recent blockhash as base58 text.
pub recent_blockhash: std::string::String,
/// Top-level compiled instructions.
pub instructions: std::vec::Vec<CanonicalCompiledInstruction>,
/// Address lookup table references for versioned messages.
pub address_table_lookups: std::vec::Vec<CanonicalAddressTableLookup>,
/// Loaded writable and readonly addresses resolved by the runtime.
pub loaded_addresses: CanonicalLoadedAddresses,
}
impl CanonicalTransactionMessage {
/// Returns the resolved account count including loaded addresses.
pub fn resolved_account_count(&self) -> usize {
return self.static_account_keys.len()
+ self.loaded_addresses.writable.len()
+ self.loaded_addresses.readonly.len();
}
fn validate(&self) -> kb_core::Result<()> {
if self.static_account_keys.is_empty() {
return std::result::Result::Err(kb_core::Error::invalid_state(
"canonical transaction message must contain static account keys",
));
}
for account_key in &self.static_account_keys {
let key_result = validate_pubkey_text(account_key.as_str(), "static account key");
if let std::result::Result::Err(error) = key_result {
return std::result::Result::Err(error);
}
}
let blockhash_result =
validate_pubkey_text(self.recent_blockhash.as_str(), "recent blockhash");
if let std::result::Result::Err(error) = blockhash_result {
return std::result::Result::Err(error);
}
let required_signature_count = usize::from(self.header.num_required_signatures);
if required_signature_count == 0
|| required_signature_count > self.static_account_keys.len()
{
return std::result::Result::Err(kb_core::Error::invalid_state(
"canonical message required signature count is invalid",
));
}
if usize::from(self.header.num_readonly_signed_accounts) > required_signature_count {
return std::result::Result::Err(kb_core::Error::invalid_state(
"canonical readonly signed account count is invalid",
));
}
let unsigned_account_count = self.static_account_keys.len() - required_signature_count;
if usize::from(self.header.num_readonly_unsigned_accounts) > unsigned_account_count {
return std::result::Result::Err(kb_core::Error::invalid_state(
"canonical readonly unsigned account count is invalid",
));
}
let resolved_account_count = self.resolved_account_count();
for instruction in &self.instructions {
let instruction_result = instruction.validate(resolved_account_count);
if let std::result::Result::Err(error) = instruction_result {
return std::result::Result::Err(error);
}
}
for lookup in &self.address_table_lookups {
let lookup_result =
validate_pubkey_text(lookup.account_key.as_str(), "address lookup table key");
if let std::result::Result::Err(error) = lookup_result {
return std::result::Result::Err(error);
}
}
for account_key in &self.loaded_addresses.writable {
let key_result =
validate_pubkey_text(account_key.as_str(), "loaded writable account key");
if let std::result::Result::Err(error) = key_result {
return std::result::Result::Err(error);
}
}
for account_key in &self.loaded_addresses.readonly {
let key_result =
validate_pubkey_text(account_key.as_str(), "loaded readonly account key");
if let std::result::Result::Err(error) = key_result {
return std::result::Result::Err(error);
}
}
return std::result::Result::Ok(());
}
}
/// Canonical compiled Solana instruction.
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalCompiledInstruction {
/// Resolved account index of the invoked program.
pub program_id_index: u16,
/// Resolved account indexes consumed by the instruction.
pub account_indexes: std::vec::Vec<u16>,
/// Instruction data encoded as standard base64.
pub data_base64: std::string::String,
/// Optional runtime invocation stack height.
pub stack_height: std::option::Option<u32>,
}
impl CanonicalCompiledInstruction {
fn validate(&self, resolved_account_count: usize) -> kb_core::Result<()> {
if usize::from(self.program_id_index) >= resolved_account_count {
return std::result::Result::Err(kb_core::Error::invalid_state(
"canonical instruction program id index is outside resolved accounts",
));
}
for account_index in &self.account_indexes {
if usize::from(*account_index) >= resolved_account_count {
return std::result::Result::Err(kb_core::Error::invalid_state(
"canonical instruction account index is outside resolved accounts",
));
}
}
let decode_result =
base64::engine::general_purpose::STANDARD.decode(self.data_base64.as_bytes());
if let std::result::Result::Err(error) = decode_result {
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
"canonical instruction data is not valid base64: {error}"
)));
}
return std::result::Result::Ok(());
}
}
/// Canonical address lookup table reference.
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalAddressTableLookup {
/// Lookup table account key as base58 text.
pub account_key: std::string::String,
/// Writable lookup indexes.
pub writable_indexes: std::vec::Vec<u8>,
/// Readonly lookup indexes.
pub readonly_indexes: std::vec::Vec<u8>,
}
/// Runtime-loaded addresses for a versioned transaction.
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalLoadedAddresses {
/// Writable loaded addresses in runtime order.
pub writable: std::vec::Vec<std::string::String>,
/// Readonly loaded addresses in runtime order.
pub readonly: std::vec::Vec<std::string::String>,
}
/// Canonical transaction execution status.
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CanonicalTransactionStatus {
/// Transaction execution succeeded.
Success,
/// Transaction execution failed.
Failed,
}
/// Canonical transaction execution metadata.
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalTransactionMetadata {
/// Canonical execution status.
pub status: CanonicalTransactionStatus,
/// Optional source-independent transaction error document.
pub error: std::option::Option<serde_json::Value>,
/// Transaction fee in lamports.
pub fee: u64,
/// Account balances before execution in lamports.
pub pre_balances: std::vec::Vec<u64>,
/// Account balances after execution in lamports.
pub post_balances: std::vec::Vec<u64>,
/// Inner instruction groups indexed by parent top-level instruction.
pub inner_instructions: std::vec::Vec<CanonicalInnerInstructionGroup>,
/// Runtime log messages in execution order.
pub log_messages: std::vec::Vec<std::string::String>,
/// Token balances before execution.
pub pre_token_balances: std::vec::Vec<CanonicalTokenBalance>,
/// Token balances after execution.
pub post_token_balances: std::vec::Vec<CanonicalTokenBalance>,
/// Transaction rewards.
pub rewards: std::vec::Vec<CanonicalReward>,
/// Optional transaction return data.
pub return_data: std::option::Option<CanonicalReturnData>,
/// Optional consumed compute units.
pub compute_units_consumed: std::option::Option<u64>,
/// Optional runtime cost units.
pub cost_units: std::option::Option<u64>,
}
impl CanonicalTransactionMetadata {
fn validate(
&self,
resolved_account_count: usize,
top_level_instruction_count: usize,
) -> kb_core::Result<()> {
if self.pre_balances.len() != self.post_balances.len() {
return std::result::Result::Err(kb_core::Error::invalid_state(
"canonical pre and post balance arrays must have the same length",
));
}
if self.pre_balances.len() != resolved_account_count {
return std::result::Result::Err(kb_core::Error::invalid_state(
"canonical balance arrays must match the resolved account count",
));
}
match self.status {
CanonicalTransactionStatus::Success => {
if self.error.is_some() {
return std::result::Result::Err(kb_core::Error::invalid_state(
"successful canonical transaction must not contain an error",
));
}
},
CanonicalTransactionStatus::Failed => {
if self.error.is_none() {
return std::result::Result::Err(kb_core::Error::invalid_state(
"failed canonical transaction must contain an error",
));
}
},
}
for inner_group in &self.inner_instructions {
if usize::from(inner_group.parent_instruction_index) >= top_level_instruction_count {
return std::result::Result::Err(kb_core::Error::invalid_state(
"canonical inner instruction parent index is outside top-level instructions",
));
}
for instruction in &inner_group.instructions {
let instruction_result = instruction.validate(resolved_account_count);
if let std::result::Result::Err(error) = instruction_result {
return std::result::Result::Err(error);
}
}
}
for balance in &self.pre_token_balances {
let balance_result = balance.validate(resolved_account_count);
if let std::result::Result::Err(error) = balance_result {
return std::result::Result::Err(error);
}
}
for balance in &self.post_token_balances {
let balance_result = balance.validate(resolved_account_count);
if let std::result::Result::Err(error) = balance_result {
return std::result::Result::Err(error);
}
}
for reward in &self.rewards {
let reward_result = validate_pubkey_text(reward.pubkey.as_str(), "reward public key");
if let std::result::Result::Err(error) = reward_result {
return std::result::Result::Err(error);
}
if let std::option::Option::Some(commission) = reward.commission {
if commission > 100 {
return std::result::Result::Err(kb_core::Error::invalid_state(
"canonical reward commission must not exceed 100 percent",
));
}
}
}
if let std::option::Option::Some(return_data) = &self.return_data {
let program_result =
validate_pubkey_text(return_data.program_id.as_str(), "return data program id");
if let std::result::Result::Err(error) = program_result {
return std::result::Result::Err(error);
}
let decode_result = base64::engine::general_purpose::STANDARD
.decode(return_data.data_base64.as_bytes());
if let std::result::Result::Err(error) = decode_result {
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
"canonical return data is not valid base64: {error}"
)));
}
}
return std::result::Result::Ok(());
}
}
/// Group of inner instructions attached to one top-level instruction.
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalInnerInstructionGroup {
/// Parent top-level instruction index.
pub parent_instruction_index: u16,
/// Inner instructions in runtime order.
pub instructions: std::vec::Vec<CanonicalCompiledInstruction>,
}
/// Exact SPL token balance representation.
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalTokenBalance {
/// Resolved account index.
pub account_index: u16,
/// Token mint public key.
pub mint: std::string::String,
/// Optional token account owner public key.
pub owner: std::option::Option<std::string::String>,
/// Optional token program public key.
pub program_id: std::option::Option<std::string::String>,
/// Exact raw token amount as an unsigned decimal string.
pub amount: std::string::String,
/// Mint decimals.
pub decimals: u8,
/// Exact fixed-scale decimal amount derived from `amount` and `decimals`.
pub decimal_amount: std::string::String,
}
impl CanonicalTokenBalance {
/// Creates an exact canonical token balance without provider-specific UI formatting.
pub fn new(
account_index: u16,
mint: impl std::convert::Into<std::string::String>,
owner: std::option::Option<std::string::String>,
program_id: std::option::Option<std::string::String>,
amount: impl std::convert::Into<std::string::String>,
decimals: u8,
) -> kb_core::Result<Self> {
let amount_value = amount.into();
let normalized_result = normalize_unsigned_decimal(amount_value.as_str());
let normalized_amount = match normalized_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let decimal_result = exact_decimal_amount(normalized_amount.as_str(), decimals);
let decimal_amount = match decimal_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self {
account_index,
mint: mint.into(),
owner,
program_id,
amount: normalized_amount,
decimals,
decimal_amount,
});
}
fn validate(&self, resolved_account_count: usize) -> kb_core::Result<()> {
if usize::from(self.account_index) >= resolved_account_count {
return std::result::Result::Err(kb_core::Error::invalid_state(
"canonical token balance account index is outside resolved accounts",
));
}
let mint_result = validate_pubkey_text(self.mint.as_str(), "token balance mint");
if let std::result::Result::Err(error) = mint_result {
return std::result::Result::Err(error);
}
if let std::option::Option::Some(owner) = &self.owner {
let owner_result = validate_pubkey_text(owner.as_str(), "token balance owner");
if let std::result::Result::Err(error) = owner_result {
return std::result::Result::Err(error);
}
}
if let std::option::Option::Some(program_id) = &self.program_id {
let program_result =
validate_pubkey_text(program_id.as_str(), "token balance program id");
if let std::result::Result::Err(error) = program_result {
return std::result::Result::Err(error);
}
}
let decimal_result = exact_decimal_amount(self.amount.as_str(), self.decimals);
let expected_decimal_amount = match decimal_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if self.decimal_amount != expected_decimal_amount {
return std::result::Result::Err(kb_core::Error::invalid_state(
"canonical token balance decimal amount does not match amount and decimals",
));
}
return std::result::Result::Ok(());
}
}
/// Canonical transaction reward.
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalReward {
/// Reward account public key.
pub pubkey: std::string::String,
/// Signed lamport delta.
pub lamports: i64,
/// Post-reward balance in lamports.
pub post_balance: u64,
/// Optional reward type string.
pub reward_type: std::option::Option<std::string::String>,
/// Optional validator commission percentage.
pub commission: std::option::Option<u8>,
}
/// Canonical transaction return data.
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalReturnData {
/// Program that returned the data.
pub program_id: std::string::String,
/// Return bytes encoded as standard base64.
pub data_base64: std::string::String,
}
fn normalize_unsigned_decimal(amount: &str) -> kb_core::Result<std::string::String> {
if amount.is_empty() || !amount.chars().all(|character| return character.is_ascii_digit()) {
return std::result::Result::Err(kb_core::Error::invalid_state(
"canonical token balance amount must be an unsigned decimal string",
));
}
let trimmed = amount.trim_start_matches('0');
if trimmed.is_empty() {
return std::result::Result::Ok("0".to_string());
}
return std::result::Result::Ok(trimmed.to_string());
}
fn exact_decimal_amount(amount: &str, decimals: u8) -> kb_core::Result<std::string::String> {
let normalized_result = normalize_unsigned_decimal(amount);
let normalized = match normalized_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let scale = usize::from(decimals);
if scale == 0 {
return std::result::Result::Ok(normalized.clone());
}
if normalized.len() > scale {
let split_index = normalized.len() - scale;
return std::result::Result::Ok(format!(
"{}.{}",
&normalized[..split_index],
&normalized[split_index..]
));
}
let zero_count = scale - normalized.len();
return std::result::Result::Ok(format!("0.{}{}", "0".repeat(zero_count), normalized));
}
fn sort_json_value(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(object) => {
let mut keys = object.keys().cloned().collect::<std::vec::Vec<_>>();
keys.sort();
let mut sorted = serde_json::Map::new();
for key in keys {
let removed = object.remove(key.as_str());
if let std::option::Option::Some(mut child) = removed {
sort_json_value(&mut child);
sorted.insert(key, child);
}
}
*object = sorted;
},
serde_json::Value::Array(items) => {
for item in items {
sort_json_value(item);
}
},
_ => {},
}
}
fn validate_signature_text(value: &str, field_name: &str) -> kb_core::Result<()> {
return validate_base58_length(value, field_name, 64);
}
fn validate_pubkey_text(value: &str, field_name: &str) -> kb_core::Result<()> {
return validate_base58_length(value, field_name, 32);
}
fn validate_base58_length(
value: &str,
field_name: &str,
expected_length: usize,
) -> kb_core::Result<()> {
if value.trim().is_empty() {
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
"{field_name} must not be empty"
)));
}
let decode_result = bs58::decode(value).into_vec();
let decoded = match decode_result {
std::result::Result::Ok(bytes) => bytes,
std::result::Result::Err(error) => {
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
"{field_name} is not valid base58: {error}"
)));
},
};
if decoded.len() != expected_length {
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
"{field_name} must decode to {expected_length} bytes"
)));
}
return std::result::Result::Ok(());
}
#[cfg(test)]
mod tests {
fn sample_transaction() -> crate::CanonicalTransaction {
return crate::CanonicalTransaction {
format_version: crate::CANONICAL_TRANSACTION_FORMAT_VERSION,
primary_signature: "2Ana1pUpv2ZbMVkwF5FXapYeBEjdxDatLn7nvJkhgTSXbs59SyZSx866bXirPgj8QQVB57uxHJBG1YFvkRbFj4T".to_string(),
slot: 42,
block_time: std::option::Option::Some(1_700_000_000),
version: crate::CanonicalTransactionVersion::Legacy,
signatures: std::vec![
"2Ana1pUpv2ZbMVkwF5FXapYeBEjdxDatLn7nvJkhgTSXbs59SyZSx866bXirPgj8QQVB57uxHJBG1YFvkRbFj4T".to_string(),
],
message: crate::CanonicalTransactionMessage {
header: crate::CanonicalMessageHeader {
num_required_signatures: 1,
num_readonly_signed_accounts: 0,
num_readonly_unsigned_accounts: 1,
},
static_account_keys: std::vec![
"11111111111111111111111111111111".to_string(),
"SysvarC1ock11111111111111111111111111111111".to_string(),
],
recent_blockhash: "11111111111111111111111111111111".to_string(),
instructions: std::vec![crate::CanonicalCompiledInstruction {
program_id_index: 0,
account_indexes: std::vec![1],
data_base64: "AQ==".to_string(),
stack_height: std::option::Option::Some(1),
}],
address_table_lookups: std::vec::Vec::new(),
loaded_addresses: crate::CanonicalLoadedAddresses::default(),
},
metadata: std::option::Option::Some(crate::CanonicalTransactionMetadata {
status: crate::CanonicalTransactionStatus::Success,
error: std::option::Option::None,
fee: 5000,
pre_balances: std::vec![10_000, 1],
post_balances: std::vec![5_000, 1],
inner_instructions: std::vec::Vec::new(),
log_messages: std::vec!["Program 11111111111111111111111111111111 success".to_string()],
pre_token_balances: std::vec::Vec::new(),
post_token_balances: std::vec::Vec::new(),
rewards: std::vec::Vec::new(),
return_data: std::option::Option::None,
compute_units_consumed: std::option::Option::Some(100),
cost_units: std::option::Option::Some(120),
}),
};
}
#[test]
fn canonical_hash_is_stable() {
let first = sample_transaction();
let second = sample_transaction();
let first_hash = match first.canonical_json_hash() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("first hash failed: {error}"),
};
let second_hash = match second.canonical_json_hash() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("second hash failed: {error}"),
};
assert_eq!(first_hash, second_hash);
assert_eq!(first_hash.len(), 64);
}
#[test]
fn canonical_hash_sorts_nested_error_objects() {
let mut first = sample_transaction();
let mut second = sample_transaction();
if let std::option::Option::Some(metadata) = &mut first.metadata {
metadata.status = crate::CanonicalTransactionStatus::Failed;
metadata.error = std::option::Option::Some(serde_json::json!({
"z": 3,
"nested": {"b": 2, "a": 1}
}));
}
if let std::option::Option::Some(metadata) = &mut second.metadata {
metadata.status = crate::CanonicalTransactionStatus::Failed;
let parse_result =
serde_json::from_str::<serde_json::Value>(r#"{"nested":{"a":1,"b":2},"z":3}"#);
metadata.error = match parse_result {
std::result::Result::Ok(value) => std::option::Option::Some(value),
std::result::Result::Err(error) => panic!("error fixture parse failed: {error}"),
};
}
let first_hash = match first.canonical_json_hash() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("first nested hash failed: {error}"),
};
let second_hash = match second.canonical_json_hash() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("second nested hash failed: {error}"),
};
assert_eq!(first_hash, second_hash);
}
#[test]
fn validation_rejects_signature_with_public_key_length() {
let mut transaction = sample_transaction();
transaction.primary_signature = "3Bxs4NN8M2Yn4TLb7gR6Xy7n2D1Q8VjQqWcnpX8C6pQw".to_string();
transaction.signatures[0] = transaction.primary_signature.clone();
let result = transaction.validate();
assert!(result.is_err());
}
#[test]
fn token_decimal_amount_is_derived_without_source_ui_formatting() {
let result = crate::CanonicalTokenBalance::new(
0,
"So11111111111111111111111111111111111111112",
std::option::Option::None,
std::option::Option::None,
"000123",
5,
);
let balance = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("token balance creation failed: {error}"),
};
assert_eq!(balance.amount, "123");
assert_eq!(balance.decimal_amount, "0.00123");
}
#[test]
fn canonical_json_contains_no_provider_fields() {
let transaction = sample_transaction();
let json_result = transaction.to_canonical_json();
let json = match json_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("canonical json failed: {error}"),
};
let text = json.to_string();
assert!(!text.contains("provider"));
assert!(!text.contains("endpoint"));
assert!(!text.contains("protocol"));
}
#[test]
fn validation_rejects_inconsistent_status_and_error() {
let mut transaction = sample_transaction();
if let std::option::Option::Some(metadata) = &mut transaction.metadata {
metadata.status = crate::CanonicalTransactionStatus::Failed;
}
let result = transaction.validate();
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,87 @@
// file: kb-lib/src/model/decoded.rs
// version: 4
//! Shared decoded protocol event model.
use ts_rs::TS; // rust-rules: derive-import
/// Source from which a decoded event was derived.
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_model/decoded/EventSourceKind.ts"
)]
pub enum EventSourceKind {
/// Top-level instruction.
Instruction,
/// Inner instruction.
InnerInstruction,
/// Program log line.
Log,
/// Anchor event.
AnchorEvent,
/// Anchor self-CPI event.
AnchorSelfCpiEvent,
/// Balance delta inference.
BalanceDelta,
/// Synthetic test or generated event.
Synthetic,
/// Inferred event.
Inferred,
/// Audit-only event.
Audit,
}
/// Confidence level for a decoded event.
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_model/decoded/DecoderConfidence.ts"
)]
pub enum DecoderConfidence {
/// Exact manual decode.
Exact,
/// Exact IDL-based decode.
IdlExact,
/// Exact manually verified decode.
ManualExact,
/// Inferred decode.
Inferred,
/// Unsafe decode.
Unsafe,
/// Audit-only decode.
AuditOnly,
/// Unknown confidence.
Unknown,
}
/// Protocol-level decoded event produced by a decoder.
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_model/decoded/DecodedProtocolEvent.ts"
)]
pub struct DecodedProtocolEvent {
/// Transaction signature.
pub signature: crate::Signature,
/// Transaction slot.
pub slot: crate::Slot,
/// Instruction path.
pub instruction_path: crate::InstructionPath,
/// Program id.
pub program_id: crate::ProgramId,
/// Protocol family.
pub protocol_code: crate::ProtocolCode,
/// Protocol surface.
pub surface_code: crate::SurfaceCode,
/// Canonical event code.
pub event_code: crate::EventCode,
/// Short event name.
pub event_name: crate::EventName,
/// Event family.
pub event_family: crate::EventFamily,
/// Event source kind.
pub source_kind: crate::EventSourceKind,
/// Decoder confidence.
pub confidence: crate::DecoderConfidence,
}

View File

@@ -0,0 +1,80 @@
// file: kb-lib/src/model/materialized.rs
// version: 5
//! Shared materialized business event model.
use ts_rs::TS; // rust-rules: derive-import
/// Materialized event family.
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_model/materialized/MaterializedEventFamily.ts"
)]
pub enum MaterializedEventFamily {
/// Trade materialization.
Trade,
/// Liquidity materialization.
Liquidity,
/// Lifecycle materialization.
Lifecycle,
/// Fee materialization.
Fee,
/// Admin materialization.
Admin,
/// Token-account materialization.
TokenAccount,
/// Pool-state materialization.
PoolState,
/// Orderbook materialization.
Orderbook,
/// Reward materialization.
Reward,
/// NFT materialization.
Nft,
/// Metadata materialization.
Metadata,
/// Oracle materialization.
Oracle,
/// Lending materialization.
Lending,
/// Staking materialization.
Staking,
/// Governance materialization.
Governance,
/// Bridge materialization.
Bridge,
/// Perpetuals materialization.
Perpetuals,
/// Vault materialization.
Vault,
/// Routing materialization.
Routing,
/// Compliance audit materialization.
ComplianceAudit,
/// Token metadata risk materialization.
TokenMetadataRisk,
/// Risk materialization.
Risk,
/// Transaction annotation materialization.
TransactionAnnotation,
/// Unknown materialization family.
Unknown,
}
/// Business-level materialized event placeholder.
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_model/materialized/MaterializedEvent.ts"
)]
pub struct MaterializedEvent {
/// Source transaction signature.
pub signature: crate::Signature,
/// Source transaction slot.
pub slot: crate::Slot,
/// Source decoded family.
pub decoded_family: crate::EventFamily,
/// Materialized family.
pub materialized_family: crate::MaterializedEventFamily,
}

View File

@@ -0,0 +1,99 @@
// file: kb-lib/src/model/nomenclature.rs
// version: 3
//! Shared protocol, surface, and event nomenclature types.
use ts_rs::TS; // rust-rules: derive-import
/// Stable internal program code.
#[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Deserialize, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_model/nomenclature/ProgramCode.ts"
)]
pub struct ProgramCode(pub std::string::String);
/// Protocol family code, for example `raydium` or `meteora`.
#[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Deserialize, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_model/nomenclature/ProtocolCode.ts"
)]
pub struct ProtocolCode(pub std::string::String);
/// Concrete protocol surface code, for example `raydium_amm_v4`.
#[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Deserialize, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_model/nomenclature/SurfaceCode.ts"
)]
pub struct SurfaceCode(pub std::string::String);
/// Event name without surface prefix.
#[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Deserialize, serde::Serialize, TS)]
#[ts(export, export_to = "../frontend/ts/bindings/kb_model/nomenclature/EventName.ts")]
pub struct EventName(pub std::string::String);
/// Canonical event code in `<surface_code>.<event_name>` format.
#[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Deserialize, serde::Serialize, TS)]
#[ts(export, export_to = "../frontend/ts/bindings/kb_model/nomenclature/EventCode.ts")]
pub struct EventCode(pub std::string::String);
/// High-level event family.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, serde::Deserialize, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_model/nomenclature/EventFamily.ts"
)]
pub enum EventFamily {
/// Swap or buy/sell event.
Trade,
/// Liquidity deposit/withdraw or equivalent.
Liquidity,
/// Pool, market, curve, or config lifecycle event.
Lifecycle,
/// Fee claim, collection, sweep, or update.
Fee,
/// Admin or authority event.
Admin,
/// Reward event.
Reward,
/// Orderbook event.
Orderbook,
/// Token account event.
TokenAccount,
/// Token mint event.
TokenMint,
/// Token burn event.
TokenBurn,
/// NFT event.
Nft,
/// Token or NFT metadata event.
Metadata,
/// Oracle event.
Oracle,
/// Lending or borrowing event.
Lending,
/// Staking event.
Staking,
/// Governance event.
Governance,
/// Bridge event.
Bridge,
/// Perpetuals or derivatives event.
Perpetuals,
/// Vault event.
Vault,
/// Router or aggregator event.
Routing,
/// Compliance audit event.
ComplianceAudit,
/// Token metadata risk event.
TokenMetadataRisk,
/// Risk signal.
Risk,
/// Audit-only event.
Audit,
/// Unknown family.
Unknown,
}

View File

@@ -0,0 +1,31 @@
// file: kb-lib/src/model/observation.rs
// version: 4
//! Shared program observation model.
use ts_rs::TS; // rust-rules: derive-import
/// Generic program observation derived from a Solana instruction.
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_model/observation/ProgramObservation.ts"
)]
pub struct ProgramObservation {
/// Transaction signature.
pub signature: crate::Signature,
/// Transaction slot.
pub slot: crate::Slot,
/// Instruction path.
pub instruction_path: crate::InstructionPath,
/// Program id.
pub program_id: crate::ProgramId,
/// Optional 8-byte discriminator in hexadecimal.
pub discriminator_8: std::option::Option<std::string::String>,
/// Raw instruction data length.
pub data_len: usize,
/// Number of instruction accounts.
pub accounts_len: usize,
/// Whether the source transaction failed.
pub failed: bool,
}

126
kb-lib/src/model/replay.rs Normal file
View File

@@ -0,0 +1,126 @@
// file: kb-lib/src/model/replay.rs
// version: 2
//! Source-neutral instruction replay input shared by decoders and stores.
/// Version of the normalized core replay contract.
pub const CORE_REPLAY_INPUT_CONTRACT_VERSION: u32 = 2;
/// Decoder replay input containing one instruction plus extracted transaction context.
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct CoreInstructionReplayInput {
/// Version of the normalized core replay contract.
pub core_contract_version: u32,
/// Stable deduplication key for this replay input.
pub replay_input_key: std::string::String,
/// Transaction signature as non-empty base58 text.
pub signature: std::string::String,
/// Transaction slot in Solana unsigned representation.
pub slot: u64,
/// Stable instruction path, for example `0` or `2/1`.
pub instruction_path: std::string::String,
/// Program id as non-empty base58 text.
pub program_id: std::string::String,
/// Optional surface hint supplied by an operator or upstream classifier.
pub surface_code_hint: std::option::Option<std::string::String>,
/// Whether the parent transaction failed on-chain.
pub transaction_failed: bool,
/// Optional transaction error JSON.
pub transaction_err_json: std::option::Option<serde_json::Value>,
/// Resolved account keys for the whole transaction.
pub account_keys_json: serde_json::Value,
/// Instruction accounts for the target instruction.
pub instruction_accounts_json: serde_json::Value,
/// Optional instruction payload retained by the store.
pub instruction_payload_json: std::option::Option<serde_json::Value>,
/// Optional deterministic hash of the retained instruction payload.
pub instruction_payload_hash: std::option::Option<std::string::String>,
/// Ordered outer instructions for the transaction.
pub outer_instructions_json: serde_json::Value,
/// Inner instruction tree or subtree relevant to the target instruction.
pub inner_instructions_json: serde_json::Value,
/// Ordered logs relevant to the transaction or target instruction.
pub logs_json: serde_json::Value,
/// Balance changes relevant to the transaction or target instruction.
pub balance_changes_json: serde_json::Value,
}
impl CoreInstructionReplayInput {
/// Builds a decoder replay input after validating its stable identity and array context.
#[allow(clippy::too_many_arguments)]
pub fn new(
replay_input_key: impl std::convert::Into<std::string::String>,
signature: impl std::convert::Into<std::string::String>,
slot: u64,
instruction_path: impl std::convert::Into<std::string::String>,
program_id: impl std::convert::Into<std::string::String>,
transaction_failed: bool,
transaction_err_json: std::option::Option<serde_json::Value>,
account_keys_json: serde_json::Value,
instruction_accounts_json: serde_json::Value,
instruction_payload_json: std::option::Option<serde_json::Value>,
instruction_payload_hash: std::option::Option<std::string::String>,
outer_instructions_json: serde_json::Value,
inner_instructions_json: serde_json::Value,
logs_json: serde_json::Value,
balance_changes_json: serde_json::Value,
) -> kb_core::Result<Self> {
if !outer_instructions_json.is_array() {
return std::result::Result::Err(kb_core::Error::invalid_state(
"core replay input outer instructions must be a JSON array",
));
}
let value = Self {
core_contract_version: crate::CORE_REPLAY_INPUT_CONTRACT_VERSION,
replay_input_key: replay_input_key.into(),
signature: signature.into(),
slot,
instruction_path: instruction_path.into(),
program_id: program_id.into(),
surface_code_hint: std::option::Option::None,
transaction_failed,
transaction_err_json,
account_keys_json,
instruction_accounts_json,
instruction_payload_json,
instruction_payload_hash,
outer_instructions_json,
inner_instructions_json,
logs_json,
balance_changes_json,
};
let validation_result = value.validate();
if let std::result::Result::Err(error) = validation_result {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(value);
}
/// Validates the stable replay identity and contract version.
pub fn validate(&self) -> kb_core::Result<()> {
if self.core_contract_version != crate::CORE_REPLAY_INPUT_CONTRACT_VERSION {
return std::result::Result::Err(kb_core::Error::invalid_state(
"unsupported core replay input contract version",
));
}
if self.replay_input_key.trim().is_empty()
|| self.signature.trim().is_empty()
|| self.instruction_path.trim().is_empty()
|| self.program_id.trim().is_empty()
{
return std::result::Result::Err(kb_core::Error::invalid_state(
"core replay identity fields must not be empty",
));
}
if self
.instruction_payload_hash
.as_deref()
.is_some_and(|value| return value.trim().is_empty())
{
return std::result::Result::Err(kb_core::Error::invalid_state(
"core replay payload hash must not be empty when present",
));
}
return std::result::Result::Ok(());
}
}

View File

@@ -0,0 +1,43 @@
// file: kb-lib/src/model/solana.rs
// version: 3
//! Shared Solana primitive wrapper types.
use ts_rs::TS; // rust-rules: derive-import
/// Solana transaction signature.
#[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Deserialize, serde::Serialize, TS)]
#[ts(export, export_to = "../frontend/ts/bindings/kb_model/solana/Signature.ts")]
pub struct Signature(pub std::string::String);
/// Solana slot.
#[derive(
Clone,
Copy,
Debug,
Eq,
PartialEq,
Ord,
PartialOrd,
Hash,
serde::Deserialize,
serde::Serialize,
TS,
)]
#[ts(export, export_to = "../frontend/ts/bindings/kb_model/solana/Slot.ts")]
pub struct Slot(pub u64);
/// Solana program id.
#[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Deserialize, serde::Serialize, TS)]
#[ts(export, export_to = "../frontend/ts/bindings/kb_model/solana/ProgramId.ts")]
pub struct ProgramId(pub std::string::String);
/// Solana public key.
#[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Deserialize, serde::Serialize, TS)]
#[ts(export, export_to = "../frontend/ts/bindings/kb_model/solana/Pubkey.ts")]
pub struct Pubkey(pub std::string::String);
/// Stable top-level or inner instruction path.
#[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Deserialize, serde::Serialize, TS)]
#[ts(export, export_to = "../frontend/ts/bindings/kb_model/solana/InstructionPath.ts")]
pub struct InstructionPath(pub std::string::String);