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::{
build_proof_log_commitment, 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,
SolanaClient,
};
use crate::utils::Hash;
#[cfg(feature = "solana-proof-log-rpc")]
use reqwest::blocking::Client;
#[cfg(feature = "solana-proof-log-rpc")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "solana-proof-log-rpc")]
use serde_json::json;
#[cfg(feature = "solana-proof-log-rpc")]
use solana_rpc_client::rpc_client::RpcClient;
#[cfg(feature = "solana-proof-log-rpc")]
use solana_sdk::commitment_config::CommitmentConfig;
#[cfg(feature = "solana-proof-log-rpc")]
use solana_sdk::instruction::Instruction;
#[cfg(feature = "solana-proof-log-rpc")]
use solana_sdk::message::Message;
#[cfg(feature = "solana-proof-log-rpc")]
use solana_sdk::pubkey::Pubkey;
#[cfg(feature = "solana-proof-log-rpc")]
use solana_sdk::signature::{read_keypair_file, Keypair, Signer};
#[cfg(feature = "solana-proof-log-rpc")]
use solana_sdk::transaction::Transaction;
#[cfg(feature = "solana-proof-log-rpc")]
use std::str::FromStr;
#[derive(Debug, Clone)]
pub struct SolanaProofLogAdaptor {
pub client: SolanaClient,
pub attestor_pubkey: [u8; 32],
pub attestor_key_ref: String,
}
#[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,
rpc_client: RpcClient,
http_client: Client,
signer: Keypair,
}
#[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")]
impl PublicRpcSolanaProofLogAdaptor {
fn extract_memo_payload_text(&self, instruction: &serde_json::Value) -> TrazaeoResult<String> {
let parsed = instruction.get("parsed").ok_or_else(|| {
TrazaeoError::external(
"public rpc proof log adaptor",
"memo payload not found in transaction",
)
})?;
match parsed {
serde_json::Value::String(value) => Ok(value.clone()),
serde_json::Value::Object(_) => parsed
.get("memo")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.ok_or_else(|| {
TrazaeoError::external(
"public rpc proof log adaptor",
"memo payload not found in transaction",
)
}),
_ => Err(TrazaeoError::external(
"public rpc proof log adaptor",
"memo payload not found in transaction",
)),
}
}
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: Keypair) -> Self {
let commitment = CommitmentConfig::finalized();
Self {
config: Self::build_config(rpc_url, cluster),
rpc_client: RpcClient::new_with_commitment(rpc_url.to_string(), commitment),
http_client: Client::new(),
signer,
}
}
pub fn new(rpc_url: &str, cluster: &str) -> TrazaeoResult<Self> {
if cluster == "solana-mainnet" {
return Err(TrazaeoError::invalid_input(
"public rpc proof log adaptor",
"solana-mainnet requires an explicit funded keypair; use from_keypair_path",
));
}
Ok(Self::with_signer(rpc_url, cluster, Keypair::new()))
}
pub fn from_keypair_path(
rpc_url: &str,
cluster: &str,
keypair_path: &str,
) -> TrazaeoResult<Self> {
let signer = read_keypair_file(keypair_path).map_err(|e| {
TrazaeoError::io(
"public rpc proof log adaptor",
format!("failed to read solana keypair file '{keypair_path}': {e}"),
)
})?;
Ok(Self::with_signer(rpc_url, cluster, signer))
}
pub fn signer_pubkey(&self) -> String {
self.signer.pubkey().to_string()
}
pub fn chain_root(&self) -> TrazaeoResult<Hash> {
Err(TrazaeoError::external(
"public rpc proof log adaptor",
"chain root lookup is unsupported for memo-backed public RPC proof logs",
))
}
pub fn optional_chain_root(&self) -> Option<Hash> {
None
}
fn memo_program_pubkey(&self) -> TrazaeoResult<Pubkey> {
Pubkey::from_str(&self.config.memo_program_id).map_err(|e| {
TrazaeoError::invalid_input(
"public rpc proof log adaptor",
format!("invalid memo program id: {e}"),
)
})
}
fn ensure_funded(&self) -> TrazaeoResult<()> {
if !self.config.request_airdrop_on_devnet {
return Ok(());
}
let balance = self
.rpc_client
.get_balance(&self.signer.pubkey())
.map_err(|e| {
TrazaeoError::external(
"public rpc proof log adaptor",
format!("failed to get balance: {e}"),
)
})?;
if balance > 0 {
return Ok(());
}
let signature = self
.rpc_client
.request_airdrop(&self.signer.pubkey(), 1_000_000_000)
.map_err(|e| {
TrazaeoError::external(
"public rpc proof log adaptor",
format!("failed to request airdrop: {e}"),
)
})?;
self.rpc_client
.confirm_transaction(&signature)
.map_err(|e| {
TrazaeoError::external(
"public rpc proof log adaptor",
format!("failed to confirm airdrop: {e}"),
)
})?;
Ok(())
}
fn build_memo_payload(&self, envelope: &PublishEnvelope) -> String {
let envelope_hash =
hex::encode(blake3::hash(&envelope.canonical_signed_bytes()).as_bytes());
json!({
"type": "trazaeo_proof_log_commitment_v1",
"cluster": self.config.cluster,
"committed_envelope_hash": envelope_hash,
"committed_checkpoint_hash": envelope.checkpoint_manifest_hash,
"committed_log_root_hash": envelope.checkpoint_log_root_hash,
})
.to_string()
}
fn send_memo_transaction(&self, memo_payload: &str) -> TrazaeoResult<ProofLogReceipt> {
self.ensure_funded()?;
let blockhash = self.rpc_client.get_latest_blockhash().map_err(|e| {
TrazaeoError::external(
"public rpc proof log adaptor",
format!("failed to get latest blockhash: {e}"),
)
})?;
let instruction = Instruction::new_with_bytes(
self.memo_program_pubkey()?,
memo_payload.as_bytes(),
vec![],
);
let message = Message::new(&[instruction], Some(&self.signer.pubkey()));
let tx = Transaction::new(&[&self.signer], message, blockhash);
let signature = self
.rpc_client
.send_and_confirm_transaction(&tx)
.map_err(|e| {
TrazaeoError::external(
"public rpc proof log adaptor",
format!("failed to send memo transaction: {e}"),
)
})?;
let slot = self.rpc_client.get_slot().map_err(|e| {
TrazaeoError::external(
"public rpc proof log adaptor",
format!("failed to get slot: {e}"),
)
})?;
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 ensure_finalized_commitment(&self) -> TrazaeoResult<()> {
if self.config.commitment == "finalized" {
Ok(())
} else {
Err(TrazaeoError::external(
"public rpc proof log adaptor",
"verification requires finalized commitment for public RPC proof logs",
))
}
}
fn parse_transaction_proof(
&self,
expected_signature: &str,
value: serde_json::Value,
) -> TrazaeoResult<PublicRpcTransactionProof> {
let result = value.get("result").ok_or_else(|| {
TrazaeoError::external("public rpc proof log adaptor", "missing transaction result")
})?;
let transaction = result.get("transaction").ok_or_else(|| {
TrazaeoError::external(
"public rpc proof log adaptor",
"missing transaction payload",
)
})?;
let signatures = transaction
.get("signatures")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
TrazaeoError::external(
"public rpc proof log adaptor",
"missing transaction signatures",
)
})?
.iter()
.map(|signature| {
signature.as_str().map(str::to_string).ok_or_else(|| {
TrazaeoError::serialization(
"public rpc proof log adaptor",
"transaction signature was not a string",
)
})
})
.collect::<TrazaeoResult<Vec<_>>>()?;
if !signatures
.iter()
.any(|signature| signature == expected_signature)
{
return Err(TrazaeoError::external(
"public rpc proof log adaptor",
"transaction response did not include the expected signature",
));
}
let message = transaction.get("message").ok_or_else(|| {
TrazaeoError::external(
"public rpc proof log adaptor",
"missing transaction message",
)
})?;
let signer_pubkeys = message
.get("accountKeys")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
TrazaeoError::external(
"public rpc proof log adaptor",
"missing transaction account keys",
)
})?
.iter()
.filter(|account| {
account
.get("signer")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
})
.map(|account| {
account
.get("pubkey")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.ok_or_else(|| {
TrazaeoError::serialization(
"public rpc proof log adaptor",
"transaction signer pubkey was not a string",
)
})
})
.collect::<TrazaeoResult<Vec<_>>>()?;
let instructions = message
.get("instructions")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
TrazaeoError::external(
"public rpc proof log adaptor",
"missing transaction instructions",
)
})?;
for instruction in instructions {
if instruction
.get("programId")
.and_then(serde_json::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| {
TrazaeoError::serialization(
"public rpc proof log adaptor",
format!("failed to parse memo payload: {e}"),
)
})?;
return Ok(PublicRpcTransactionProof {
signatures,
signer_pubkeys,
program_id: self.config.memo_program_id.clone(),
memo_payload,
});
}
}
Err(TrazaeoError::external(
"public rpc proof log adaptor",
"memo payload not found in transaction",
))
}
fn fetch_transaction_proof(&self, signature: &str) -> TrazaeoResult<PublicRpcTransactionProof> {
let response = self
.http_client
.post(&self.config.rpc_url)
.json(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "getTransaction",
"params": [
signature,
{
"commitment": self.config.commitment,
"maxSupportedTransactionVersion": 0,
"encoding": "jsonParsed"
}
]
}))
.send()
.map_err(|e| {
TrazaeoError::external(
"public rpc proof log adaptor",
format!("failed rpc request: {e}"),
)
})?;
let value: serde_json::Value = response.json().map_err(|e| {
TrazaeoError::serialization(
"public rpc proof log adaptor",
format!("failed to parse rpc response: {e}"),
)
})?;
self.parse_transaction_proof(signature, value)
}
fn verify_payload_contains_commitment(
&self,
commitment: &ProofLogCommitment,
payload: &PublicRpcMemoPayload,
) -> TrazaeoResult<()> {
if payload.payload_type != "trazaeo_proof_log_commitment_v1" {
return Err(TrazaeoError::external(
"public rpc proof log adaptor",
"memo payload type mismatch",
));
}
if payload.cluster != self.config.cluster
|| payload.envelope_hash != commitment.envelope_hash
|| payload.checkpoint_hash != commitment.checkpoint_hash
|| payload.log_root_hash != commitment.log_root_hash
{
return Err(TrazaeoError::external(
"public rpc proof log adaptor",
"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(TrazaeoError::external(
"public rpc proof log adaptor",
"transaction program mismatch",
));
}
if !proof
.signatures
.iter()
.any(|signature| signature == &commitment.entry_id)
{
return Err(TrazaeoError::external(
"public rpc proof log adaptor",
"transaction signature mismatch",
));
}
if !proof
.signer_pubkeys
.iter()
.any(|pubkey| pubkey == &commitment.attestor_key_ref)
{
return Err(TrazaeoError::external(
"public rpc proof log adaptor",
"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_pubkey: [u8; 32], attestor_key_ref: &str) -> Self {
Self {
client,
attestor_pubkey,
attestor_key_ref: attestor_key_ref.to_string(),
}
}
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_pubkey: self.attestor_pubkey,
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),
)
}
}
#[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 memo_payload = self.build_memo_payload(envelope);
let receipt = self.send_memo_transaction(&memo_payload)?;
let commitment = build_proof_log_commitment(
envelope,
committed_at,
&receipt.entry_id,
&self.signer.pubkey().to_string(),
);
Ok(ProofLogPublishResult {
commitment,
receipt,
})
}
fn verify_publish_proof(
&self,
envelope: &PublishEnvelope,
commitment: &ProofLogCommitment,
) -> TrazaeoResult<()> {
verify_proof_log_commitment_linkage(envelope, commitment).map_err(|_| {
TrazaeoError::external(
"public rpc proof log adaptor",
"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 tempfile::NamedTempFile;
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": "trazaeo_proof_log_commitment_v1",
"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": "trazaeo_proof_log_commitment_v1",
"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("http://localhost:8899", "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_can_load_signer_from_keypair_path() {
let expected_signer = Keypair::new();
let file = NamedTempFile::new().expect("temp keypair file");
std::fs::write(
file.path(),
serde_json::to_vec(&expected_signer.to_bytes().to_vec()).expect("serialize keypair"),
)
.expect("write keypair file");
let adaptor = PublicRpcSolanaProofLogAdaptor::from_keypair_path(
"http://localhost:8899",
"solana-mainnet",
file.path().to_str().expect("utf-8 path"),
)
.expect("load adaptor from keypair path");
assert_eq!(
adaptor.signer_pubkey(),
expected_signer.pubkey().to_string()
);
}
}