Skip to main content

sui_indexer_alt_reader/
fullnode_client.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::Context;
5use anyhow::anyhow;
6use async_graphql::dataloader::DataLoader;
7use futures::future::try_join_all;
8use prometheus::Registry;
9use prost_types::FieldMask;
10use sui_rpc::Client;
11use sui_rpc::field::FieldMaskUtil;
12use sui_rpc::proto::sui::rpc::v2 as proto;
13use sui_sdk_types::Address;
14use sui_types::signature::GenericSignature;
15use sui_types::transaction::Transaction;
16use sui_types::transaction::TransactionData;
17use tracing::instrument;
18use url::Url;
19
20use crate::metrics::GrpcMetricsLayer;
21
22// Programmable transaction validation requires the command count to be strictly less than the
23// protocol's 1,024-command limit.
24const MAX_REWARDS_PER_PTB: usize = 1023;
25
26#[derive(clap::Args, Debug, Clone, Default)]
27pub struct FullnodeArgs {
28    /// gRPC URL for full node operations such as executeTransaction and simulateTransaction.
29    /// `Option` so the flag stays optional when flattened into a parent args struct.
30    #[clap(long)]
31    pub(crate) fullnode_rpc_url: Option<Url>,
32}
33
34/// A client for executing and simulating transactions via the full node gRPC service.
35#[derive(Clone)]
36pub struct FullnodeClient {
37    client: Client,
38}
39
40#[derive(thiserror::Error, Debug)]
41pub enum Error {
42    #[error(transparent)]
43    Internal(#[from] anyhow::Error),
44
45    #[error(transparent)]
46    GrpcExecutionError(#[from] tonic::Status),
47}
48
49impl FullnodeArgs {
50    pub fn new(url: Url) -> Self {
51        Self {
52            fullnode_rpc_url: Some(url),
53        }
54    }
55}
56
57impl FullnodeClient {
58    pub async fn new(
59        prefix: Option<&str>,
60        args: FullnodeArgs,
61        registry: &Registry,
62    ) -> Result<Option<Self>, Error> {
63        let Some(url) = args.fullnode_rpc_url else {
64            return Ok(None);
65        };
66
67        let client = Client::new(url.to_string())
68            .context("Failed to create client for gRPC endpoint")?
69            .request_layer(GrpcMetricsLayer::new(
70                prefix.unwrap_or("fullnode"),
71                registry,
72            ));
73
74        Ok(Some(Self { client }))
75    }
76
77    pub fn as_data_loader(&self) -> DataLoader<Self> {
78        DataLoader::new(self.clone(), tokio::spawn)
79    }
80
81    /// Execute a transaction on the Sui network via gRPC.
82    #[instrument(skip(self, transaction_data, signatures, read_mask), level = "debug")]
83    pub async fn execute_transaction(
84        &self,
85        transaction_data: TransactionData,
86        signatures: Vec<GenericSignature>,
87        read_mask: FieldMask,
88    ) -> Result<proto::ExecuteTransactionResponse, Error> {
89        let transaction = Transaction::from_generic_sig_data(transaction_data, signatures);
90
91        let signatures = transaction
92            .inner()
93            .tx_signatures
94            .iter()
95            .map(|signature| {
96                let mut message = proto::UserSignature::default();
97                message.bcs = Some(signature.as_ref().to_vec().into());
98                message
99            })
100            .collect();
101
102        let request = proto::ExecuteTransactionRequest::new({
103            let mut tx = proto::Transaction::default();
104            tx.bcs = Some(
105                proto::Bcs::serialize(&transaction.inner().intent_message.value)
106                    .context("Failed to serialize transaction")?,
107            );
108            tx
109        })
110        .with_signatures(signatures)
111        .with_read_mask(read_mask);
112
113        self.client
114            .clone()
115            .execution_client()
116            .execute_transaction(request)
117            .await
118            .map(|r| r.into_inner())
119            .map_err(Into::into)
120    }
121
122    /// Simulate a transaction on the Sui network via gRPC.
123    /// Note: Simulation does not require signatures since the transaction is not committed to the blockchain.
124    ///
125    /// - `checks_enabled`: If true, enables transaction validation checks during simulation.
126    /// - `do_gas_selection`: If true, enables automatic gas coin selection and budget estimation.
127    #[instrument(skip(self, transaction, read_mask), level = "debug")]
128    pub async fn simulate_transaction(
129        &self,
130        transaction: proto::Transaction,
131        checks_enabled: bool,
132        do_gas_selection: bool,
133        read_mask: FieldMask,
134    ) -> Result<proto::SimulateTransactionResponse, Error> {
135        use proto::simulate_transaction_request::TransactionChecks;
136
137        let checks = if checks_enabled {
138            TransactionChecks::Enabled
139        } else {
140            TransactionChecks::Disabled
141        };
142
143        let request = proto::SimulateTransactionRequest::new(transaction)
144            .with_read_mask(read_mask)
145            .with_checks(checks)
146            .with_do_gas_selection(do_gas_selection);
147
148        self.client
149            .clone()
150            .execution_client()
151            .simulate_transaction(request)
152            .await
153            .map(|r| r.into_inner())
154            .map_err(Into::into)
155    }
156
157    /// Construct and dry run PTBs to calculate the rewards for a list of staked SUI objects.
158    /// Returns a list of u64 guaranteed to match the order of the input staked SUI ids.
159    pub async fn calculate_rewards(&self, staked_sui_ids: &[Address]) -> Result<Vec<u64>, Error> {
160        let batches = staked_sui_ids
161            .chunks(MAX_REWARDS_PER_PTB)
162            .map(|batch| self.calculate_rewards_batch(batch));
163
164        Ok(try_join_all(batches).await?.into_iter().flatten().collect())
165    }
166
167    /// Construct and dry run a PTB to get the corresponding validator addresses for a list of
168    /// staking pool ids. Returns a list of validator addresses guaranteed to match the order of the
169    /// input pool ids.
170    pub async fn get_validator_address_by_pool_id(
171        &self,
172        pool_ids: &[Address],
173    ) -> Result<Vec<Address>, Error> {
174        let mut ptb = proto::ProgrammableTransaction::default()
175            .with_inputs(vec![proto::Input::default().with_object_id("0x5")]);
176        let system_object = proto::Argument::new_input(0);
177
178        for id in pool_ids {
179            let pool_id = proto::Argument::new_input(ptb.inputs.len() as u16);
180            ptb.inputs
181                .push(proto::Input::default().with_pure(id.into_inner().to_vec()));
182            ptb.commands.push(
183                proto::MoveCall::default()
184                    .with_package("0x3")
185                    .with_module("sui_system")
186                    .with_function("validator_address_by_pool_id")
187                    .with_arguments(vec![system_object, pool_id])
188                    .into(),
189            );
190        }
191
192        let transaction = proto::Transaction::default()
193            .with_kind(ptb)
194            .with_sender("0x0");
195
196        let resp = self
197            .simulate_transaction(
198                transaction,
199                false,
200                false,
201                FieldMask::from_paths([
202                    "command_outputs.return_values.value",
203                    "transaction.effects.status",
204                ]),
205            )
206            .await?;
207
208        if !resp.transaction().effects().status().success() {
209            return Err(Error::Internal(anyhow!("transaction execution failed")));
210        }
211
212        if pool_ids.len() != resp.command_outputs.len() {
213            return Err(Error::Internal(anyhow!(
214                "Mismatch between transaction inputs and command_outputs"
215            )));
216        }
217
218        resp.command_outputs
219            .iter()
220            .map(|output| {
221                // Both active and inactive validators are checked, so on success expect every
222                // command to have a return address
223                let bcs_address = output
224                    .return_values
225                    .first()
226                    .and_then(|o| o.value_opt())
227                    .ok_or_else(|| Error::Internal(anyhow!("missing address bcs")))?;
228
229                Address::from_bytes(bcs_address.value())
230                    .map_err(|e| Error::Internal(anyhow!("Failed to deserialize address: {e}")))
231            })
232            .collect()
233    }
234
235    async fn calculate_rewards_batch(&self, staked_sui_ids: &[Address]) -> Result<Vec<u64>, Error> {
236        let mut ptb = proto::ProgrammableTransaction::default()
237            .with_inputs(vec![proto::Input::default().with_object_id("0x5")]);
238        let system_object = proto::Argument::new_input(0);
239
240        for id in staked_sui_ids {
241            let staked_sui = proto::Argument::new_input(ptb.inputs.len() as u16);
242            ptb.inputs.push(proto::Input::default().with_object_id(id));
243            ptb.commands.push(
244                proto::MoveCall::default()
245                    .with_package("0x3")
246                    .with_module("sui_system")
247                    .with_function("calculate_rewards")
248                    .with_arguments(vec![system_object, staked_sui])
249                    .into(),
250            );
251        }
252
253        let transaction = proto::Transaction::default()
254            .with_kind(ptb)
255            .with_sender("0x0");
256
257        let resp = self
258            .simulate_transaction(
259                transaction,
260                false,
261                false,
262                FieldMask::from_paths([
263                    "command_outputs.return_values.value",
264                    "transaction.effects.status",
265                ]),
266            )
267            .await?;
268
269        if !resp.transaction().effects().status().success() {
270            return Err(Error::Internal(anyhow!("transaction execution failed")));
271        }
272
273        if staked_sui_ids.len() != resp.command_outputs.len() {
274            return Err(Error::Internal(anyhow!(
275                "missing transaction command_outputs"
276            )));
277        }
278
279        resp.command_outputs
280            .iter()
281            .map(|output| {
282                // At success, expect every command to guarantee a u64 returned
283                let bcs_rewards = output
284                    .return_values
285                    .first()
286                    .and_then(|o| o.value_opt())
287                    .ok_or_else(|| Error::Internal(anyhow!("missing rewards bcs")))?;
288
289                bcs::from_bytes::<u64>(bcs_rewards.value())
290                    .map_err(|e| Error::Internal(anyhow!("Failed to deserialize rewards: {e}")))
291            })
292            .collect()
293    }
294}
295
296impl From<Error> for crate::error::Error {
297    fn from(e: Error) -> Self {
298        match e {
299            Error::Internal(err) => err.into(),
300            Error::GrpcExecutionError(status) => status.into(),
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    async fn fn_client(url: Option<&str>) -> Result<Option<FullnodeClient>, Error> {
310        let registry = Registry::new();
311        let args = FullnodeArgs {
312            fullnode_rpc_url: url.map(|u| Url::parse(u).unwrap()),
313        };
314        FullnodeClient::new(None, args, &registry).await
315    }
316
317    #[tokio::test]
318    async fn no_url_means_not_configured() {
319        let client = fn_client(None).await.unwrap();
320        assert!(client.is_none());
321    }
322
323    #[tokio::test]
324    async fn http_url_creates_client() {
325        assert!(
326            fn_client(Some("http://localhost:9000"))
327                .await
328                .unwrap()
329                .is_some()
330        );
331    }
332
333    #[tokio::test]
334    async fn https_url_creates_client() {
335        assert!(
336            fn_client(Some("https://fn.example.com"))
337                .await
338                .unwrap()
339                .is_some()
340        );
341    }
342}