thruput-json-rpc-server 0.42.0

Reusable JSON-RPC server layer for Thruput
Documentation
use super::*;
use crate::{RpcServerConfig, TipReading, native::transaction::NativeReadError};
use async_trait::async_trait;
use std::sync::{
    Arc, Mutex,
    atomic::{AtomicUsize, Ordering},
};
use thruput_json_rpc_types::{
    base58::Base58,
    block::ChainTip,
    error::native::{BackendDataInvalidReason, ReceiptObservation, codes},
    hex::HexBytes,
};
use thruput_xdr::network_config::{NetworkConfig, NetworkConfigV0};
use xdr_common::VecM;

#[derive(Clone, Copy, Default)]
enum Mode {
    #[default]
    Accept,
    Queued,
    Dispatched,
}
#[derive(Clone, Copy, Default)]
enum Precheck {
    #[default]
    Ready,
    Pending,
    Slow,
}
#[derive(Clone, Default)]
struct Backend {
    mode: Mode,
    calls: Arc<AtomicUsize>,
    attempt: Arc<Mutex<Option<SubmissionAttempt>>>,
    submitted: Arc<Mutex<Option<Vec<u8>>>>,
    corrupt_receipt: bool,
    precheck: Precheck,
    observation: Option<ReceiptObservation>,
    read_error: bool,
    receipt_height: Option<i64>,
    status: Option<out::TransactionStatus>,
}
#[async_trait]
impl ChainPlane for Backend {
    async fn chain_tip(&self, _: RpcRequestContext) -> Result<TipReading, RpcServerError> {
        if matches!(self.precheck, Precheck::Slow) {
            // Model non-yielding preparation that runs past the request budget.
            std::thread::sleep(std::time::Duration::from_millis(50));
        }
        if matches!(self.precheck, Precheck::Pending) {
            std::future::pending::<()>().await;
        }
        Ok(TipReading::new(
            ChainTip {
                height: 42.into(),
                block_hash: Base58([8; 32].into()),
            },
            NetworkConfig::_0 {
                v0: NetworkConfigV0 {
                    params: VecM(vec![]),
                },
            },
        ))
    }
    async fn pending_transaction_count(&self, _: RpcRequestContext) -> Result<u64, RpcServerError> {
        panic!("not used")
    }
}
#[async_trait]
impl NativeSubmitPlane for Backend {
    async fn native_submit(
        &self,
        _: RpcRequestContext,
        tx: ValidatedTransaction,
        attempt: SubmissionAttempt,
    ) -> Result<(), RpcServerError> {
        self.calls.fetch_add(1, Ordering::SeqCst);
        *self.submitted.lock().unwrap() = Some(tx.bytes);
        *self.attempt.lock().unwrap() = Some(attempt.clone());
        if matches!(self.mode, Mode::Accept | Mode::Dispatched) {
            assert!(attempt.begin_dispatch());
        }
        if !matches!(self.mode, Mode::Accept) {
            std::future::pending::<()>().await;
        }
        Ok(())
    }
}
#[async_trait]
impl NativeTransactionReadPlane for Backend {
    async fn native_receipt(
        &self,
        _: RpcRequestContext,
        hash: TxHash,
    ) -> Result<Option<out::TransactionReceipt>, NativeReadError> {
        if self.read_error {
            return Err(NativeReadError {
                error: RpcServerError::invalid_data(
                    BackendDataInvalidReason::InvalidEncoding,
                    "broken event",
                ),
                observation: self.observation.clone().map(Box::new),
            });
        }
        Ok(Some(out::TransactionReceipt {
            transaction_hash: if self.corrupt_receipt {
                TxHash(Base58([9; 32].into()))
            } else {
                hash
            },
            block_height: self.receipt_height.unwrap_or(42).into(),
            execution: out::ReceiptExecution::Return {
                return_data: HexBytes(vec![]),
            },
            events: vec![],
            gas_used: 10,
        }))
    }
    async fn native_status(
        &self,
        _: RpcRequestContext,
        _: TxHash,
    ) -> Result<out::TransactionStatus, NativeReadError> {
        Ok(self
            .status
            .clone()
            .unwrap_or(out::TransactionStatus::Committed {
                block_height: 42.into(),
            }))
    }
}
fn server(backend: Backend) -> NativeTransactionService<Backend, Backend, Backend> {
    NativeTransactionService::new(
        backend.clone(),
        backend.clone(),
        backend,
        RpcServerConfig {
            request_timeout_ms: Some(25),
            tip_refresh_interval_ms: 0,
            ..RpcServerConfig::default()
        },
    )
}
fn request() -> input::SendTransactionRequest {
    input::SendTransactionRequest {
        signed_transaction: HexBytes(
            xdrs::txs::encode(&crate::tx::test_fixtures::canonical_signed_transaction()).unwrap(),
        ),
    }
}
fn hash() -> TxHash {
    let bytes: [u8; 32] = hex::decode(crate::tx::test_fixtures::CANONICAL_HASH)
        .unwrap()
        .try_into()
        .unwrap();
    TxHash(Base58(bytes.into()))
}
fn data(error: &jsonrpsee::types::ErrorObjectOwned) -> serde_json::Value {
    serde_json::from_str(error.data().unwrap().get()).unwrap()
}

