Skip to main content

chainrpc_core/
aptos.rs

1//! Aptos RPC support — REST-to-JSON-RPC adapter, method safety, and transport.
2//!
3//! Aptos uses a REST API (not JSON-RPC), so the transport internally converts
4//! chain client calls into REST operations. For use with the generic
5//! `RpcTransport` trait, we provide a thin adapter.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use crate::chain_client::{ChainBlock, ChainClient};
12use crate::error::TransportError;
13use crate::request::{JsonRpcRequest, JsonRpcResponse};
14use crate::transport::{HealthStatus, RpcTransport};
15
16// ---------------------------------------------------------------------------
17// CU cost table
18// ---------------------------------------------------------------------------
19
20#[derive(Debug, Clone)]
21pub struct AptosCuCostTable {
22    costs: HashMap<String, u32>,
23    default_cost: u32,
24}
25
26impl AptosCuCostTable {
27    pub fn defaults() -> Self {
28        let mut table = Self::new(15);
29        let entries: &[(&str, u32)] = &[
30            ("get_ledger_info", 5),
31            ("get_block_by_height", 20),
32            ("get_block_by_version", 20),
33            ("get_account", 10),
34            ("get_account_resources", 15),
35            ("get_account_modules", 15),
36            ("get_transactions", 20),
37            ("get_transaction_by_hash", 15),
38            ("submit_transaction", 10),
39            ("simulate_transaction", 30),
40            ("get_events_by_event_handle", 20),
41        ];
42        for &(method, cost) in entries {
43            table.costs.insert(method.to_string(), cost);
44        }
45        table
46    }
47
48    pub fn new(default_cost: u32) -> Self {
49        Self { costs: HashMap::new(), default_cost }
50    }
51
52    pub fn cost_for(&self, method: &str) -> u32 {
53        self.costs.get(method).copied().unwrap_or(self.default_cost)
54    }
55}
56
57impl Default for AptosCuCostTable {
58    fn default() -> Self {
59        Self::defaults()
60    }
61}
62
63// ---------------------------------------------------------------------------
64// Known endpoints
65// ---------------------------------------------------------------------------
66
67pub fn aptos_mainnet_endpoints() -> &'static [&'static str] {
68    &[
69        "https://fullnode.mainnet.aptoslabs.com/v1",
70        "https://aptos-mainnet.nodereal.io/v1",
71    ]
72}
73
74pub fn aptos_testnet_endpoints() -> &'static [&'static str] {
75    &[
76        "https://fullnode.testnet.aptoslabs.com/v1",
77    ]
78}
79
80pub fn aptos_devnet_endpoints() -> &'static [&'static str] {
81    &[
82        "https://fullnode.devnet.aptoslabs.com/v1",
83    ]
84}
85
86// ---------------------------------------------------------------------------
87// AptosTransport
88// ---------------------------------------------------------------------------
89
90/// Aptos RPC transport wrapper.
91///
92/// Since Aptos uses REST, this wraps a JSON-RPC transport and maps
93/// known method names to REST-like operations internally.
94pub struct AptosTransport {
95    inner: Arc<dyn RpcTransport>,
96}
97
98impl AptosTransport {
99    pub fn new(inner: Arc<dyn RpcTransport>) -> Self {
100        Self { inner }
101    }
102
103    pub fn inner(&self) -> &Arc<dyn RpcTransport> {
104        &self.inner
105    }
106}
107
108#[async_trait]
109impl RpcTransport for AptosTransport {
110    async fn send(&self, req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
111        self.inner.send(req).await
112    }
113
114    async fn send_batch(&self, reqs: Vec<JsonRpcRequest>) -> Result<Vec<JsonRpcResponse>, TransportError> {
115        self.inner.send_batch(reqs).await
116    }
117
118    fn health(&self) -> HealthStatus { self.inner.health() }
119    fn url(&self) -> &str { self.inner.url() }
120}
121
122// ---------------------------------------------------------------------------
123// AptosChainClient
124// ---------------------------------------------------------------------------
125
126/// Aptos implementation of [`ChainClient`].
127///
128/// Maps chain client methods to Aptos REST API calls routed through the
129/// underlying transport as JSON-RPC method names.
130pub struct AptosChainClient {
131    transport: Arc<dyn RpcTransport>,
132    chain_id: String,
133}
134
135impl AptosChainClient {
136    pub fn new(transport: Arc<dyn RpcTransport>, chain_id: impl Into<String>) -> Self {
137        Self {
138            transport,
139            chain_id: chain_id.into(),
140        }
141    }
142}
143
144#[async_trait]
145impl ChainClient for AptosChainClient {
146    async fn get_head_height(&self) -> Result<u64, TransportError> {
147        let req = JsonRpcRequest::new(1, "get_ledger_info", vec![]);
148        let resp = self.transport.send(req).await?;
149        let result = resp.into_result().map_err(TransportError::Rpc)?;
150
151        // Aptos returns block_height as a string
152        let height_str = result["block_height"]
153            .as_str()
154            .or_else(|| result["result"]["block_height"].as_str())
155            .unwrap_or("0");
156        height_str.parse::<u64>().map_err(|e| {
157            TransportError::Other(format!("invalid aptos block height: {e}"))
158        })
159    }
160
161    async fn get_block_by_height(
162        &self,
163        height: u64,
164    ) -> Result<Option<ChainBlock>, TransportError> {
165        let req = JsonRpcRequest::new(
166            1,
167            "get_block_by_height",
168            vec![serde_json::json!(height.to_string())],
169        );
170        let resp = self.transport.send(req).await?;
171        let result = resp.into_result().map_err(TransportError::Rpc)?;
172
173        if result.is_null() {
174            return Ok(None);
175        }
176
177        let hash = result["block_hash"]
178            .as_str()
179            .unwrap_or_default()
180            .to_string();
181        let timestamp = result["block_timestamp"]
182            .as_str()
183            .and_then(|s| s.parse::<u64>().ok())
184            .map(|us| (us / 1_000_000) as i64) // microseconds to seconds
185            .unwrap_or(0);
186        let tx_count = result["transactions"]
187            .as_array()
188            .map(|a| a.len() as u32)
189            .unwrap_or(0);
190
191        // Aptos doesn't have parent hash in the same way
192        let first_version = result["first_version"]
193            .as_str()
194            .unwrap_or_default()
195            .to_string();
196
197        Ok(Some(ChainBlock {
198            height,
199            hash,
200            parent_hash: first_version, // closest analog
201            timestamp,
202            tx_count,
203        }))
204    }
205
206    fn chain_id(&self) -> &str {
207        &self.chain_id
208    }
209
210    fn chain_family(&self) -> &str {
211        "aptos"
212    }
213
214    async fn health_check(&self) -> Result<bool, TransportError> {
215        self.get_head_height().await.map(|_| true)
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::request::RpcId;
223    use serde_json::Value;
224    use std::sync::Mutex;
225
226    struct MockTransport {
227        url: String,
228        responses: Mutex<Vec<JsonRpcResponse>>,
229    }
230
231    impl MockTransport {
232        fn new(responses: Vec<JsonRpcResponse>) -> Self {
233            Self { url: "mock://aptos".into(), responses: Mutex::new(responses) }
234        }
235    }
236
237    #[async_trait]
238    impl RpcTransport for MockTransport {
239        async fn send(&self, _req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
240            let mut r = self.responses.lock().unwrap();
241            if r.is_empty() { Err(TransportError::Other("no mock".into())) }
242            else { Ok(r.remove(0)) }
243        }
244        fn url(&self) -> &str { &self.url }
245    }
246
247    fn ok(result: Value) -> JsonRpcResponse {
248        JsonRpcResponse { jsonrpc: "2.0".into(), id: RpcId::Number(1), result: Some(result), error: None }
249    }
250
251    #[test]
252    fn cu_costs() {
253        let t = AptosCuCostTable::defaults();
254        assert_eq!(t.cost_for("get_ledger_info"), 5);
255        assert_eq!(t.cost_for("get_block_by_height"), 20);
256    }
257
258    #[test]
259    fn endpoints() {
260        assert!(!aptos_mainnet_endpoints().is_empty());
261        assert!(!aptos_testnet_endpoints().is_empty());
262    }
263
264    #[tokio::test]
265    async fn aptos_get_head_height() {
266        let t = Arc::new(MockTransport::new(vec![ok(serde_json::json!({
267            "block_height": "150000000"
268        }))]));
269        let c = AptosChainClient::new(t, "1");
270        assert_eq!(c.get_head_height().await.unwrap(), 150000000);
271    }
272
273    #[tokio::test]
274    async fn aptos_get_block() {
275        let t = Arc::new(MockTransport::new(vec![ok(serde_json::json!({
276            "block_hash": "0xabc123",
277            "block_timestamp": "1700000000000000",
278            "first_version": "100000",
279            "transactions": [{"type": "user"}, {"type": "user"}]
280        }))]));
281        let c = AptosChainClient::new(t, "1");
282        let b = c.get_block_by_height(100).await.unwrap().unwrap();
283        assert_eq!(b.hash, "0xabc123");
284        assert_eq!(b.timestamp, 1700000000);
285        assert_eq!(b.tx_count, 2);
286    }
287
288    #[tokio::test]
289    async fn aptos_metadata() {
290        let t = Arc::new(MockTransport::new(vec![]));
291        let c = AptosChainClient::new(t, "2");
292        assert_eq!(c.chain_family(), "aptos");
293        assert_eq!(c.chain_id(), "2");
294    }
295}