Skip to content

feat!: minimal gRPC endpoint (app) support#10

Open
gbarros wants to merge 2 commits intoserver-refactorfrom
gb/grpc-txclient
Open

feat!: minimal gRPC endpoint (app) support#10
gbarros wants to merge 2 commits intoserver-refactorfrom
gb/grpc-txclient

Conversation

@gbarros
Copy link
Contributor

@gbarros gbarros commented Feb 25, 2026

This pull request introduces a new gRPC server alongside the existing JSON-RPC server, providing Celestia-compatible gRPC endpoints for node info, authentication, transaction broadcasting, gas estimation, and transaction status. The implementation uses the tonic library and adds several new service modules, each handling a specific gRPC API. The main entrypoint (main.rs) is updated to launch both servers and parse their respective listen addresses.

New gRPC server and services:

  • Added a new gRPC server entrypoint in src/grpc/mod.rs, which wires up five gRPC services: node info, authentication, transaction broadcasting, transaction status, and gas estimation. (src/grpc/mod.rs)
  • Implemented gRPC service modules for:
    • Node info: Handles GetNodeInfo requests for client initialization. (src/grpc/node_info.rs)
    • Authentication: Handles account queries for sequence/account_number. (src/grpc/auth.rs)
    • Transaction broadcasting: Decodes and stores blob transactions, returns tx hash and height. (src/grpc/tx_service.rs)
    • Transaction status: Looks up stored height for a tx and returns status. (src/grpc/tx_status.rs)
    • Gas estimation: Returns fixed gas price and usage estimates for blob transactions. (src/grpc/gas_estimator.rs)

Dependency and configuration updates:

  • Added new dependencies in Cargo.toml for gRPC support (tonic, prost, http, etc.), and updated proto dependencies to enable gRPC features. (Cargo.toml)
  • Updated src/main.rs to parse a new GRPC_ADDR environment variable, initialize the gRPC server, and log its address. (src/main.rs) [1] [2]

These changes allow the application to serve Celestia-compatible gRPC endpoints for local testing and integration, supporting both JSON-RPC and gRPC clients.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds minimal gRPC endpoint support to localestia, enabling it to handle Celestia transaction client (txclient) requests. The implementation provides five gRPC services that complement the existing JSON-RPC functionality, allowing clients to submit blob transactions via gRPC, check transaction status, query account information, estimate gas costs, and retrieve node information.

