v0.1.0-pre.026

This commit is contained in:
2026-07-25 08:29:46 +02:00
parent f4d024680c
commit d00a4c7245
9 changed files with 1285 additions and 1 deletions

View File

@@ -0,0 +1,234 @@
// file: kb-onchain-transport/src/get_signatures_for_address.rs
// version: 2
//! Standard Solana `getSignaturesForAddress` request and response contracts.
/// One transaction signature summary returned by `getSignaturesForAddress`.
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AddressSignatureInfo {
/// Transaction signature.
pub signature: std::string::String,
/// Slot containing the transaction.
pub slot: u64,
/// Optional transaction error returned by the RPC node.
pub err: std::option::Option<serde_json::Value>,
/// Optional memo associated with the transaction.
pub memo: std::option::Option<std::string::String>,
/// Optional block time as a Unix timestamp.
pub block_time: std::option::Option<i64>,
/// Optional confirmation status reported by the RPC node.
pub confirmation_status: std::option::Option<std::string::String>,
}
/// Configuration for one standard Solana `getSignaturesForAddress` request.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GetSignaturesForAddressConfig {
/// Requested commitment, restricted to `confirmed` or `finalized`.
pub commitment: std::string::String,
/// Maximum number of signatures requested from the node.
pub limit: u16,
/// Optional exclusive cursor toward older signatures.
pub before: std::option::Option<std::string::String>,
/// Optional exclusive lower boundary toward newer signatures.
pub until: std::option::Option<std::string::String>,
/// Optional minimum context slot.
pub min_context_slot: std::option::Option<u64>,
}
impl GetSignaturesForAddressConfig {
/// Creates and validates one signature history request configuration.
pub fn new(
commitment: impl std::convert::Into<std::string::String>,
limit: u16,
before: std::option::Option<std::string::String>,
until: std::option::Option<std::string::String>,
min_context_slot: std::option::Option<u64>,
) -> kb_core::Result<Self> {
let commitment_value = commitment.into();
if commitment_value != "confirmed" && commitment_value != "finalized" {
return std::result::Result::Err(kb_core::Error::config(
"getSignaturesForAddress commitment must be 'confirmed' or 'finalized'",
));
}
if limit == 0 || limit > 1000 {
return std::result::Result::Err(kb_core::Error::config(
"getSignaturesForAddress limit must be between 1 and 1000",
));
}
let before_result = validate_optional_signature(
before.as_ref(),
"getSignaturesForAddress before signature",
);
if let std::result::Result::Err(error) = before_result {
return std::result::Result::Err(error);
}
let until_result =
validate_optional_signature(until.as_ref(), "getSignaturesForAddress until signature");
if let std::result::Result::Err(error) = until_result {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(Self {
commitment: commitment_value,
limit,
before,
until,
min_context_slot,
});
}
fn request_params(&self, address: &str) -> kb_core::Result<std::vec::Vec<serde_json::Value>> {
let address_result =
crate::validate_solana_pubkey_text(address, "getSignaturesForAddress address");
if let std::result::Result::Err(error) = address_result {
return std::result::Result::Err(error);
}
let mut config = serde_json::Map::<std::string::String, serde_json::Value>::new();
config.insert("commitment".to_string(), serde_json::Value::String(self.commitment.clone()));
config.insert(
"limit".to_string(),
serde_json::Value::Number(serde_json::Number::from(self.limit)),
);
if let std::option::Option::Some(before) = &self.before {
config.insert("before".to_string(), serde_json::Value::String(before.clone()));
}
if let std::option::Option::Some(until) = &self.until {
config.insert("until".to_string(), serde_json::Value::String(until.clone()));
}
if let std::option::Option::Some(min_context_slot) = self.min_context_slot {
config.insert(
"minContextSlot".to_string(),
serde_json::Value::Number(serde_json::Number::from(min_context_slot)),
);
}
return std::result::Result::Ok(std::vec![
serde_json::Value::String(address.to_string()),
serde_json::Value::Object(config),
]);
}
}
impl crate::HttpClient {
/// Fetches one page of signatures associated with an account or program address.
pub async fn get_signatures_for_address(
&self,
address: &str,
config: &crate::GetSignaturesForAddressConfig,
) -> kb_core::Result<std::vec::Vec<crate::AddressSignatureInfo>> {
let params_result = config.request_params(address);
let params = match params_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let result = self
.execute_json_rpc_result_raw("getSignaturesForAddress".to_string(), params)
.await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return crate::adapt_get_signatures_for_address_result(&value);
}
}
/// Adapts a raw standard Solana `getSignaturesForAddress` result value.
pub fn adapt_get_signatures_for_address_result(
source: &serde_json::Value,
) -> kb_core::Result<std::vec::Vec<crate::AddressSignatureInfo>> {
let parse_result =
serde_json::from_value::<std::vec::Vec<crate::AddressSignatureInfo>>(source.clone());
let parsed = match parse_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(kb_core::Error::json(format!(
"cannot parse getSignaturesForAddress result: {error}"
)));
},
};
for item in &parsed {
let validation_result = crate::validate_transaction_signature_text(
item.signature.as_str(),
"getSignaturesForAddress result signature",
);
if let std::result::Result::Err(error) = validation_result {
return std::result::Result::Err(error);
}
}
return std::result::Result::Ok(parsed);
}
fn validate_optional_signature(
value: std::option::Option<&std::string::String>,
field_name: &str,
) -> kb_core::Result<()> {
if let std::option::Option::Some(signature) = value {
return crate::validate_transaction_signature_text(signature.as_str(), field_name);
}
return std::result::Result::Ok(());
}
#[cfg(test)]
mod tests {
fn signature(byte: u8) -> std::string::String {
return bs58::encode([byte; 64]).into_string();
}
fn pubkey(byte: u8) -> std::string::String {
return bs58::encode([byte; 32]).into_string();
}
#[test]
fn config_supports_before_and_until_for_bounded_pagination() {
let config_result = crate::GetSignaturesForAddressConfig::new(
"confirmed",
1000,
std::option::Option::Some(signature(1)),
std::option::Option::Some(signature(2)),
std::option::Option::Some(44),
);
let config = match config_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("unexpected config error: {error}"),
};
let params_result = config.request_params(pubkey(3).as_str());
let params = match params_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("unexpected params error: {error}"),
};
assert_eq!(params.len(), 2);
assert_eq!(params[1]["limit"], serde_json::json!(1000));
assert_eq!(params[1]["minContextSlot"], serde_json::json!(44));
}
#[test]
fn config_rejects_limit_above_rpc_maximum() {
let result = crate::GetSignaturesForAddressConfig::new(
"confirmed",
1001,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
);
assert!(result.is_err());
}
#[test]
fn result_adapter_preserves_signature_metadata() {
let source = serde_json::json!([{
"signature": signature(4),
"slot": 123,
"err": null,
"memo": "hello",
"blockTime": 456,
"confirmationStatus": "finalized"
}]);
let result = crate::adapt_get_signatures_for_address_result(&source);
let values = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("unexpected adapter error: {error}"),
};
assert_eq!(values.len(), 1);
assert_eq!(values[0].slot, 123);
assert_eq!(values[0].memo.as_deref(), std::option::Option::Some("hello"));
}
}

