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,21 @@
# file: kb_model/Cargo.toml
# version: 2
[package]
name = "kb_model"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
base64.workspace = true
bs58.workspace = true
kb_core = { path = "../kb_core" }
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
ts-rs.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,32 @@
<!-- file: kb_model/README.md -->
<!-- version: 4 -->
# kb_model
Ce crate définit les modèles neutres utilisés par l'ingestion, l'extraction, le décodage et la matérialisation.
## Rôle dans l'écosystème
Ce module fait partie du découpage strict de Khadhroony Bot2. Il doit conserver des dépendances limitées et ne pas contourner les interfaces communes du workspace.
## Règles locales
- Les commentaires de code restent en anglais.
- La documentation Markdown reste en français.
- Les exports publics sont contrôlés depuis `lib.rs` lorsque le crate expose une bibliothèque.
- Les binaires utilisent `main.rs` avec les attributs Rust obligatoires.
## Contrat canonique `0.3.2`
Le crate expose maintenant `CanonicalTransaction` et les structures associées pour représenter une transaction Solana indépendamment de sa source. Le contrat couvre les transactions legacy et v0, les comptes statiques et chargés par ALT, les instructions externes et internes, les logs, les balances SOL/SPL, les erreurs, rewards, return data, compute units et block time.
La sérialisation canonique :
- utilise des noms de champs stables en camelCase ;
- trie récursivement les clés des objets JSON ;
- conserve signatures et public keys en base58 ;
- conserve les payloads binaires en base64 ;
- conserve les valeurs entières et recalcule localement les montants SPL décimaux à partir du montant brut et des décimales ;
- exclut toute information de provider, endpoint ou transport ;
- calcule `canonical_json_hash` en SHA-256 sur le JSON compact déterministe.

View File

@@ -0,0 +1,819 @@
// file: kb_model/src/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_model/src/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,77 @@
// file: kb_model/src/lib.rs
// version: 2
//! Shared data model types.
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
mod canonical_transaction;
mod decoded;
mod materialized;
mod nomenclature;
mod observation;
mod solana;
/// Current canonical transaction document version.
pub use crate::canonical_transaction::CANONICAL_TRANSACTION_FORMAT_VERSION;
/// Canonical address lookup table reference.
pub use crate::canonical_transaction::CanonicalAddressTableLookup;
/// Canonical compiled Solana instruction.
pub use crate::canonical_transaction::CanonicalCompiledInstruction;
/// Canonical group of inner instructions.
pub use crate::canonical_transaction::CanonicalInnerInstructionGroup;
/// Canonical runtime-loaded addresses.
pub use crate::canonical_transaction::CanonicalLoadedAddresses;
/// Canonical Solana message header.
pub use crate::canonical_transaction::CanonicalMessageHeader;
/// Canonical transaction return data.
pub use crate::canonical_transaction::CanonicalReturnData;
/// Canonical transaction reward.
pub use crate::canonical_transaction::CanonicalReward;
/// Canonical exact token balance.
pub use crate::canonical_transaction::CanonicalTokenBalance;
/// Source-independent canonical Solana transaction.
pub use crate::canonical_transaction::CanonicalTransaction;
/// Canonical Solana transaction message.
pub use crate::canonical_transaction::CanonicalTransactionMessage;
/// Canonical Solana transaction execution metadata.
pub use crate::canonical_transaction::CanonicalTransactionMetadata;
/// Canonical Solana transaction execution status.
pub use crate::canonical_transaction::CanonicalTransactionStatus;
/// Canonical Solana transaction version.
pub use crate::canonical_transaction::CanonicalTransactionVersion;
/// Protocol-level decoded event produced by a decoder.
pub use crate::decoded::DecodedProtocolEvent;
/// Confidence level for a decoded event.
pub use crate::decoded::DecoderConfidence;
/// Source from which a decoded event was derived.
pub use crate::decoded::EventSourceKind;
/// Business-level materialized event placeholder.
pub use crate::materialized::MaterializedEvent;
/// Materialized event family.
pub use crate::materialized::MaterializedEventFamily;
/// Canonical event code in `<surface_code>.<event_name>` format.
pub use crate::nomenclature::EventCode;
/// High-level event family.
pub use crate::nomenclature::EventFamily;
/// Event name without surface prefix.
pub use crate::nomenclature::EventName;
/// Stable internal program code.
pub use crate::nomenclature::ProgramCode;
/// Protocol family code, for example `raydium` or `meteora`.
pub use crate::nomenclature::ProtocolCode;
/// Concrete protocol surface code, for example `raydium_amm_v4`.
pub use crate::nomenclature::SurfaceCode;
/// Generic program observation derived from a Solana instruction.
pub use crate::observation::ProgramObservation;
/// Stable top-level or inner instruction path.
pub use crate::solana::InstructionPath;
/// Solana program id.
pub use crate::solana::ProgramId;
/// Solana public key.
pub use crate::solana::Pubkey;
/// Solana transaction signature.
pub use crate::solana::Signature;
/// Solana slot.
pub use crate::solana::Slot;

View File

@@ -0,0 +1,80 @@
// file: kb_model/src/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_model/src/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_model/src/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,
}

View File

@@ -0,0 +1,43 @@
// file: kb_model/src/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);