use super::ProofLogAdaptor;
use crate::envelope::PublishEnvelope;
#[cfg(feature = "solana-proof-log-rpc")]
use crate::error::TrazaeoError;
use crate::error::TrazaeoResult;
use crate::onchain::{
commit_publish_proof_log, verify_proof_log_commitment_onchain, PublishProofLogCommitRequest,
};
#[cfg(feature = "solana-proof-log-rpc")]
use crate::proof_log::{verify_proof_log_commitment_linkage, ProofLogReceipt};
use crate::proof_log::{ProofLogCommitment, ProofLogPublishResult};
use crate::solana::{
cluster_name, get_anchor_account_by_pda, get_chain_root, get_transaction, program_id,
LocalAttestorSigner, SolanaClient,
};
use crate::utils::Hash;
#[cfg(feature = "solana-proof-log-rpc")]
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
#[cfg(feature = "solana-proof-log-rpc")]
use ed25519_dalek::{Signer as Ed25519Signer, SigningKey};
#[cfg(feature = "solana-proof-log-rpc")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "solana-proof-log-rpc")]
use serde_json::{json, Value};
use std::fmt;
#[cfg(feature = "solana-proof-log-rpc")]
use std::net::IpAddr;
#[cfg(feature = "solana-proof-log-rpc")]
use std::time::Duration;
#[cfg(feature = "solana-proof-log-rpc")]
use ureq::{Agent, AgentBuilder};
#[cfg(feature = "solana-proof-log-rpc")]
use url::Url;
#[cfg(feature = "solana-proof-log-rpc")]
const PUBLIC_RPC_PROOF_LOG_CONTEXT: &str = "public rpc proof log adaptor";
#[cfg(feature = "solana-proof-log-rpc")]
const PUBLIC_RPC_MEMO_PAYLOAD_TYPE: &str = "trazaeo_proof_log_commitment_v1";
#[derive(Clone)]
pub struct SolanaProofLogAdaptor {
pub client: SolanaClient,
attestor_signing_seed: [u8; 32],
pub attestor_key_ref: String,
}
impl fmt::Debug for SolanaProofLogAdaptor {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("SolanaProofLogAdaptor")
.field("client", &self.client)
.field("attestor_pubkey", &hex::encode(self.attestor_pubkey()))
.field("attestor_signing_seed", &"<redacted>")
.field("attestor_key_ref", &self.attestor_key_ref)
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg(feature = "solana-proof-log-rpc")]
pub struct PublicRpcSolanaProofLogConfig {
pub rpc_url: String,
pub cluster: String,
pub memo_program_id: String,
pub commitment: String,
pub request_airdrop_on_devnet: bool,
}
#[cfg(feature = "solana-proof-log-rpc")]
pub struct PublicRpcSolanaProofLogAdaptor {
pub config: PublicRpcSolanaProofLogConfig,
http_client: Agent,
signer: PublicRpcSolanaSigner,
}
#[cfg(feature = "solana-proof-log-rpc")]
#[derive(Debug, Clone)]
pub struct PublicRpcSolanaSigner {
signing_key: SigningKey,
}
#[cfg(feature = "solana-proof-log-rpc")]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct PublicRpcMemoPayload {
#[serde(rename = "type")]
payload_type: String,
cluster: String,
#[serde(rename = "committed_envelope_hash")]
envelope_hash: String,
#[serde(rename = "committed_checkpoint_hash")]
checkpoint_hash: String,
#[serde(rename = "committed_log_root_hash")]
log_root_hash: String,
}
#[cfg(feature = "solana-proof-log-rpc")]
#[derive(Debug, Clone, PartialEq, Eq)]
struct PublicRpcTransactionProof {
signatures: Vec<String>,
signer_pubkeys: Vec<String>,
program_id: String,
memo_payload: PublicRpcMemoPayload,
}
#[cfg(feature = "solana-proof-log-rpc")]
#[derive(Debug, Clone, PartialEq, Eq)]
struct BuiltMemoTransaction {
signature: String,
bytes: Vec<u8>,
}
#[cfg(feature = "solana-proof-log-rpc")]
impl PublicRpcMemoPayload {
fn from_envelope(cluster: &str, envelope: &PublishEnvelope) -> Self {
Self {
payload_type: PUBLIC_RPC_MEMO_PAYLOAD_TYPE.to_string(),
cluster: cluster.to_string(),
envelope_hash: hex::encode(blake3::hash(&envelope.canonical_signed_bytes()).as_bytes()),
checkpoint_hash: envelope.checkpoint_manifest_hash.clone(),
log_root_hash: envelope.checkpoint_log_root_hash.clone(),
}
}
fn to_commitment(
&self,
entry_id: &str,
committed_at: &str,
attestor_key_ref: &str,
) -> ProofLogCommitment {
ProofLogCommitment {
entry_id: entry_id.to_string(),
envelope_hash: self.envelope_hash.clone(),
checkpoint_hash: self.checkpoint_hash.clone(),
log_root_hash: self.log_root_hash.clone(),
committed_at: committed_at.to_string(),
attestor_key_ref: attestor_key_ref.to_string(),
}
}
fn matches_commitment(&self, cluster: &str, commitment: &ProofLogCommitment) -> bool {
self.cluster == cluster
&& self.envelope_hash == commitment.envelope_hash
&& self.checkpoint_hash == commitment.checkpoint_hash
&& self.log_root_hash == commitment.log_root_hash
}
}
#[cfg(feature = "solana-proof-log-rpc")]
impl PublicRpcSolanaSigner {
pub fn generate() -> TrazaeoResult<Self> {
let mut secret = [0u8; 32];
getrandom::fill(&mut secret).map_err(|e| {
TrazaeoError::external(
PUBLIC_RPC_PROOF_LOG_CONTEXT,
format!("failed to generate Solana signing key: {e}"),
)
})?;
Ok(Self::from_secret_key_bytes(secret))
}
pub fn from_keypair_bytes(bytes: &[u8]) -> TrazaeoResult<Self> {
if bytes.len() != 64 {
return Err(TrazaeoError::invalid_input(
PUBLIC_RPC_PROOF_LOG_CONTEXT,
"Solana keypair JSON must contain 64 bytes",
));
}
let secret: [u8; 32] = bytes[..32].try_into().map_err(|_| {
TrazaeoError::invalid_input(
PUBLIC_RPC_PROOF_LOG_CONTEXT,
"Solana secret key must be exactly 32 bytes",
)
})?;
let public: [u8; 32] = bytes[32..].try_into().map_err(|_| {
TrazaeoError::invalid_input(
PUBLIC_RPC_PROOF_LOG_CONTEXT,
"Solana public key must be exactly 32 bytes",
)
})?;
let signer = Self::from_secret_key_bytes(secret);
if signer.public_key_bytes() != public {
return Err(TrazaeoError::invalid_input(
PUBLIC_RPC_PROOF_LOG_CONTEXT,
"Solana keypair public key does not match secret key",
));
}
Ok(signer)
}
fn from_secret_key_bytes(secret: [u8; 32]) -> Self {
Self {
signing_key: SigningKey::from_bytes(&secret),
}
}
fn public_key_bytes(&self) -> [u8; 32] {
self.signing_key.verifying_key().to_bytes()
}
fn public_key_base58(&self) -> String {
bs58::encode(self.public_key_bytes()).into_string()
}
fn sign(&self, message: &[u8]) -> [u8; 64] {
Ed25519Signer::sign(&self.signing_key, message).to_bytes()
}
}
#[cfg(feature = "solana-proof-log-rpc")]
impl PublicRpcSolanaProofLogAdaptor {
fn external_error(details: impl Into<String>) -> TrazaeoError {
TrazaeoError::external(PUBLIC_RPC_PROOF_LOG_CONTEXT, details)
}
fn invalid_input_error(details: impl Into<String>) -> TrazaeoError {
TrazaeoError::invalid_input(PUBLIC_RPC_PROOF_LOG_CONTEXT, details)
}
fn serialization_error(details: impl Into<String>) -> TrazaeoError {
TrazaeoError::serialization(PUBLIC_RPC_PROOF_LOG_CONTEXT, details)
}
fn json_path<'a>(
value: &'a Value,
path: &[&str],
missing_message: &'static str,
) -> TrazaeoResult<&'a Value> {
let mut current = value;
for key in path {
current = current
.get(*key)
.ok_or_else(|| Self::external_error(missing_message))?;
}
Ok(current)
}
fn json_path_str<'a>(
value: &'a Value,
path: &[&str],
missing_message: &'static str,
) -> TrazaeoResult<&'a str> {
Self::json_path(value, path, missing_message)?
.as_str()
.ok_or_else(|| Self::external_error(missing_message))
}
fn json_path_u64(
value: &Value,
path: &[&str],
missing_message: &'static str,
) -> TrazaeoResult<u64> {
Self::json_path(value, path, missing_message)?
.as_u64()
.ok_or_else(|| Self::external_error(missing_message))
}
fn json_path_array<'a>(
value: &'a Value,
path: &[&str],
missing_message: &'static str,
) -> TrazaeoResult<&'a [Value]> {
Self::json_path(value, path, missing_message)?
.as_array()
.map(Vec::as_slice)
.ok_or_else(|| Self::external_error(missing_message))
}
fn extract_memo_payload_text(&self, instruction: &Value) -> TrazaeoResult<String> {
let parsed = instruction
.get("parsed")
.ok_or_else(|| Self::external_error("memo payload not found in transaction"))?;
match parsed {
Value::String(value) => Ok(value.clone()),
Value::Object(_) => parsed
.get("memo")
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| Self::external_error("memo payload not found in transaction")),
_ => Err(Self::external_error(
"memo payload not found in transaction",
)),
}
}
fn public_rpc_host_for_cluster(cluster: &str) -> Option<&'static str> {
match cluster {
"solana-devnet" => Some("api.devnet.solana.com"),
"solana-mainnet" => Some("api.mainnet-beta.solana.com"),
_ => None,
}
}
fn is_loopback_host(host: &str) -> bool {
matches!(
host.to_ascii_lowercase().as_str(),
"localhost" | "127.0.0.1" | "::1"
)
}
fn is_internal_rpc_host(host: &str) -> bool {
let normalized = host
.trim_matches(|c| c == '[' || c == ']')
.to_ascii_lowercase();
if Self::is_loopback_host(&normalized) || normalized.ends_with(".localhost") {
return true;
}
normalized.parse::<IpAddr>().is_ok_and(|addr| match addr {
IpAddr::V4(addr) => {
addr.is_loopback()
|| addr.is_private()
|| addr.is_link_local()
|| addr.is_multicast()
|| addr.is_unspecified()
}
IpAddr::V6(addr) => {
let first_segment = addr.segments()[0];
addr.is_loopback()
|| (first_segment & 0xfe00) == 0xfc00
|| addr.is_unicast_link_local()
|| addr.is_multicast()
|| addr.is_unspecified()
}
})
}
fn validate_rpc_url(rpc_url: &str, cluster: &str) -> TrazaeoResult<()> {
let expected_public_host = Self::public_rpc_host_for_cluster(cluster).ok_or_else(|| {
Self::invalid_input_error(format!("unsupported Solana cluster: {cluster}"))
})?;
let parsed = Url::parse(rpc_url)
.map_err(|e| Self::invalid_input_error(format!("invalid Solana RPC URL: {e}")))?;
let host = parsed
.host_str()
.ok_or_else(|| Self::invalid_input_error("Solana RPC URL must include a host"))?;
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(Self::invalid_input_error(
"Solana RPC URL must not include embedded credentials",
));
}
if cluster == "solana-devnet" && parsed.scheme() == "http" && Self::is_loopback_host(host) {
return Ok(());
}
if parsed.scheme() != "https" {
return Err(Self::invalid_input_error(
"Solana RPC URL must use https; http is allowed only for loopback solana-devnet",
));
}
if Self::is_internal_rpc_host(host) {
return Err(Self::invalid_input_error(
"Solana RPC URL host must not be loopback, private, or link-local",
));
}
let host = host.to_ascii_lowercase();
let solana_public_hosts = ["api.devnet.solana.com", "api.mainnet-beta.solana.com"];
if solana_public_hosts.contains(&host.as_str()) && host != expected_public_host {
return Err(Self::invalid_input_error(format!(
"Solana RPC URL host does not match cluster: {cluster}"
)));
}
if cluster == "solana-mainnet" && host != expected_public_host {
return Err(Self::invalid_input_error(format!(
"solana-mainnet RPC URL must use the public mainnet endpoint https://{expected_public_host}"
)));
}
Ok(())
}
fn build_config(rpc_url: &str, cluster: &str) -> PublicRpcSolanaProofLogConfig {
PublicRpcSolanaProofLogConfig {
rpc_url: rpc_url.to_string(),
cluster: cluster.to_string(),
memo_program_id: "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr".to_string(),
commitment: "finalized".to_string(),
request_airdrop_on_devnet: cluster == "solana-devnet",
}
}
pub fn with_signer(
rpc_url: &str,
cluster: &str,
signer: PublicRpcSolanaSigner,
) -> TrazaeoResult<Self> {
Self::validate_rpc_url(rpc_url, cluster)?;
let http_client = AgentBuilder::new()
.timeout(Duration::from_secs(30))
.redirects(0)
.build();
Ok(Self {
config: Self::build_config(rpc_url, cluster),
http_client,
signer,
})
}
pub fn new(rpc_url: &str, cluster: &str) -> TrazaeoResult<Self> {
if cluster == "solana-mainnet" {
return Err(Self::invalid_input_error(
"solana-mainnet requires an explicit funded keypair; use from_keypair_path",
));
}
Self::with_signer(rpc_url, cluster, PublicRpcSolanaSigner::generate()?)
}
pub fn from_keypair_path(
rpc_url: &str,
cluster: &str,
keypair_path: &str,
) -> TrazaeoResult<Self> {
let body = std::fs::read_to_string(keypair_path).map_err(|e| {
TrazaeoError::io(
PUBLIC_RPC_PROOF_LOG_CONTEXT,
format!("failed to read solana keypair file '{keypair_path}': {e}"),
)
})?;
let keypair_bytes: Vec<u8> = serde_json::from_str(&body).map_err(|e| {
TrazaeoError::serialization(
PUBLIC_RPC_PROOF_LOG_CONTEXT,
format!("failed to parse solana keypair file '{keypair_path}': {e}"),
)
})?;
let signer = PublicRpcSolanaSigner::from_keypair_bytes(&keypair_bytes)?;
Self::with_signer(rpc_url, cluster, signer)
}
pub fn signer_pubkey(&self) -> String {
self.signer.public_key_base58()
}
pub fn chain_root(&self) -> TrazaeoResult<Hash> {
Err(Self::external_error(
"chain root lookup is unsupported for memo-backed public RPC proof logs",
))
}
pub fn optional_chain_root(&self) -> Option<Hash> {
None
}
fn decode_pubkey(value: &str, field: &str) -> TrazaeoResult<[u8; 32]> {
let bytes = bs58::decode(value)
.into_vec()
.map_err(|e| Self::invalid_input_error(format!("invalid {field}: {e}")))?;
bytes
.as_slice()
.try_into()
.map_err(|_| Self::invalid_input_error(format!("{field} must decode to 32 bytes")))
}
fn rpc_request(&self, method: &str, params: Value) -> TrazaeoResult<Value> {
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
});
let response = self
.http_client
.post(&self.config.rpc_url)
.send_json(request)
.map_err(|e| Self::external_error(format!("failed rpc request: {e}")))?;
let value: Value = response
.into_json()
.map_err(|e| Self::serialization_error(format!("failed to parse rpc response: {e}")))?;
if let Some(error) = value.get("error") {
return Err(Self::external_error(format!("rpc returned error: {error}")));
}
Ok(value)
}
fn ensure_funded(&self) -> TrazaeoResult<()> {
if !self.config.request_airdrop_on_devnet {
return Ok(());
}
let signer_pubkey = self.signer.public_key_base58();
let balance_response = self.rpc_request(
"getBalance",
json!([signer_pubkey, {"commitment": self.config.commitment}]),
)?;
let balance = Self::json_path_u64(
&balance_response,
&["result", "value"],
"getBalance response missing numeric result.value",
)?;
if balance > 0 {
return Ok(());
}
let airdrop_response =
self.rpc_request("requestAirdrop", json!([signer_pubkey, 1_000_000_000u64]))?;
let signature = Self::json_path_str(
&airdrop_response,
&["result"],
"requestAirdrop response missing signature result",
)?;
self.confirm_transaction(signature)?;
Ok(())
}
fn build_memo_payload(&self, payload: &PublicRpcMemoPayload) -> TrazaeoResult<String> {
serde_json::to_string(payload).map_err(|e| {
Self::serialization_error(format!("failed to serialize memo payload: {e}"))
})
}
fn send_memo_transaction(&self, memo_payload: &str) -> TrazaeoResult<ProofLogReceipt> {
self.ensure_funded()?;
let blockhash = self.latest_blockhash()?;
let transaction = self.build_memo_transaction(memo_payload, &blockhash)?;
let transaction_base64 = BASE64.encode(transaction.bytes);
let send_response = self.rpc_request(
"sendTransaction",
json!([
transaction_base64,
{
"encoding": "base64",
"skipPreflight": false,
"preflightCommitment": self.config.commitment,
}
]),
)?;
let signature = Self::json_path_str(
&send_response,
&["result"],
"sendTransaction response missing signature result",
)?;
if signature != transaction.signature {
return Err(Self::external_error(
"sendTransaction returned an unexpected signature",
));
}
self.confirm_transaction(signature)?;
let slot = self.current_slot()?;
Ok(ProofLogReceipt {
entry_id: signature.to_string(),
network: self.config.cluster.clone(),
verifier_ref: self.config.memo_program_id.clone(),
inclusion_height: slot,
finalized: true,
locator: String::new(),
})
}
fn latest_blockhash(&self) -> TrazaeoResult<String> {
let response = self.rpc_request(
"getLatestBlockhash",
json!([{"commitment": self.config.commitment}]),
)?;
Self::json_path_str(
&response,
&["result", "value", "blockhash"],
"getLatestBlockhash response missing result.value.blockhash",
)
.map(str::to_string)
}
fn confirm_transaction(&self, signature: &str) -> TrazaeoResult<()> {
let response = self.rpc_request(
"confirmTransaction",
json!([signature, {"commitment": self.config.commitment}]),
)?;
let confirmed = response
.get("result")
.and_then(|result| result.get("value"))
.and_then(Value::as_bool)
.unwrap_or(false);
if confirmed {
Ok(())
} else {
Err(Self::external_error("transaction was not confirmed"))
}
}
fn current_slot(&self) -> TrazaeoResult<u64> {
let response =
self.rpc_request("getSlot", json!([{"commitment": self.config.commitment}]))?;
Self::json_path_u64(
&response,
&["result"],
"getSlot response missing numeric result",
)
}
fn build_memo_transaction(
&self,
memo_payload: &str,
blockhash: &str,
) -> TrazaeoResult<BuiltMemoTransaction> {
let signer_pubkey = self.signer.public_key_bytes();
let memo_program_pubkey =
Self::decode_pubkey(&self.config.memo_program_id, "memo program id")?;
let blockhash = Self::decode_pubkey(blockhash, "recent blockhash")?;
let mut message = Vec::new();
message.extend_from_slice(&[1, 0, 1]);
Self::push_short_vec_len(&mut message, 2);
message.extend_from_slice(&signer_pubkey);
message.extend_from_slice(&memo_program_pubkey);
message.extend_from_slice(&blockhash);
Self::push_short_vec_len(&mut message, 1);
message.push(1);
Self::push_short_vec_len(&mut message, 0);
Self::push_short_vec_len(&mut message, memo_payload.len());
message.extend_from_slice(memo_payload.as_bytes());
let signature = self.signer.sign(&message);
let mut transaction = Vec::new();
Self::push_short_vec_len(&mut transaction, 1);
transaction.extend_from_slice(&signature);
transaction.extend_from_slice(&message);
Ok(BuiltMemoTransaction {
signature: bs58::encode(signature).into_string(),
bytes: transaction,
})
}
fn push_short_vec_len(out: &mut Vec<u8>, len: usize) {
let mut rem = len;
loop {
let mut elem = (rem & 0x7f) as u8;
rem >>= 7;
if rem == 0 {
out.push(elem);
break;
}
elem |= 0x80;
out.push(elem);
}
}
fn ensure_finalized_commitment(&self) -> TrazaeoResult<()> {
if self.config.commitment == "finalized" {
Ok(())
} else {
Err(Self::external_error(
"verification requires finalized commitment for public RPC proof logs",
))
}
}
fn parse_transaction_proof(
&self,
expected_signature: &str,
value: Value,
) -> TrazaeoResult<PublicRpcTransactionProof> {
let result = Self::json_path(&value, &["result"], "missing transaction result")?;
let transaction = Self::json_path(result, &["transaction"], "missing transaction payload")?;
let signatures = Self::json_path_array(
transaction,
&["signatures"],
"missing transaction signatures",
)?
.iter()
.map(|signature| {
signature
.as_str()
.map(str::to_string)
.ok_or_else(|| Self::serialization_error("transaction signature was not a string"))
})
.collect::<TrazaeoResult<Vec<_>>>()?;
if !signatures
.iter()
.any(|signature| signature == expected_signature)
{
return Err(Self::external_error(
"transaction response did not include the expected signature",
));
}
let message = Self::json_path(transaction, &["message"], "missing transaction message")?;
let signer_pubkeys = Self::json_path_array(
message,
&["accountKeys"],
"missing transaction account keys",
)?
.iter()
.filter(|account| {
account
.get("signer")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.map(|account| {
account
.get("pubkey")
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| {
Self::serialization_error("transaction signer pubkey was not a string")
})
})
.collect::<TrazaeoResult<Vec<_>>>()?;
let instructions = Self::json_path_array(
message,
&["instructions"],
"missing transaction instructions",
)?;
for instruction in instructions {
if instruction.get("programId").and_then(Value::as_str)
== Some(self.config.memo_program_id.as_str())
{
let memo = self.extract_memo_payload_text(instruction)?;
let memo_payload: PublicRpcMemoPayload =
serde_json::from_str(&memo).map_err(|e| {
Self::serialization_error(format!("failed to parse memo payload: {e}"))
})?;
return Ok(PublicRpcTransactionProof {
signatures,
signer_pubkeys,
program_id: self.config.memo_program_id.clone(),
memo_payload,
});
}
}
Err(Self::external_error(
"memo payload not found in transaction",
))
}
fn fetch_transaction_proof(&self, signature: &str) -> TrazaeoResult<PublicRpcTransactionProof> {
let value = self.rpc_request(
"getTransaction",
json!([
signature,
{
"commitment": self.config.commitment,
"maxSupportedTransactionVersion": 0,
"encoding": "jsonParsed"
}
]),
)?;
self.parse_transaction_proof(signature, value)
}
fn verify_payload_contains_commitment(
&self,
commitment: &ProofLogCommitment,
payload: &PublicRpcMemoPayload,
) -> TrazaeoResult<()> {
if payload.payload_type != PUBLIC_RPC_MEMO_PAYLOAD_TYPE {
return Err(Self::external_error("memo payload type mismatch"));
}
if !payload.matches_commitment(&self.config.cluster, commitment) {
return Err(Self::external_error(
"memo payload missing expected proof-log fields",
));
}
Ok(())
}
fn verify_transaction_proof(
&self,
commitment: &ProofLogCommitment,
proof: &PublicRpcTransactionProof,
) -> TrazaeoResult<()> {
self.ensure_finalized_commitment()?;
if proof.program_id != self.config.memo_program_id {
return Err(Self::external_error("transaction program mismatch"));
}
if !proof
.signatures
.iter()
.any(|signature| signature == &commitment.entry_id)
{
return Err(Self::external_error("transaction signature mismatch"));
}
if !proof
.signer_pubkeys
.iter()
.any(|pubkey| pubkey == &commitment.attestor_key_ref)
{
return Err(Self::external_error(
"proof-log attestor key did not sign the transaction",
));
}
self.verify_payload_contains_commitment(commitment, &proof.memo_payload)
}
}
impl SolanaProofLogAdaptor {
pub fn new(
client: SolanaClient,
attestor_signing_seed: [u8; 32],
attestor_key_ref: &str,
) -> Self {
Self {
client,
attestor_signing_seed,
attestor_key_ref: attestor_key_ref.to_string(),
}
}
pub fn attestor_pubkey(&self) -> [u8; 32] {
LocalAttestorSigner::from_seed(self.attestor_signing_seed).attestor_pubkey()
}
pub fn chain_root(&self) -> TrazaeoResult<Hash> {
get_chain_root(&self.client)
}
pub fn cluster(&self) -> String {
cluster_name(&self.client)
}
pub fn program_id(&self) -> String {
program_id(&self.client)
}
pub fn get_transaction(
&self,
signature: &str,
) -> TrazaeoResult<Option<crate::solana::TxResult>> {
get_transaction(&self.client, signature)
}
pub fn get_proof_log_account(
&self,
pda: &str,
) -> TrazaeoResult<Option<crate::solana::AnchorAccountV1>> {
get_anchor_account_by_pda(&self.client, pda)
}
}
impl ProofLogAdaptor for SolanaProofLogAdaptor {
fn log_publish_proof(
&self,
envelope: &PublishEnvelope,
committed_at: &str,
committed_unix_seconds: i64,
prev_entry_hash: [u8; 32],
) -> TrazaeoResult<ProofLogPublishResult> {
commit_publish_proof_log(PublishProofLogCommitRequest {
envelope,
client: &self.client,
attestor_signing_seed: self.attestor_signing_seed,
committed_at,
committed_unix_seconds,
prev_entry_hash,
attestor_key_ref: &self.attestor_key_ref,
})
}
fn verify_publish_proof(
&self,
envelope: &PublishEnvelope,
commitment: &ProofLogCommitment,
) -> TrazaeoResult<()> {
verify_proof_log_commitment_onchain(
envelope,
commitment,
&self.client,
&cluster_name(&self.client),
&program_id(&self.client),
&self.attestor_pubkey(),
)
}
}
#[cfg(feature = "solana-proof-log-rpc")]
impl ProofLogAdaptor for PublicRpcSolanaProofLogAdaptor {
fn log_publish_proof(
&self,
envelope: &PublishEnvelope,
committed_at: &str,
_committed_unix_seconds: i64,
_prev_entry_hash: [u8; 32],
) -> TrazaeoResult<ProofLogPublishResult> {
let payload = PublicRpcMemoPayload::from_envelope(&self.config.cluster, envelope);
let memo_payload = self.build_memo_payload(&payload)?;
let receipt = self.send_memo_transaction(&memo_payload)?;
let commitment = payload.to_commitment(
&receipt.entry_id,
committed_at,
&self.signer.public_key_base58(),
);
Ok(ProofLogPublishResult {
commitment,
receipt,
})
}
fn verify_publish_proof(
&self,
envelope: &PublishEnvelope,
commitment: &ProofLogCommitment,
) -> TrazaeoResult<()> {
verify_proof_log_commitment_linkage(envelope, commitment).map_err(|_| {
PublicRpcSolanaProofLogAdaptor::external_error(
"proof-log linkage mismatch against envelope",
)
})?;
let proof = self.fetch_transaction_proof(&commitment.entry_id)?;
self.verify_transaction_proof(commitment, &proof)
}
}
#[cfg(all(test, feature = "solana-proof-log-rpc"))]
mod public_rpc_tests {
use super::*;
use serde_json::json;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
use std::time::Duration as StdDuration;
use tempfile::NamedTempFile;
fn test_signer() -> PublicRpcSolanaSigner {
PublicRpcSolanaSigner::from_secret_key_bytes([7; 32])
}
fn keypair_bytes_for(signer: &PublicRpcSolanaSigner) -> Vec<u8> {
let mut bytes = signer.signing_key.to_bytes().to_vec();
bytes.extend_from_slice(&signer.public_key_bytes());
bytes
}
fn proof_log_commitment(signature: &str, attestor_key_ref: &str) -> ProofLogCommitment {
ProofLogCommitment {
entry_id: signature.to_string(),
envelope_hash: "0909090909090909090909090909090909090909090909090909090909090909"
.to_string(),
checkpoint_hash: "0808080808080808080808080808080808080808080808080808080808080808"
.to_string(),
log_root_hash: "0707070707070707070707070707070707070707070707070707070707070707"
.to_string(),
committed_at: "2026-01-01T00:00:00Z".to_string(),
attestor_key_ref: attestor_key_ref.to_string(),
}
}
fn rpc_transaction_value(
signature: &str,
signer_pubkey: &str,
memo_program_id: &str,
cluster: &str,
anchor: &ProofLogCommitment,
) -> serde_json::Value {
json!({
"result": {
"transaction": {
"signatures": [signature],
"message": {
"accountKeys": [
{
"pubkey": signer_pubkey,
"signer": true
}
],
"instructions": [
{
"programId": memo_program_id,
"parsed": {
"memo": json!({
"type": PUBLIC_RPC_MEMO_PAYLOAD_TYPE,
"cluster": cluster,
"committed_envelope_hash": anchor.envelope_hash,
"committed_checkpoint_hash": anchor.checkpoint_hash,
"committed_log_root_hash": anchor.log_root_hash,
})
.to_string()
}
}
]
}
}
}
})
}
fn rpc_transaction_value_with_string_parsed_memo(
signature: &str,
signer_pubkey: &str,
memo_program_id: &str,
cluster: &str,
anchor: &ProofLogCommitment,
) -> serde_json::Value {
json!({
"result": {
"transaction": {
"signatures": [signature],
"message": {
"accountKeys": [
{
"pubkey": signer_pubkey,
"signer": true
}
],
"instructions": [
{
"program": "spl-memo",
"programId": memo_program_id,
"parsed": json!({
"type": PUBLIC_RPC_MEMO_PAYLOAD_TYPE,
"cluster": cluster,
"committed_envelope_hash": anchor.envelope_hash,
"committed_checkpoint_hash": anchor.checkpoint_hash,
"committed_log_root_hash": anchor.log_root_hash,
})
.to_string()
}
]
}
}
}
})
}
#[test]
fn public_rpc_verification_accepts_matching_finalized_transaction() {
let adaptor = PublicRpcSolanaProofLogAdaptor::new("http://localhost:8899", "solana-devnet")
.expect("create devnet adaptor");
let anchor = proof_log_commitment("tx-1", "attestor-1");
let proof = adaptor
.parse_transaction_proof(
&anchor.entry_id,
rpc_transaction_value(
&anchor.entry_id,
&anchor.attestor_key_ref,
&adaptor.config.memo_program_id,
&adaptor.config.cluster,
&anchor,
),
)
.expect("parse transaction proof");
assert!(adaptor.verify_transaction_proof(&anchor, &proof).is_ok());
}
#[test]
fn public_rpc_verification_accepts_string_parsed_memo_transaction() {
let adaptor = PublicRpcSolanaProofLogAdaptor::new("http://localhost:8899", "solana-devnet")
.expect("create devnet adaptor");
let anchor = proof_log_commitment("tx-1", "attestor-1");
let proof = adaptor
.parse_transaction_proof(
&anchor.entry_id,
rpc_transaction_value_with_string_parsed_memo(
&anchor.entry_id,
&anchor.attestor_key_ref,
&adaptor.config.memo_program_id,
&adaptor.config.cluster,
&anchor,
),
)
.expect("parse transaction proof");
assert!(adaptor.verify_transaction_proof(&anchor, &proof).is_ok());
}
#[test]
fn public_rpc_verification_rejects_non_finalized_commitment() {
let mut adaptor =
PublicRpcSolanaProofLogAdaptor::new("http://localhost:8899", "solana-devnet")
.expect("create devnet adaptor");
adaptor.config.commitment = "confirmed".to_string();
let anchor = proof_log_commitment("tx-1", "attestor-1");
let proof = adaptor
.parse_transaction_proof(
&anchor.entry_id,
rpc_transaction_value(
&anchor.entry_id,
&anchor.attestor_key_ref,
&adaptor.config.memo_program_id,
&adaptor.config.cluster,
&anchor,
),
)
.expect("parse transaction proof");
let err = adaptor
.verify_transaction_proof(&anchor, &proof)
.expect_err("confirmed commitment must be rejected");
assert!(err.to_string().contains("finalized"));
}
#[test]
fn public_rpc_verification_rejects_signer_mismatch() {
let adaptor = PublicRpcSolanaProofLogAdaptor::new("http://localhost:8899", "solana-devnet")
.expect("create devnet adaptor");
let anchor = proof_log_commitment("tx-1", "attestor-1");
let proof = adaptor
.parse_transaction_proof(
&anchor.entry_id,
rpc_transaction_value(
&anchor.entry_id,
"different-attestor",
&adaptor.config.memo_program_id,
&adaptor.config.cluster,
&anchor,
),
)
.expect("parse transaction proof");
let err = adaptor
.verify_transaction_proof(&anchor, &proof)
.expect_err("unexpected signer must fail verification");
assert!(err.to_string().contains("did not sign"));
}
#[test]
fn public_rpc_mainnet_requires_explicit_keypair() {
let err = match PublicRpcSolanaProofLogAdaptor::new(
"https://api.mainnet-beta.solana.com",
"solana-mainnet",
) {
Ok(_) => panic!("mainnet must reject ephemeral signer"),
Err(err) => err,
};
assert!(err
.to_string()
.contains("requires an explicit funded keypair"));
}
#[test]
fn public_rpc_rejects_insecure_remote_url() {
let err = match PublicRpcSolanaProofLogAdaptor::with_signer(
"http://rpc.example",
"solana-devnet",
test_signer(),
) {
Ok(_) => panic!("remote http rpc urls must be rejected"),
Err(err) => err,
};
assert!(err.to_string().contains("must use https"));
}
#[test]
fn public_rpc_rejects_internal_https_url() {
let err = match PublicRpcSolanaProofLogAdaptor::with_signer(
"https://169.254.169.254/rpc",
"solana-devnet",
test_signer(),
) {
Ok(_) => panic!("internal rpc urls must be rejected"),
Err(err) => err,
};
assert!(err.to_string().contains("loopback, private, or link-local"));
}
#[test]
fn public_rpc_rejects_mainnet_non_public_endpoint() {
let err = match PublicRpcSolanaProofLogAdaptor::with_signer(
"https://rpc.example",
"solana-mainnet",
test_signer(),
) {
Ok(_) => panic!("mainnet rpc urls must use the public mainnet endpoint"),
Err(err) => err,
};
assert!(err.to_string().contains("public mainnet endpoint"));
let err = match PublicRpcSolanaProofLogAdaptor::with_signer(
"https://api.devnet.solana.com",
"solana-mainnet",
test_signer(),
) {
Ok(_) => panic!("mainnet rpc urls must not use devnet endpoint"),
Err(err) => err,
};
assert!(err.to_string().contains("does not match cluster"));
}
#[test]
fn public_rpc_does_not_follow_redirects() {
let target_listener = TcpListener::bind("127.0.0.1:0").expect("bind redirect target");
let target_addr = target_listener
.local_addr()
.expect("redirect target local addr");
let (target_tx, target_rx) = mpsc::channel();
let target_thread = thread::spawn(move || {
target_listener
.set_nonblocking(true)
.expect("set redirect target nonblocking");
let deadline = std::time::Instant::now() + StdDuration::from_millis(300);
while std::time::Instant::now() < deadline {
match target_listener.accept() {
Ok((mut stream, _)) => {
let _ = target_tx.send(());
let _ = stream.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}",
);
return;
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(StdDuration::from_millis(10));
}
Err(_) => return,
}
}
});
let origin_listener = TcpListener::bind("127.0.0.1:0").expect("bind redirect origin");
let origin_addr = origin_listener.local_addr().expect("origin local addr");
let origin_thread = thread::spawn(move || {
let (mut stream, _) = origin_listener.accept().expect("accept redirect origin");
let mut buffer = [0; 1024];
let _ = stream.read(&mut buffer);
let response = format!(
"HTTP/1.1 302 Found\r\nLocation: http://{target_addr}/internal\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{{}}"
);
stream
.write_all(response.as_bytes())
.expect("write redirect response");
});
let adaptor = PublicRpcSolanaProofLogAdaptor::with_signer(
&format!("http://{origin_addr}/rpc"),
"solana-devnet",
test_signer(),
)
.expect("create adaptor");
let result = adaptor.rpc_request("getBalance", json!([]));
origin_thread.join().expect("origin thread joins");
target_thread.join().expect("target thread joins");
assert!(result.is_ok());
assert!(
target_rx.try_recv().is_err(),
"redirect target must not receive a followed request"
);
}
#[test]
fn public_rpc_builds_signed_memo_transaction_without_solana_sdk() {
let adaptor = PublicRpcSolanaProofLogAdaptor::with_signer(
"http://localhost:8899",
"solana-devnet",
test_signer(),
)
.expect("create adaptor");
let blockhash = bs58::encode([1u8; 32]).into_string();
let transaction = adaptor
.build_memo_transaction("memo-payload", &blockhash)
.expect("build memo transaction");
let signature = bs58::decode(&transaction.signature)
.into_vec()
.expect("signature decodes");
assert_eq!(signature.len(), 64);
assert_eq!(transaction.bytes[0], 1);
assert_eq!(&transaction.bytes[65..68], &[1, 0, 1]);
}
#[test]
fn public_rpc_can_load_signer_from_keypair_path() {
let expected_signer = test_signer();
let file = NamedTempFile::new().expect("temp keypair file");
std::fs::write(
file.path(),
serde_json::to_vec(&keypair_bytes_for(&expected_signer)).expect("serialize keypair"),
)
.expect("write keypair file");
let adaptor = PublicRpcSolanaProofLogAdaptor::from_keypair_path(
"https://api.mainnet-beta.solana.com",
"solana-mainnet",
file.path().to_str().expect("utf-8 path"),
)
.expect("load adaptor from keypair path");
assert_eq!(adaptor.signer_pubkey(), expected_signer.public_key_base58());
}
}