View File

@@ -0,0 +1,730 @@
// file: kb-onchain-transport/src/get_transaction.rs
// version: 8
//! Standard Solana `getTransaction` adapter for the canonical transaction contract.
use base64::Engine; // rust-rules: trait-import
/// Common adapter contract for transport-specific transaction payloads.
pub trait CanonicalTransactionAdapter {
/// Source payload type accepted by the adapter.
type Source;
/// Adapts one source payload into an optional canonical transaction.
fn adapt(
&self,
source: &Self::Source,
) -> kb_core::Result<std::option::Option<kb_lib::MdCanonicalTransaction>>;
}
/// Configuration for a standard Solana `getTransaction` request.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GetTransactionConfig {
/// Requested commitment, restricted to `confirmed` or `finalized`.
pub commitment: std::string::String,
/// Maximum supported numbered transaction version.
pub max_supported_transaction_version: u8,
}
impl GetTransactionConfig {
/// Creates and validates a `getTransaction` request configuration.
pub fn new(
commitment: impl std::convert::Into<std::string::String>,
max_supported_transaction_version: u8,
) -> kb_core::Result<Self> {
let commitment_value = commitment.into();
if commitment_value != "confirmed" && commitment_value != "finalized" {
return std::result::Result::Err(kb_core::Error::config(
"getTransaction commitment must be 'confirmed' or 'finalized'",
));
}
return std::result::Result::Ok(Self {
commitment: commitment_value,
max_supported_transaction_version,
});
}
/// Creates the default confirmed configuration supporting transaction version zero.
pub fn confirmed_v0() -> Self {
return Self {
commitment: "confirmed".to_string(),
max_supported_transaction_version: 0,
};
}
fn request_params(&self, signature: &str) -> kb_core::Result<std::vec::Vec<serde_json::Value>> {
let signature_result =
crate::validate_transaction_signature_text(signature, "getTransaction signature");
if let std::result::Result::Err(error) = signature_result {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(std::vec![
serde_json::Value::String(signature.to_string()),
serde_json::json!({
"commitment": self.commitment.clone(),
"encoding": "json",
"maxSupportedTransactionVersion": self.max_supported_transaction_version
}),
]);
}
}
/// Source payload and canonical result returned by one `getTransaction` request.
#[derive(Clone, Debug, PartialEq)]
pub struct GetTransactionAcquisition {
/// Source-specific JSON result retained only for immediate metrics and hashing.
pub source_json: serde_json::Value,
/// Canonical source-independent transaction when the RPC result is not null.
pub canonical_transaction: std::option::Option<kb_lib::MdCanonicalTransaction>,
}
/// Adapter from the standard Solana `getTransaction` JSON result to the canonical model.
#[derive(Clone, Copy, Debug, Default)]
pub struct GetTransactionAdapter;
impl crate::CanonicalTransactionAdapter for crate::GetTransactionAdapter {
type Source = serde_json::Value;
fn adapt(
&self,
source: &Self::Source,
) -> kb_core::Result<std::option::Option<kb_lib::MdCanonicalTransaction>> {
return adapt_get_transaction_result(source);
}
}
impl crate::HttpClient {
/// Fetches one source payload and its canonical transaction adaptation.
pub async fn get_transaction_acquisition(
&self,
signature: &str,
config: &crate::GetTransactionConfig,
) -> kb_core::Result<crate::GetTransactionAcquisition> {
let params_result = config.request_params(signature);
let params = match params_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let result = self.execute_json_rpc_result_raw("getTransaction".to_string(), params).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let adapter = crate::GetTransactionAdapter;
let canonical_result = crate::CanonicalTransactionAdapter::adapt(&adapter, &value);
let canonical_transaction = match canonical_result {
std::result::Result::Ok(canonical) => canonical,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(crate::GetTransactionAcquisition {
source_json: value,
canonical_transaction,
});
}
/// Fetches and adapts one standard Solana transaction into the canonical model.
pub async fn get_transaction_canonical(
&self,
signature: &str,
config: &crate::GetTransactionConfig,
) -> kb_core::Result<std::option::Option<kb_lib::MdCanonicalTransaction>> {
let acquisition_result = self.get_transaction_acquisition(signature, config).await;
let acquisition = match acquisition_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(acquisition.canonical_transaction);
}
}
/// Adapts a raw standard Solana `getTransaction` result value.
pub fn adapt_get_transaction_result(
source: &serde_json::Value,
) -> kb_core::Result<std::option::Option<kb_lib::MdCanonicalTransaction>> {
if source.is_null() {
return std::result::Result::Ok(std::option::Option::None);
}
let parse_result = serde_json::from_value::<RpcGetTransactionResult>(source.clone());
let parsed = match parse_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(kb_core::Error::json(format!(
"cannot parse getTransaction result: {error}"
)));
},
};
let canonical_result = adapt_parsed_transaction(parsed);
return match canonical_result {
std::result::Result::Ok(transaction) => {
std::result::Result::Ok(std::option::Option::Some(transaction))
},
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RpcGetTransactionResult {
slot: u64,
#[serde(default)]
block_time: std::option::Option<i64>,
#[serde(default)]
meta: std::option::Option<RpcTransactionMeta>,
transaction: RpcJsonTransaction,
#[serde(default)]
version: std::option::Option<RpcTransactionVersion>,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(untagged)]
enum RpcTransactionVersion {
Legacy(std::string::String),
Number(u8),
}
#[derive(Clone, Debug, serde::Deserialize)]
struct RpcJsonTransaction {
signatures: std::vec::Vec<std::string::String>,
message: RpcRawMessage,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RpcRawMessage {
header: RpcMessageHeader,
account_keys: std::vec::Vec<std::string::String>,
recent_blockhash: std::string::String,
instructions: std::vec::Vec<RpcCompiledInstruction>,
#[serde(default)]
address_table_lookups: std::vec::Vec<RpcAddressTableLookup>,
}
#[derive(Clone, Copy, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RpcMessageHeader {
num_required_signatures: u8,
num_readonly_signed_accounts: u8,
num_readonly_unsigned_accounts: u8,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RpcCompiledInstruction {
program_id_index: u16,
accounts: std::vec::Vec<u16>,
data: std::string::String,
#[serde(default)]
stack_height: std::option::Option<u32>,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RpcAddressTableLookup {
account_key: std::string::String,
writable_indexes: std::vec::Vec<u8>,
readonly_indexes: std::vec::Vec<u8>,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RpcTransactionMeta {
#[serde(default)]
err: std::option::Option<serde_json::Value>,
fee: u64,
pre_balances: std::vec::Vec<u64>,
post_balances: std::vec::Vec<u64>,
#[serde(default)]
inner_instructions: std::option::Option<std::vec::Vec<RpcInnerInstructionGroup>>,
#[serde(default)]
log_messages: std::option::Option<std::vec::Vec<std::string::String>>,
#[serde(default)]
pre_token_balances: std::option::Option<std::vec::Vec<RpcTokenBalance>>,
#[serde(default)]
post_token_balances: std::option::Option<std::vec::Vec<RpcTokenBalance>>,
#[serde(default)]
rewards: std::option::Option<std::vec::Vec<RpcReward>>,
#[serde(default)]
loaded_addresses: std::option::Option<RpcLoadedAddresses>,
#[serde(default)]
return_data: std::option::Option<RpcReturnData>,
#[serde(default)]
compute_units_consumed: std::option::Option<u64>,
#[serde(default)]
cost_units: std::option::Option<u64>,
}
#[derive(Clone, Debug, serde::Deserialize)]
struct RpcInnerInstructionGroup {
index: u16,
instructions: std::vec::Vec<RpcCompiledInstruction>,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RpcTokenBalance {
account_index: u16,
mint: std::string::String,
#[serde(default)]
owner: std::option::Option<std::string::String>,
#[serde(default)]
program_id: std::option::Option<std::string::String>,
ui_token_amount: RpcUiTokenAmount,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RpcUiTokenAmount {
amount: std::string::String,
decimals: u8,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RpcReward {
pubkey: std::string::String,
lamports: i64,
post_balance: u64,
#[serde(default)]
reward_type: std::option::Option<std::string::String>,
#[serde(default)]
commission: std::option::Option<u8>,
}
#[derive(Clone, Debug, Default, serde::Deserialize)]
struct RpcLoadedAddresses {
#[serde(default)]
writable: std::vec::Vec<std::string::String>,
#[serde(default)]
readonly: std::vec::Vec<std::string::String>,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RpcReturnData {
program_id: std::string::String,
data: std::vec::Vec<std::string::String>,
}
fn adapt_parsed_transaction(
parsed: RpcGetTransactionResult,
) -> kb_core::Result<kb_lib::MdCanonicalTransaction> {
let primary_signature = match parsed.transaction.signatures.first() {
std::option::Option::Some(value) => value.clone(),
std::option::Option::None => {
return std::result::Result::Err(kb_core::Error::json(
"getTransaction result contains no signatures",
));
},
};
let version_result = adapt_version(parsed.version);
let version = match version_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let loaded_addresses = match &parsed.meta {
std::option::Option::Some(metadata) => {
adapt_loaded_addresses(metadata.loaded_addresses.clone())
},
std::option::Option::None => kb_lib::MdCanonicalLoadedAddresses::default(),
};
let instructions_result = adapt_instructions(parsed.transaction.message.instructions);
let instructions = match instructions_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let metadata_result = adapt_metadata(parsed.meta);
let metadata = match metadata_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let transaction = kb_lib::MdCanonicalTransaction {
format_version: kb_lib::MD_CANONICAL_TRANSACTION_FORMAT_VERSION,
primary_signature,
slot: parsed.slot,
block_time: parsed.block_time,
version,
signatures: parsed.transaction.signatures,
message: kb_lib::MdCanonicalTransactionMessage {
header: kb_lib::MdCanonicalMessageHeader {
num_required_signatures: parsed.transaction.message.header.num_required_signatures,
num_readonly_signed_accounts: parsed
.transaction
.message
.header
.num_readonly_signed_accounts,
num_readonly_unsigned_accounts: parsed
.transaction
.message
.header
.num_readonly_unsigned_accounts,
},
static_account_keys: parsed.transaction.message.account_keys,
recent_blockhash: parsed.transaction.message.recent_blockhash,
instructions,
address_table_lookups: parsed
.transaction
.message
.address_table_lookups
.into_iter()
.map(|lookup| {
return kb_lib::MdCanonicalAddressTableLookup {
account_key: lookup.account_key,
writable_indexes: lookup.writable_indexes,
readonly_indexes: lookup.readonly_indexes,
};
})
.collect(),
loaded_addresses,
},
metadata,
};
let validation_result = transaction.validate();
if let std::result::Result::Err(error) = validation_result {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(transaction);
}
fn adapt_version(
version: std::option::Option<RpcTransactionVersion>,
) -> kb_core::Result<kb_lib::MdCanonicalTransactionVersion> {
return match version {
std::option::Option::Some(RpcTransactionVersion::Legacy(value)) => {
if value != "legacy" {
return std::result::Result::Err(kb_core::Error::json(format!(
"unsupported textual transaction version: {value}"
)));
}
std::result::Result::Ok(kb_lib::MdCanonicalTransactionVersion::Legacy)
},
std::option::Option::Some(RpcTransactionVersion::Number(value)) => {
std::result::Result::Ok(kb_lib::MdCanonicalTransactionVersion::Number(value))
},
std::option::Option::None => {
std::result::Result::Ok(kb_lib::MdCanonicalTransactionVersion::Legacy)
},
};
}
fn adapt_instructions(
instructions: std::vec::Vec<RpcCompiledInstruction>,
) -> kb_core::Result<std::vec::Vec<kb_lib::MdCanonicalCompiledInstruction>> {
let mut adapted = std::vec::Vec::with_capacity(instructions.len());
for instruction in instructions {
let instruction_result = adapt_instruction(instruction);
let canonical = match instruction_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
adapted.push(canonical);
}
return std::result::Result::Ok(adapted);
}
fn adapt_instruction(
instruction: RpcCompiledInstruction,
) -> kb_core::Result<kb_lib::MdCanonicalCompiledInstruction> {
let decode_result = bs58::decode(instruction.data.as_str()).into_vec();
let bytes = match decode_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(kb_core::Error::json(format!(
"getTransaction instruction data is not valid base58: {error}"
)));
},
};
let data_base64 = base64::engine::general_purpose::STANDARD.encode(bytes);
return std::result::Result::Ok(kb_lib::MdCanonicalCompiledInstruction {
program_id_index: instruction.program_id_index,
account_indexes: instruction.accounts,
data_base64,
stack_height: instruction.stack_height,
});
}
fn adapt_loaded_addresses(
loaded_addresses: std::option::Option<RpcLoadedAddresses>,
) -> kb_lib::MdCanonicalLoadedAddresses {
return match loaded_addresses {
std::option::Option::Some(value) => kb_lib::MdCanonicalLoadedAddresses {
writable: value.writable,
readonly: value.readonly,
},
std::option::Option::None => kb_lib::MdCanonicalLoadedAddresses::default(),
};
}
fn adapt_metadata(
metadata: std::option::Option<RpcTransactionMeta>,
) -> kb_core::Result<std::option::Option<kb_lib::MdCanonicalTransactionMetadata>> {
let source = match metadata {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
};
let inner_result = adapt_inner_instructions(source.inner_instructions.unwrap_or_default());
let inner_instructions = match inner_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let return_result = adapt_return_data(source.return_data);
let return_data = match return_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let pre_token_result = adapt_token_balances(source.pre_token_balances.unwrap_or_default());
let pre_token_balances = match pre_token_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let post_token_result = adapt_token_balances(source.post_token_balances.unwrap_or_default());
let post_token_balances = match post_token_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let status = if source.err.is_some() {
kb_lib::MdCanonicalTransactionStatus::Failed
} else {
kb_lib::MdCanonicalTransactionStatus::Success
};
return std::result::Result::Ok(std::option::Option::Some(
kb_lib::MdCanonicalTransactionMetadata {
status,
error: source.err,
fee: source.fee,
pre_balances: source.pre_balances,
post_balances: source.post_balances,
inner_instructions,
log_messages: source.log_messages.unwrap_or_default(),
pre_token_balances,
post_token_balances,
rewards: source
.rewards
.unwrap_or_default()
.into_iter()
.map(|reward| {
return kb_lib::MdCanonicalReward {
pubkey: reward.pubkey,
lamports: reward.lamports,
post_balance: reward.post_balance,
reward_type: reward.reward_type,
commission: reward.commission,
};
})
.collect(),
return_data,
compute_units_consumed: source.compute_units_consumed,
cost_units: source.cost_units,
},
));
}
fn adapt_inner_instructions(
groups: std::vec::Vec<RpcInnerInstructionGroup>,
) -> kb_core::Result<std::vec::Vec<kb_lib::MdCanonicalInnerInstructionGroup>> {
let mut adapted = std::vec::Vec::with_capacity(groups.len());
for group in groups {
let instructions_result = adapt_instructions(group.instructions);
let instructions = match instructions_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
adapted.push(kb_lib::MdCanonicalInnerInstructionGroup {
parent_instruction_index: group.index,
instructions,
});
}
return std::result::Result::Ok(adapted);
}
fn adapt_token_balances(
balances: std::vec::Vec<RpcTokenBalance>,
) -> kb_core::Result<std::vec::Vec<kb_lib::MdCanonicalTokenBalance>> {
let mut adapted = std::vec::Vec::with_capacity(balances.len());
for balance in balances {
let balance_result = kb_lib::MdCanonicalTokenBalance::new(
balance.account_index,
balance.mint,
balance.owner,
balance.program_id,
balance.ui_token_amount.amount,
balance.ui_token_amount.decimals,
);
let canonical = match balance_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
adapted.push(canonical);
}
return std::result::Result::Ok(adapted);
}
fn adapt_return_data(
return_data: std::option::Option<RpcReturnData>,
) -> kb_core::Result<std::option::Option<kb_lib::MdCanonicalReturnData>> {
let source = match return_data {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
};
if source.data.len() != 2 {
return std::result::Result::Err(kb_core::Error::json(
"getTransaction return data must contain payload and encoding",
));
}
if source.data[1] != "base64" {
return std::result::Result::Err(kb_core::Error::json(format!(
"unsupported getTransaction return data encoding: {}",
source.data[1]
)));
}
return std::result::Result::Ok(std::option::Option::Some(kb_lib::MdCanonicalReturnData {
program_id: source.program_id,
data_base64: source.data[0].clone(),
}));
}
#[cfg(test)]
mod tests {
fn fixture(name: &str) -> serde_json::Value {
let text = match name {
"legacy_success" => {
include_str!("../tests/fixtures/get_transaction_legacy_success.json")
},
"legacy_failure" => {
include_str!("../tests/fixtures/get_transaction_legacy_failure.json")
},
"v0_alt" => {
include_str!("../tests/fixtures/get_transaction_v0_alt_cpi_token_2022.json")
},
"optional_absent" => {
include_str!("../tests/fixtures/get_transaction_optional_absent.json")
},
"equivalent_a" => include_str!("../tests/fixtures/get_transaction_equivalent_a.json"),
"equivalent_b" => include_str!("../tests/fixtures/get_transaction_equivalent_b.json"),
_ => panic!("unknown fixture: {name}"),
};
let parse_result = serde_json::from_str(text);
match parse_result {
std::result::Result::Ok(value) => return value,
std::result::Result::Err(error) => panic!("fixture parse failed: {error}"),
}
}
fn adapt_fixture(name: &str) -> kb_lib::MdCanonicalTransaction {
let value = fixture(name);
let result = crate::adapt_get_transaction_result(&value);
match result {
std::result::Result::Ok(std::option::Option::Some(transaction)) => return transaction,
std::result::Result::Ok(std::option::Option::None) => {
panic!("fixture unexpectedly adapted to none");
},
std::result::Result::Err(error) => panic!("fixture adaptation failed: {error}"),
}
}
#[test]
fn null_result_adapts_to_none() {
let result = crate::adapt_get_transaction_result(&serde_json::Value::Null);
assert_eq!(result, std::result::Result::Ok(std::option::Option::None));
}
#[test]
fn legacy_success_preserves_token_balances_and_return_data() {
let transaction = adapt_fixture("legacy_success");
assert_eq!(transaction.version, kb_lib::MdCanonicalTransactionVersion::Legacy);
assert_eq!(transaction.block_time, std::option::Option::Some(1_700_000_001));
let metadata = match transaction.metadata {
std::option::Option::Some(value) => value,
std::option::Option::None => panic!("metadata missing"),
};
assert_eq!(metadata.status, kb_lib::MdCanonicalTransactionStatus::Success);
assert_eq!(transaction.message.instructions[0].data_base64, "AQ==");
assert_eq!(metadata.post_token_balances.len(), 1);
assert_eq!(metadata.post_token_balances[0].amount, "12345678901234567890");
assert_eq!(metadata.post_token_balances[0].decimal_amount, "12345678901.234567890");
assert!(metadata.return_data.is_some());
assert_eq!(metadata.inner_instructions.len(), 1);
}
#[test]
fn legacy_failure_preserves_error() {
let transaction = adapt_fixture("legacy_failure");
let metadata = match transaction.metadata {
std::option::Option::Some(value) => value,
std::option::Option::None => panic!("metadata missing"),
};
assert_eq!(metadata.status, kb_lib::MdCanonicalTransactionStatus::Failed);
assert!(metadata.error.is_some());
}
#[test]
fn version_zero_preserves_alt_cpi_and_token_2022() {
let transaction = adapt_fixture("v0_alt");
assert_eq!(transaction.version, kb_lib::MdCanonicalTransactionVersion::Number(0));
assert_eq!(transaction.message.address_table_lookups.len(), 1);
assert_eq!(transaction.message.loaded_addresses.writable.len(), 1);
let metadata = match transaction.metadata {
std::option::Option::Some(value) => value,
std::option::Option::None => panic!("metadata missing"),
};
assert_eq!(metadata.inner_instructions.len(), 1);
assert_eq!(
metadata.post_token_balances[0].program_id.as_deref(),
std::option::Option::Some("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb")
);
}
#[test]
fn optional_fields_and_block_time_can_be_absent() {
let transaction = adapt_fixture("optional_absent");
assert_eq!(transaction.block_time, std::option::Option::None);
let metadata = match transaction.metadata {
std::option::Option::Some(value) => value,
std::option::Option::None => panic!("metadata missing"),
};
assert!(metadata.log_messages.is_empty());
assert!(metadata.rewards.is_empty());
assert!(metadata.return_data.is_none());
}
#[test]
fn equivalent_provider_shapes_produce_identical_hashes() {
let first = adapt_fixture("equivalent_a");
let second = adapt_fixture("equivalent_b");
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);
}
#[test]
fn request_config_rejects_public_key_length_signature() {
let config = crate::GetTransactionConfig::confirmed_v0();
let result = config.request_params("3Bxs4NN8M2Yn4TLb7gR6Xy7n2D1Q8VjQqWcnpX8C6pQw");
assert!(result.is_err());
}
#[test]
fn request_config_rejects_processed_commitment() {
let result = crate::GetTransactionConfig::new("processed", 0);
assert!(result.is_err());
}
#[test]
fn request_config_uses_json_encoding_and_version_zero() {
let config = crate::GetTransactionConfig::confirmed_v0();
let params_result = config.request_params(
"2Ana1pUpv2ZbMVkwF5FXapYeBEjdxDatLn7nvJkhgTSXbs59SyZSx866bXirPgj8QQVB57uxHJBG1YFvkRbFj4T",
);
let params = match params_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("request params failed: {error}"),
};
assert_eq!(params[1]["encoding"], serde_json::Value::String("json".to_string()));
assert_eq!(params[1]["maxSupportedTransactionVersion"], serde_json::Value::from(0));
}
}

View File

@@ -1,5 +1,5 @@
// file: kb-onchain-transport/src/lib.rs
// version: 6
// version: 7
#![forbid(unsafe_code)]
#![deny(unreachable_pub)]
@@ -11,6 +11,8 @@ mod client;
mod constants;
mod endpoint_role;
mod execution_rpc;
mod get_signatures_for_address;
mod get_transaction;
mod http_client;
mod http_pool;
mod json_rpc;
@@ -124,6 +126,22 @@ pub use self::execution_rpc::adapt_send_transaction_result;
pub use self::execution_rpc::adapt_simulate_transaction_result;
/// Classifies a genesis hash as an official public cluster.
pub use self::execution_rpc::classify_genesis_hash;
/// One signature summary returned by `getSignaturesForAddress`.
pub use self::get_signatures_for_address::AddressSignatureInfo;
/// Configuration for `getSignaturesForAddress`.
pub use self::get_signatures_for_address::GetSignaturesForAddressConfig;
/// Adapts a raw `getSignaturesForAddress` result.
pub use self::get_signatures_for_address::adapt_get_signatures_for_address_result;
/// Common adapter contract for transport-specific transaction payloads.
pub use self::get_transaction::CanonicalTransactionAdapter;
/// Source payload and canonical result returned by `getTransaction`.
pub use self::get_transaction::GetTransactionAcquisition;
/// Standard Solana `getTransaction` adapter.
pub use self::get_transaction::GetTransactionAdapter;
/// Configuration for `getTransaction`.
pub use self::get_transaction::GetTransactionConfig;
/// Adapts a raw standard Solana `getTransaction` result.
pub use self::get_transaction::adapt_get_transaction_result;
/// HTTP JSON-RPC client bound to one endpoint.
pub use self::http_client::HttpClient;
/// HTTP method class used for routing diagnostics.

View File

@@ -0,0 +1,39 @@
{
"slot": 104,
"blockTime": null,
"version": "legacy",
"transaction": {
"signatures": ["6psR5zDjAeh7HyLceXFQkWTJeRxjor8asYrG1AeUrrKVNXGE7DkvDqvVo98R9rPPQKHGm6TW3PBMSLrw2fHfxaX"],
"message": {
"header": {
"numRequiredSignatures": 1,
"numReadonlySignedAccounts": 0,
"numReadonlyUnsignedAccounts": 1
},
"accountKeys": [
"11111111111111111111111111111111",
"ComputeBudget111111111111111111111111111111"
],
"recentBlockhash": "11111111111111111111111111111111",
"instructions": [
{"programIdIndex": 1, "accounts": [0], "data": "8", "stackHeight": null}
],
"addressTableLookups": []
}
},
"meta": {
"err": null,
"fee": 5000,
"preBalances": [10000, 1],
"postBalances": [5000, 1],
"innerInstructions": null,
"logMessages": null,
"preTokenBalances": null,
"postTokenBalances": null,
"rewards": null,
"loadedAddresses": null,
"returnData": null,
"computeUnitsConsumed": null,
"costUnits": null
}
}

View File

@@ -0,0 +1,30 @@
{
"meta": {
"postBalances": [5000, 1],
"fee": 5000,
"status": {"Ok": null},
"err": null,
"preBalances": [10000, 1]
},
"transaction": {
"message": {
"instructions": [
{"data": "8", "accounts": [0], "programIdIndex": 1}
],
"recentBlockhash": "11111111111111111111111111111111",
"accountKeys": [
"11111111111111111111111111111111",
"ComputeBudget111111111111111111111111111111"
],
"header": {
"numReadonlyUnsignedAccounts": 1,
"numReadonlySignedAccounts": 0,
"numRequiredSignatures": 1
}
},
"signatures": ["6psR5zDjAeh7HyLceXFQkWTJeRxjor8asYrG1AeUrrKVNXGE7DkvDqvVo98R9rPPQKHGm6TW3PBMSLrw2fHfxaX"]
},
"blockTime": null,
"slot": 104,
"providerSpecificField": "ignored"
}

View File

@@ -0,0 +1,37 @@
{
"blockTime": 1700000002,
"meta": {
"err": {"InstructionError": [0, {"Custom": 6001}]},
"fee": 5000,
"innerInstructions": null,
"loadedAddresses": {"readonly": [], "writable": []},
"logMessages": ["Program 11111111111111111111111111111111 failed: custom program error: 0x1771"],
"postBalances": [5000, 1],
"postTokenBalances": [],
"preBalances": [10000, 1],
"preTokenBalances": [],
"rewards": null,
"status": {"Err": {"InstructionError": [0, {"Custom": 6001}]}}
},
"slot": 101,
"transaction": {
"message": {
"accountKeys": [
"11111111111111111111111111111111",
"ComputeBudget111111111111111111111111111111"
],
"addressTableLookups": [],
"header": {
"numReadonlySignedAccounts": 0,
"numReadonlyUnsignedAccounts": 1,
"numRequiredSignatures": 1
},
"instructions": [
{"accounts": [0], "data": "4", "programIdIndex": 1}
],
"recentBlockhash": "11111111111111111111111111111111"
},
"signatures": ["3LJnHMv3ygbULcerbSFVsk2Jo33Qv8DoyUJQh2E9UZAGYXcv7Y7KGZ3hPw5F5j9C9tBhVcYqipRnNEugKV1rnSU"]
},
"version": "legacy"
}

View File

@@ -0,0 +1,80 @@
{
"slot": 100,
"blockTime": 1700000001,
"version": "legacy",
"transaction": {
"signatures": ["2Ana1pUpv2ZbMVkwF5FXapYeBEjdxDatLn7nvJkhgTSXbs59SyZSx866bXirPgj8QQVB57uxHJBG1YFvkRbFj4T"],
"message": {
"header": {
"numRequiredSignatures": 1,
"numReadonlySignedAccounts": 0,
"numReadonlyUnsignedAccounts": 2
},
"accountKeys": [
"11111111111111111111111111111111",
"ComputeBudget111111111111111111111111111111",
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
],
"recentBlockhash": "11111111111111111111111111111111",
"instructions": [
{
"programIdIndex": 1,
"accounts": [0],
"data": "2",
"stackHeight": 1
}
],
"addressTableLookups": []
}
},
"meta": {
"err": null,
"status": {"Ok": null},
"fee": 5000,
"preBalances": [10000, 1, 1],
"postBalances": [5000, 1, 1],
"innerInstructions": [
{
"index": 0,
"instructions": [
{
"programIdIndex": 2,
"accounts": [0],
"data": "3",
"stackHeight": 2
}
]
}
],
"logMessages": [
"Program ComputeBudget111111111111111111111111111111 invoke [1]",
"Program ComputeBudget111111111111111111111111111111 success"
],
"preTokenBalances": [],
"postTokenBalances": [
{
"accountIndex": 0,
"mint": "So11111111111111111111111111111111111111112",
"owner": "11111111111111111111111111111111",
"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
"uiTokenAmount": {
"amount": "12345678901234567890",
"decimals": 9,
"uiAmount": null,
"uiAmountString": "12345678901.23456789"
}
}
],
"rewards": [],
"loadedAddresses": {
"writable": [],
"readonly": []
},
"returnData": {
"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
"data": ["AQI=", "base64"]
},
"computeUnitsConsumed": 1200,
"costUnits": 1300
}
}

View File

@@ -0,0 +1,28 @@
{
"slot": 103,
"blockTime": null,
"transaction": {
"signatures": ["5fMCpSnW6zfEJrShJAFSTaye2dexqwVfErfeETB34kbkRriTSfD3uQxtzjn2ToyKeqakLbpcbrvq5eDBTbs4uCW"],
"message": {
"header": {
"numRequiredSignatures": 1,
"numReadonlySignedAccounts": 0,
"numReadonlyUnsignedAccounts": 1
},
"accountKeys": [
"11111111111111111111111111111111",
"ComputeBudget111111111111111111111111111111"
],
"recentBlockhash": "11111111111111111111111111111111",
"instructions": [
{"programIdIndex": 1, "accounts": [0], "data": "7"}
]
}
},
"meta": {
"err": null,
"fee": 5000,
"preBalances": [10000, 1],
"postBalances": [5000, 1]
}
}

View File

@@ -0,0 +1,88 @@
{
"slot": 102,
"blockTime": 1700000003,
"version": 0,
"transaction": {
"signatures": ["4VpzYuMH3LdMKjYmwoFUAfVyQqMBt2rjcAV2TjhbGet1VCAgn6fBaz1JCLRdmmZFuMtDv7BjALgJiwZRtYSTqpV"],
"message": {
"header": {
"numRequiredSignatures": 1,
"numReadonlySignedAccounts": 0,
"numReadonlyUnsignedAccounts": 2
},
"accountKeys": [
"11111111111111111111111111111111",
"TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
"ComputeBudget111111111111111111111111111111"
],
"recentBlockhash": "11111111111111111111111111111111",
"instructions": [
{
"programIdIndex": 3,
"accounts": [0, 4],
"data": "5",
"stackHeight": 1
}
],
"addressTableLookups": [
{
"accountKey": "AddressLookupTab1e1111111111111111111111111",
"writableIndexes": [0],
"readonlyIndexes": [1]
}
]
}
},
"meta": {
"err": null,
"fee": 7000,
"preBalances": [100000, 1, 1, 1, 1],
"postBalances": [93000, 1, 1, 1, 1],
"innerInstructions": [
{
"index": 0,
"instructions": [
{
"programIdIndex": 4,
"accounts": [0, 1],
"data": "6",
"stackHeight": 2
}
]
}
],
"logMessages": ["Program log: Token-2022 CPI"],
"preTokenBalances": [
{
"accountIndex": 1,
"mint": "So11111111111111111111111111111111111111112",
"owner": "11111111111111111111111111111111",
"programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
"uiTokenAmount": {
"amount": "10",
"decimals": 0,
"uiAmountString": "10"
}
}
],
"postTokenBalances": [
{
"accountIndex": 1,
"mint": "So11111111111111111111111111111111111111112",
"owner": "11111111111111111111111111111111",
"programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
"uiTokenAmount": {
"amount": "9",
"decimals": 0,
"uiAmountString": "9"
}
}
],
"rewards": [],
"loadedAddresses": {
"writable": ["SysvarRent111111111111111111111111111111111"],
"readonly": ["SysvarC1ock11111111111111111111111111111111"]
},
"computeUnitsConsumed": 9999
}
}