#[tokio::test]
async fn invalid_encoding_and_expiry_are_not_attempted() {
    let backend = Backend::default();
    let server = server(backend.clone());
    let error = server
        .send_transaction(input::SendTransactionRequest {
            signed_transaction: HexBytes(vec![1, 2, 3]),
        })
        .await
        .unwrap_err();
    assert_eq!(error.code(), codes::TRANSACTION_INVALID);
    assert_eq!(data(&error)["admission"], "notAttempted");
    assert!(data(&error).get("transactionHash").is_none());
    let mut tx = crate::tx::test_fixtures::canonical_signed_transaction();
    tx.tx.uniquifier.block_expiry_height = 42.into();
    let error = server
        .send_transaction(input::SendTransactionRequest {
            signed_transaction: HexBytes(xdrs::txs::encode(&tx).unwrap()),
        })
        .await
        .unwrap_err();
    assert_eq!(error.code(), codes::TRANSACTION_REJECTED);
    assert_eq!(data(&error)["admission"], "notAttempted");
    assert_eq!(data(&error)["reason"], "expired");
    assert!(data(&error)["transactionHash"].is_string());
    assert_eq!(backend.calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn accepted_submission_returns_the_canonical_body_hash() {
    let backend = Backend::default();
    let result = server(backend.clone())
        .send_transaction(request())
        .await
        .unwrap();
    assert_eq!(result.transaction_hash, hash());
    assert!(matches!(result.admission, out::Admission::Accepted));
    assert_eq!(backend.calls.load(Ordering::SeqCst), 1);
    assert_eq!(
        backend.submitted.lock().unwrap().as_deref(),
        Some(request().signed_transaction.0.as_slice())
    );
}
#[tokio::test]
async fn timeout_before_dispatch_closes_the_gate_but_dispatched_timeout_is_unknown() {
    for (mode, expected) in [
        (Mode::Queued, "notAttempted"),
        (Mode::Dispatched, "unknown"),
    ] {
        let backend = Backend {
            mode,
            ..Default::default()
        };
        let error = server(backend.clone())
            .send_transaction(request())
            .await
            .unwrap_err();
        assert_eq!(error.code(), codes::BACKEND_TIMEOUT);
        assert_eq!(data(&error)["admission"], expected);
        assert_eq!(
            data(&error)["transactionHash"],
            serde_json::to_value(hash()).unwrap()
        );
        let attempt = backend.attempt.lock().unwrap().clone().unwrap();
        assert!(
            !attempt.begin_dispatch(),
            "timed-out queued work must never dispatch later"
        );
    }
}
#[tokio::test]
async fn caller_cancellation_also_closes_the_queued_dispatch_gate() {
    let backend = Backend {
        mode: Mode::Queued,
        ..Default::default()
    };
    let server = server(backend.clone());
    let mut future = Box::pin(server.send_transaction(request()));
    std::future::poll_fn(|cx| {
        assert!(std::future::Future::poll(future.as_mut(), cx).is_pending());
        std::task::Poll::Ready(())
    })
    .await;
    let attempt = backend.attempt.lock().unwrap().clone().unwrap();
    drop(future);
    assert!(!attempt.begin_dispatch());
}
#[tokio::test]
async fn receipt_identity_failure_omits_observation_and_valid_evidence_survives() {
    let query = || input::TransactionQuery {
        transaction_hash: hash(),
    };
    let error = server(Backend {
        corrupt_receipt: true,
        ..Default::default()
    })
    .get_transaction_receipt(query())
    .await
    .unwrap_err();
    assert_eq!(error.code(), codes::BACKEND_DATA_INVALID);
    assert!(data(&error).get("observation").is_none());
    for (observed, height, kept) in [
        (hash(), 42, true),
        (hash(), 0, true),
        (hash(), -1, false),
        (TxHash(Base58([9; 32].into())), 42, false),
    ] {
        let error = server(Backend {
            read_error: true,
            observation: Some(ReceiptObservation {
                transaction_hash: observed,
                block_height: height.into(),
            }),
            ..Default::default()
        })
        .get_transaction_receipt(query())
        .await
        .unwrap_err();
        assert_eq!(data(&error).get("observation").is_some(), kept);
        assert!(data(&error).get("admission").is_none());
        if kept {
            assert_eq!(
                data(&error)["observation"]["blockHeight"],
                height.to_string()
            );
        }
    }
}

#[tokio::test]
async fn receipt_height_must_be_nonnegative() {
    let query = || input::TransactionQuery {
        transaction_hash: hash(),
    };
    let receipt = server(Backend {
        receipt_height: Some(0),
        ..Default::default()
    })
    .get_transaction_receipt(query())
    .await
    .unwrap();
    assert_eq!(receipt.map(|receipt| *receipt.block_height), Some(0));
    let error = server(Backend {
        receipt_height: Some(-1),
        ..Default::default()
    })
    .get_transaction_receipt(query())
    .await
    .unwrap_err();
    assert_eq!(error.code(), codes::BACKEND_DATA_INVALID);
    assert_eq!(data(&error)["reason"], "inconsistentResult");
}

#[tokio::test]
async fn status_heights_must_be_nonnegative() {
    let committed = |height: i64| out::TransactionStatus::Committed {
        block_height: height.into(),
    };
    let failed = |height: i64| out::TransactionStatus::RecentFailure {
        attempt_block_height: height.into(),
        cause: out::FailureCause::ContractRejected {
            contract_index: 0,
            code: out::ContractRejectCode::Duplicate,
        },
    };
    for (status, valid) in [
        (committed(0), true),
        (committed(-1), false),
        (failed(0), true),
        (failed(7), true),
        (failed(-1), false),
    ] {
        let result = server(Backend {
            status: Some(status.clone()),
            ..Default::default()
        })
        .get_transaction_status(input::TransactionQuery {
            transaction_hash: hash(),
        })
        .await;
        match result {
            Ok(observed) => {
                assert!(valid, "{status:?}");
                assert_eq!(
                    serde_json::to_value(observed).unwrap(),
                    serde_json::to_value(status).unwrap()
                );
            }
            Err(error) => {
                assert!(!valid, "{status:?}");
                assert_eq!(error.code(), codes::BACKEND_DATA_INVALID);
                assert_eq!(data(&error)["reason"], "inconsistentResult");
                assert!(data(&error).get("admission").is_none());
            }
        }
    }
}

#[tokio::test]
async fn precheck_timeout_has_no_hash_and_cannot_submit() {
    let backend = Backend {
        precheck: Precheck::Pending,
        ..Default::default()
    };
    let error = server(backend.clone())
        .send_transaction(request())
        .await
        .unwrap_err();
    assert_eq!(error.code(), codes::BACKEND_TIMEOUT);
    assert_eq!(data(&error)["admission"], "notAttempted");
    assert!(data(&error).get("transactionHash").is_none());
    assert_eq!(backend.calls.load(Ordering::SeqCst), 0);
    assert!(backend.attempt.lock().unwrap().is_none());
}

#[tokio::test]
async fn synchronous_preparation_overrun_cannot_start_submission() {
    let backend = Backend {
        precheck: Precheck::Slow,
        ..Default::default()
    };
    let error = server(backend.clone())
        .send_transaction(request())
        .await
        .unwrap_err();
    assert_eq!(error.code(), codes::BACKEND_TIMEOUT);
    assert_eq!(data(&error)["admission"], "notAttempted");
    assert_eq!(backend.calls.load(Ordering::SeqCst), 0);
    assert!(backend.attempt.lock().unwrap().is_none());
}