0.1.0
This commit is contained in:
819
kb-lib/src/model/canonical_transaction.rs
Normal file
819
kb-lib/src/model/canonical_transaction.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
87
kb-lib/src/model/decoded.rs
Normal file
87
kb-lib/src/model/decoded.rs
Normal 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,
|
||||
}
|
||||
80
kb-lib/src/model/materialized.rs
Normal file
80
kb-lib/src/model/materialized.rs
Normal 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,
|
||||
}
|
||||
99
kb-lib/src/model/nomenclature.rs
Normal file
99
kb-lib/src/model/nomenclature.rs
Normal 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,
|
||||
}
|
||||
31
kb-lib/src/model/observation.rs
Normal file
31
kb-lib/src/model/observation.rs
Normal 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
126
kb-lib/src/model/replay.rs
Normal 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(());
|
||||
}
|
||||
}
|
||||
43
kb-lib/src/model/solana.rs
Normal file
43
kb-lib/src/model/solana.rs
Normal 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);
|
||||
Reference in New Issue
Block a user