Changes:

  • Added gRPC server running concurrently with JSON-RPC server on configurable address (default: 0.0.0.0:9090)
  • Implemented five gRPC services: TxService (BroadcastTx), TxStatusService (TxStatus), AuthQueryService (Account query), GasEstimatorService (gas estimation), and NodeInfoService (GetNodeInfo)
  • Added Redis storage methods (store_tx_height, get_tx_height) to maintain transaction hash to height mappings

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
src/main.rs Added gRPC server initialization and concurrent execution with JSON-RPC server; added GRPC_ADDR environment variable
src/storage/redis_storage.rs Added store_tx_height and get_tx_height methods to store/retrieve transaction height mappings in Redis
src/grpc/mod.rs Module entry point that initializes and serves all five gRPC services
src/grpc/tx_service.rs Implements cosmos.tx.v1beta1.Service for BroadcastTx - decodes BlobTx, stores blobs, and returns tx hash
src/grpc/tx_status.rs Implements celestia.core.v1.tx.Tx for TxStatus - looks up transaction height and returns COMMITTED status
src/grpc/node_info.rs Implements cosmos.base.tendermint.v1beta1.Service for GetNodeInfo - returns static node information
src/grpc/auth.rs Implements cosmos.auth.v1beta1.Query for Account queries - returns mock account info for sequence/account_number
src/grpc/gas_estimator.rs Implements celestia.core.v1.gas_estimation.GasEstimator - returns fixed gas price and usage estimates
Cargo.toml Added gRPC dependencies: celestia-proto, tonic, prost, http, bytes, http-body, http-body-util, tendermint-proto
Cargo.lock Dependency resolution updates including tonic 0.13, prost 0.13, and related transitive dependencies

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +14 to +28
pub async fn serve(
storage: Arc<RedisStorage>,
addr: std::net::SocketAddr,
) -> Result<(), tonic::transport::Error> {
info!("Starting gRPC server on {}", addr);

Server::builder()
.add_service(node_info::NodeInfoService::new(storage.clone()))
.add_service(auth::AuthQueryService::new(storage.clone()))
.add_service(tx_service::TxService::new(storage.clone()))
.add_service(tx_status::TxStatusService::new(storage.clone()))
.add_service(gas_estimator::GasEstimatorService::new(storage.clone()))
.serve(addr)
.await
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new gRPC functionality lacks test coverage. The codebase has integration tests for JSON-RPC methods (e.g., tests/compat_blob.rs), and similar test coverage should be added for the gRPC endpoints (BroadcastTx, TxStatus, GetNodeInfo, Account query, and gas estimation) to ensure they work correctly and to prevent regressions.

Copilot uses AI. Check for mistakes.
Comment on lines +1233 to +1234
let key = format!("tx:height:{}", tx_hash);
let val: Option<u64> = conn.get(key).await.map_err(LocalError::RedisError)?;
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tx_hash parameter is used directly in the Redis key construction without any validation or sanitization (format!("tx:height:{}", tx_hash)). This is the same issue as in store_tx_height. Consider validating that tx_hash contains only valid hexadecimal characters before using it in the key.

Copilot uses AI. Check for mistakes.
Comment on lines +29 to +35
pub struct GasEstimatorService {
_storage: Arc<RedisStorage>,
}

impl GasEstimatorService {
pub fn new(storage: Arc<RedisStorage>) -> Self {
Self { _storage: storage }
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five gRPC service structs (NodeInfoService, AuthQueryService, TxService, TxStatusService, GasEstimatorService) store an Arc, but three of them (NodeInfoService, AuthQueryService, GasEstimatorService) prefix it with an underscore (_storage) because they don't use it in their handlers. This is acceptable but could be confusing. Consider either removing the storage parameter from services that don't need it, or keeping it for consistency if you plan to use it in future enhancements.

Suggested change
pub struct GasEstimatorService {
_storage: Arc<RedisStorage>,
}
impl GasEstimatorService {
pub fn new(storage: Arc<RedisStorage>) -> Self {
Self { _storage: storage }
pub struct GasEstimatorService;
impl GasEstimatorService {
pub fn new(_storage: Arc<RedisStorage>) -> Self {
Self

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gbarros this is a good suggestion from copilot, might be worth tinkering with

Comment on lines +73 to +77
let height = storage
.get_tx_height(&tx_id)
.await
.unwrap_or(None)
.unwrap_or(1);
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling here is problematic. The code uses .unwrap_or(None) which would convert any error (such as a Redis connection failure) into a None, then .unwrap_or(1) defaults to height 1. This silently swallows errors and returns a potentially incorrect height. If there's a database error, the client should receive an error response (gRPC Status) rather than an incorrect success response with height 1.

Suggested change
let height = storage
.get_tx_height(&tx_id)
.await
.unwrap_or(None)
.unwrap_or(1);
let height = match storage.get_tx_height(&tx_id).await {
Ok(opt_height) => opt_height.unwrap_or(1),
Err(err) => {
return Err(Status::unavailable(format!(
"failed to get tx height: {err}"
)));
}
};

Copilot uses AI. Check for mistakes.
let tx_hash = {
let mut hasher = Sha256::new();
hasher.update(&tx_bytes);
hex::encode(hasher.finalize()).to_uppercase()
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hash computation uses uppercase hex encoding (.to_uppercase()), but the stored tx_hash key is used as-is. This could lead to case-sensitivity issues when looking up the transaction later in get_tx_height if the tx_id parameter has different casing. Consider either storing the hash in lowercase consistently or documenting the expected case format, and ensure that lookups normalize the case appropriately.

Suggested change
hex::encode(hasher.finalize()).to_uppercase()
hex::encode(hasher.finalize())

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +31
let grpc_addr =
std::env::var("GRPC_ADDR").unwrap_or_else(|_| "0.0.0.0:9090".to_string());
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README should be updated to document the new GRPC_ADDR environment variable and explain the gRPC endpoints that are now available. The existing documentation at lines 32-36 mentions REDIS_URL and LISTEN_ADDR but doesn't include GRPC_ADDR.

Copilot uses AI. Check for mistakes.
Status::internal(format!("storage error: {e}"))
})?;
height
} else {
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a non-BlobTx is submitted (lines 115-120), the height is set to 0 (line 134). However, no tx_height mapping is stored in Redis for such transactions. This means if someone later calls TxStatus for this transaction hash, the lookup will fail and return None, which gets defaulted to height 1 in tx_status.rs (due to the problematic error handling). Consider either storing a tx_height mapping with height 0 for non-blob transactions, or handling the None case more appropriately in TxStatus to distinguish between "transaction not found" and "transaction exists with no blobs".

Suggested change
} else {
} else {
// Non-blob or plain Cosmos tx: record a height of 0 so TxStatus can
// distinguish between "known tx with no blobs" and "tx not found".
storage.store_tx_height(&tx_hash, 0).await.map_err(|e| {
error!("Failed to store tx height mapping: {}", e);
Status::internal(format!("storage error: {e}"))
})?;

Copilot uses AI. Check for mistakes.
let listen_addr =
std::env::var("LISTEN_ADDR").unwrap_or_else(|_| "127.0.0.1:26658".to_string());
let grpc_addr =
std::env::var("GRPC_ADDR").unwrap_or_else(|_| "0.0.0.0:9090".to_string());
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default gRPC address binds to "0.0.0.0:9090", which makes the server accessible from any network interface. This differs from the JSON-RPC default of "127.0.0.1:26658", which only listens on localhost. For a local development/testing tool, it may be more secure to default to localhost (127.0.0.1:9090) to prevent unintended network exposure, unless binding to all interfaces is intentional for Docker/container scenarios.

Suggested change
std::env::var("GRPC_ADDR").unwrap_or_else(|_| "0.0.0.0:9090".to_string());
std::env::var("GRPC_ADDR").unwrap_or_else(|_| "127.0.0.1:9090".to_string());

Copilot uses AI. Check for mistakes.
Comment on lines +94 to +105
fn unimplemented_response() -> http::Response<Body> {
let mut resp = http::Response::new(Body::empty());
resp.headers_mut().insert(
"content-type",
http::HeaderValue::from_static("application/grpc"),
);
resp.headers_mut().insert(
"grpc-status",
http::HeaderValue::from_static("12"), // UNIMPLEMENTED
);
resp
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unimplemented_response function is duplicated across all five gRPC service files (tx_status.rs, tx_service.rs, node_info.rs, gas_estimator.rs, auth.rs) with identical implementations. Consider extracting this to a shared utility module (e.g., src/grpc/utils.rs) to reduce code duplication and make maintenance easier.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

@Ferret-san Ferret-san left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the gRPC services services could implement the upstream traits to better ensure compatibility with the lumina crates, example generated by Claude:

use celestia_proto::cosmos::tx::v1beta1::{
    service_server::Service as TxService,
    SimulateRequest, SimulateResponse,
    GetTxRequest, GetTxResponse,
    BroadcastTxRequest, BroadcastTxResponse,
    GetTxsEventRequest, GetTxsEventResponse,
    GetBlockWithTxsRequest, GetBlockWithTxsResponse,
    TxDecodeRequest, TxDecodeResponse,
    TxEncodeRequest, TxEncodeResponse,
    TxEncodeAminoRequest, TxEncodeAminoResponse,
    TxDecodeAminoRequest, TxDecodeAminoResponse,
};
use celestia_proto::cosmos::base::abci::v1beta1::{TxResponse, GasInfo, Result as AbciResult};
use std::sync::Arc;
use tonic::{Request, Response, Status};

use crate::storage::RedisStorage;

pub struct MockTxService {
    storage: Arc<RedisStorage>,
}

impl MockTxService {
    pub fn new(storage: Arc<RedisStorage>) -> Self {
        Self { storage }
    }
}

#[tonic::async_trait]
impl TxService for MockTxService {
    async fn simulate(
        &self,
        _request: Request<SimulateRequest>,
    ) -> Result<Response<SimulateResponse>, Status> {
        // Return successful simulation with reasonable gas estimate
        Ok(Response::new(SimulateResponse {
            gas_info: Some(GasInfo {
                gas_wanted: 200_000,
                gas_used: 100_000,
            }),
            result: Some(AbciResult {
                data: vec![],
                log: String::new(),
                events: vec![],
                msg_responses: vec![],
            }),
        }))
    }

    async fn get_tx(
        &self,
        request: Request<GetTxRequest>,
    ) -> Result<Response<GetTxResponse>, Status> {
        let _hash = request.into_inner().hash;
        // Look up tx by hash in Redis, or return not found
        Err(Status::not_found("transaction not found"))
    }

    async fn broadcast_tx(
        &self,
        request: Request<BroadcastTxRequest>,
    ) -> Result<Response<BroadcastTxResponse>, Status> {
        let inner = request.into_inner();
        let _tx_bytes = inner.tx_bytes;

        // This is where the magic happens for blob submission:
        // 1. Decode the tx_bytes (it's a cosmos Tx, possibly a BlobTx)
        // 2. Extract MsgPayForBlobs + the blob sidecar
        // 3. Store blobs in Redis via your existing storage
        // 4. Return a TxResponse with a tx hash

        // For a minimal mock, just accept everything:
        let tx_hash = hex::encode(sha2::Sha256::digest(&_tx_bytes));

        Ok(Response::new(BroadcastTxResponse {
            tx_response: Some(TxResponse {
                height: 1,
                txhash: tx_hash,
                codespace: String::new(),
                code: 0, // 0 = success
                data: String::new(),
                raw_log: String::new(),
                logs: vec![],
                info: String::new(),
                gas_wanted: 200_000,
                gas_used: 100_000,
                tx: None,
                timestamp: String::new(),
                events: vec![],
            }),
        }))
    }

    async fn get_txs_event(
        &self,
        _req: Request<GetTxsEventRequest>,
    ) -> Result<Response<GetTxsEventResponse>, Status> {
        Err(Status::unimplemented("not implemented"))
    }

    async fn get_block_with_txs(
        &self,
        _req: Request<GetBlockWithTxsRequest>,
    ) -> Result<Response<GetBlockWithTxsResponse>, Status> {
        Err(Status::unimplemented("not implemented"))
    }

    async fn tx_decode(
        &self,
        _req: Request<TxDecodeRequest>,
    ) -> Result<Response<TxDecodeResponse>, Status> {
        Err(Status::unimplemented("not implemented"))
    }

    async fn tx_encode(
        &self,
        _req: Request<TxEncodeRequest>,
    ) -> Result<Response<TxEncodeResponse>, Status> {
        Err(Status::unimplemented("not implemented"))
    }

    async fn tx_encode_amino(
        &self,
        _req: Request<TxEncodeAminoRequest>,
    ) -> Result<Response<TxEncodeAminoResponse>, Status> {
        Err(Status::unimplemented("not implemented"))
    }

    async fn tx_decode_amino(
        &self,
        _req: Request<TxDecodeAminoRequest>,
    ) -> Result<Response<TxDecodeAminoResponse>, Status> {
        Err(Status::unimplemented("not implemented"))
    }
}

Also addressing the comments from copilot and adding testing for this feature is required

Comment on lines +68 to +86
fn call(&mut self, req: Request<QueryAccountRequest>) -> Self::Future {
let address = req.into_inner().address;
Box::pin(async move {
let account = BaseAccount {
address: address.clone(),
pub_key: None,
account_number: 1,
sequence: 0,
};

let mut buf = Vec::new();
account
.encode(&mut buf)
.map_err(|e| Status::internal(format!("encode error: {e}")))?;

let any = Any {
type_url: "/cosmos.auth.v1beta1.BaseAccount".to_string(),
value: buf,
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be helpful to explain what this does, what's the expected behavior, why the values are initialized as is, etc.

Comment on lines +93 to +104
fn unimplemented_response() -> http::Response<Body> {
let mut resp = http::Response::new(Body::empty());
resp.headers_mut().insert(
"content-type",
http::HeaderValue::from_static("application/grpc"),
);
resp.headers_mut().insert(
"grpc-status",
http::HeaderValue::from_static("12"), // UNIMPLEMENTED
);
resp
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the tonic libraries or others should have a function / type for this

Comment on lines +29 to +35
pub struct GasEstimatorService {
_storage: Arc<RedisStorage>,
}

impl GasEstimatorService {
pub fn new(storage: Arc<RedisStorage>) -> Self {
Self { _storage: storage }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gbarros this is a good suggestion from copilot, might be worth tinkering with

Comment on lines +113 to +124
fn unimplemented_response() -> http::Response<Body> {
let mut resp = http::Response::new(Body::empty());
resp.headers_mut().insert(
"content-type",
http::HeaderValue::from_static("application/grpc"),
);
resp.headers_mut().insert(
"grpc-status",
http::HeaderValue::from_static("12"), // UNIMPLEMENTED
);
resp
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment as in auth.rs

Comment on lines +90 to +101
fn unimplemented_response() -> http::Response<Body> {
let mut resp = http::Response::new(Body::empty());
resp.headers_mut().insert(
"content-type",
http::HeaderValue::from_static("application/grpc"),
);
resp.headers_mut().insert(
"grpc-status",
http::HeaderValue::from_static("12"), // UNIMPLEMENTED
);
resp
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

either import from a library or use from a single source

const NAME: &'static str = "cosmos.tx.v1beta1.Service";
}

impl tower::Service<http::Request<Body>> for TxService {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason we use use tower here? in general we might be able to impl XService from the celestia_proto crate (like TxService), same for the other grpc services, and add #[tonic::async_trait] to them. While this means implemented more methods, it means we wouldn't have to test for compatibility between our own rolled methods and lumina, and can instead catch issues at compile time as we update our dependencies

@Ferret-san Ferret-san changed the title Minimal gRPC endpoint (app) support feat!: minimal gRPC endpoint (app) support Feb 25, 2026
@gbarros
Copy link
Contributor Author

gbarros commented Feb 26, 2026

I think I will let this PR sit here until we wait for the traits then @Ferret-san
Correcting anything more could be meaningless in that changed context.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants