sui_rosetta/
construction.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::sync::Arc;
5
6use axum::extract::State;
7use axum::{Extension, Json};
8use axum_extra::extract::WithRejection;
9use fastcrypto::encoding::{Encoding, Hex};
10use fastcrypto::hash::HashFunction;
11
12use shared_crypto::intent::{Intent, IntentMessage};
13use sui_json_rpc_types::{SuiTransactionBlockEffectsAPI, SuiTransactionBlockResponseOptions};
14use sui_sdk::rpc_types::SuiExecutionStatus;
15use sui_types::base_types::SuiAddress;
16use sui_types::crypto::{DefaultHash, SignatureScheme, ToFromBytes};
17use sui_types::signature::{GenericSignature, VerifyParams};
18use sui_types::signature_verification::{
19    VerifiedDigestCache, verify_sender_signed_data_message_signatures,
20};
21use sui_types::transaction::{Transaction, TransactionData, TransactionDataAPI};
22
23use crate::errors::Error;
24use crate::types::internal_operation::{PayCoin, TransactionObjectData, TryConstructTransaction};
25use crate::types::{
26    Amount, ConstructionCombineRequest, ConstructionCombineResponse, ConstructionDeriveRequest,
27    ConstructionDeriveResponse, ConstructionHashRequest, ConstructionMetadata,
28    ConstructionMetadataRequest, ConstructionMetadataResponse, ConstructionParseRequest,
29    ConstructionParseResponse, ConstructionPayloadsRequest, ConstructionPayloadsResponse,
30    ConstructionPreprocessRequest, ConstructionPreprocessResponse, ConstructionSubmitRequest,
31    InternalOperation, MetadataOptions, SignatureType, SigningPayload, TransactionIdentifier,
32    TransactionIdentifierResponse,
33};
34use crate::{OnlineServerContext, SuiEnv};
35
36// This module implements the [Rosetta Construction API](https://www.rosetta-api.org/docs/ConstructionApi.html)
37
38/// Derive returns the AccountIdentifier associated with a public key.
39///
40/// [Rosetta API Spec](https://www.rosetta-api.org/docs/ConstructionApi.html#constructionderive)
41pub async fn derive(
42    Extension(env): Extension<SuiEnv>,
43    WithRejection(Json(request), _): WithRejection<Json<ConstructionDeriveRequest>, Error>,
44) -> Result<ConstructionDeriveResponse, Error> {
45    env.check_network_identifier(&request.network_identifier)?;
46    let address: SuiAddress = request.public_key.try_into()?;
47    Ok(ConstructionDeriveResponse {
48        account_identifier: address.into(),
49    })
50}
51
52/// Payloads is called with an array of operations and the response from /construction/metadata.
53/// It returns an unsigned transaction blob and a collection of payloads that must be signed by
54/// particular AccountIdentifiers using a certain SignatureType.
55///
56/// [Rosetta API Spec](https://www.rosetta-api.org/docs/ConstructionApi.html#constructionpayloads)
57pub async fn payloads(
58    Extension(env): Extension<SuiEnv>,
59    WithRejection(Json(request), _): WithRejection<Json<ConstructionPayloadsRequest>, Error>,
60) -> Result<ConstructionPayloadsResponse, Error> {
61    env.check_network_identifier(&request.network_identifier)?;
62    let metadata = request.metadata.ok_or(Error::MissingMetadata)?;
63    let address = metadata.sender;
64
65    let data = request
66        .operations
67        .into_internal()?
68        .try_into_data(metadata)?;
69    let intent_msg = IntentMessage::new(Intent::sui_transaction(), data);
70    let intent_msg_bytes = bcs::to_bytes(&intent_msg)?;
71
72    let mut hasher = DefaultHash::default();
73    hasher.update(bcs::to_bytes(&intent_msg).expect("Message serialization should not fail"));
74    let digest = hasher.finalize().digest;
75
76    Ok(ConstructionPayloadsResponse {
77        unsigned_transaction: Hex::from_bytes(&intent_msg_bytes),
78        payloads: vec![SigningPayload {
79            account_identifier: address.into(),
80            hex_bytes: Hex::encode(digest),
81            signature_type: Some(SignatureType::Ed25519),
82        }],
83    })
84}
85
86/// Combine creates a network-specific transaction from an unsigned transaction
87/// and an array of provided signatures.
88///
89/// [Rosetta API Spec](https://www.rosetta-api.org/docs/ConstructionApi.html#constructioncombine)
90pub async fn combine(
91    Extension(env): Extension<SuiEnv>,
92    WithRejection(Json(request), _): WithRejection<Json<ConstructionCombineRequest>, Error>,
93) -> Result<ConstructionCombineResponse, Error> {
94    env.check_network_identifier(&request.network_identifier)?;
95    let unsigned_tx = request.unsigned_transaction.to_vec()?;
96    let intent_msg: IntentMessage<TransactionData> = bcs::from_bytes(&unsigned_tx)?;
97    let sig = request
98        .signatures
99        .first()
100        .ok_or_else(|| Error::MissingInput("Signature".to_string()))?;
101    let sig_bytes = sig.hex_bytes.to_vec()?;
102    let pub_key = sig.public_key.hex_bytes.to_vec()?;
103    let flag = vec![
104        match sig.signature_type {
105            SignatureType::Ed25519 => SignatureScheme::ED25519,
106            SignatureType::Ecdsa => SignatureScheme::Secp256k1,
107        }
108        .flag(),
109    ];
110
111    let signed_tx = Transaction::from_generic_sig_data(
112        intent_msg.value,
113        vec![GenericSignature::from_bytes(
114            &[&*flag, &*sig_bytes, &*pub_key].concat(),
115        )?],
116    );
117    // TODO: this will likely fail with zklogin authenticator, since we do not know the current epoch.
118    // As long as coinbase doesn't need to use zklogin for custodial wallets this is okay.
119    let place_holder_epoch = 0;
120    verify_sender_signed_data_message_signatures(
121        &signed_tx,
122        place_holder_epoch,
123        &VerifyParams::default(),
124        Arc::new(VerifiedDigestCache::new_empty()), // no need to use cache in rosetta
125    )?;
126    let signed_tx_bytes = bcs::to_bytes(&signed_tx)?;
127
128    Ok(ConstructionCombineResponse {
129        signed_transaction: Hex::from_bytes(&signed_tx_bytes),
130    })
131}
132
133/// Submit a pre-signed transaction to the node.
134///
135/// [Rosetta API Spec](https://www.rosetta-api.org/docs/ConstructionApi.html#constructionsubmit)
136pub async fn submit(
137    State(context): State<OnlineServerContext>,
138    Extension(env): Extension<SuiEnv>,
139    WithRejection(Json(request), _): WithRejection<Json<ConstructionSubmitRequest>, Error>,
140) -> Result<TransactionIdentifierResponse, Error> {
141    env.check_network_identifier(&request.network_identifier)?;
142    let signed_tx: Transaction = bcs::from_bytes(&request.signed_transaction.to_vec()?)?;
143
144    // According to RosettaClient.rosseta_flow() (see tests), this transaction has already passed
145    // through a dry_run with a possibly invalid budget (metadata endpoint), but the requirements
146    // are that it should pass from there and fail here.
147    let tx_data = signed_tx.data().transaction_data().clone();
148    let dry_run = context
149        .client
150        .read_api()
151        .dry_run_transaction_block(tx_data)
152        .await?;
153    if let SuiExecutionStatus::Failure { error } = dry_run.effects.status() {
154        return Err(Error::TransactionDryRunError(error.clone()));
155    };
156
157    let response = context
158        .client
159        .quorum_driver_api()
160        .execute_transaction_block(
161            signed_tx,
162            SuiTransactionBlockResponseOptions::new()
163                .with_input()
164                .with_effects()
165                .with_balance_changes(),
166            None,
167        )
168        .await?;
169
170    if let SuiExecutionStatus::Failure { error } = response
171        .effects
172        .expect("Execute transaction should return effects")
173        .status()
174    {
175        return Err(Error::TransactionExecutionError(error.to_string()));
176    }
177
178    Ok(TransactionIdentifierResponse {
179        transaction_identifier: TransactionIdentifier {
180            hash: response.digest,
181        },
182        metadata: None,
183    })
184}
185
186/// Preprocess is called prior to /construction/payloads to construct a request for any metadata
187/// that is needed for transaction construction given (i.e. account nonce).
188///
189/// [Rosetta API Spec](https://www.rosetta-api.org/docs/ConstructionApi.html#constructionpreprocess)
190pub async fn preprocess(
191    Extension(env): Extension<SuiEnv>,
192    WithRejection(Json(request), _): WithRejection<Json<ConstructionPreprocessRequest>, Error>,
193) -> Result<ConstructionPreprocessResponse, Error> {
194    env.check_network_identifier(&request.network_identifier)?;
195
196    let internal_operation = request.operations.into_internal()?;
197    let sender = internal_operation.sender();
198    let budget = request.metadata.and_then(|m| m.budget);
199    Ok(ConstructionPreprocessResponse {
200        options: Some(MetadataOptions {
201            internal_operation,
202            budget,
203        }),
204        required_public_keys: vec![sender.into()],
205    })
206}
207
208/// TransactionHash returns the network-specific transaction hash for a signed transaction.
209///
210/// [Rosetta API Spec](https://www.rosetta-api.org/docs/ConstructionApi.html#constructionhash)
211pub async fn hash(
212    Extension(env): Extension<SuiEnv>,
213    WithRejection(Json(request), _): WithRejection<Json<ConstructionHashRequest>, Error>,
214) -> Result<TransactionIdentifierResponse, Error> {
215    env.check_network_identifier(&request.network_identifier)?;
216    let tx_bytes = request.signed_transaction.to_vec()?;
217    let tx: Transaction = bcs::from_bytes(&tx_bytes)?;
218
219    Ok(TransactionIdentifierResponse {
220        transaction_identifier: TransactionIdentifier { hash: *tx.digest() },
221        metadata: None,
222    })
223}
224
225/// Get any information required to construct a transaction for a specific network.
226/// For Sui, we are returning the latest object refs for all the input objects,
227/// which will be used in transaction construction.
228///
229/// [Rosetta API Spec](https://www.rosetta-api.org/docs/ConstructionApi.html#constructionmetadata)
230pub async fn metadata(
231    State(context): State<OnlineServerContext>,
232    Extension(env): Extension<SuiEnv>,
233    WithRejection(Json(request), _): WithRejection<Json<ConstructionMetadataRequest>, Error>,
234) -> Result<ConstructionMetadataResponse, Error> {
235    env.check_network_identifier(&request.network_identifier)?;
236    let option = request.options.ok_or(Error::MissingMetadata)?;
237    let budget = option.budget;
238    let sender = option.internal_operation.sender();
239    let currency = match &option.internal_operation {
240        InternalOperation::PayCoin(PayCoin { currency, .. }) => Some(currency.clone()),
241        _ => None,
242    };
243
244    let mut gas_price = context
245        .client
246        .governance_api()
247        .get_reference_gas_price()
248        .await?;
249    // make sure it works over epoch changes
250    gas_price += 100;
251
252    let TransactionObjectData {
253        gas_coins,
254        extra_gas_coins,
255        objects,
256        total_sui_balance,
257        budget,
258    } = option
259        .internal_operation
260        .try_fetch_needed_objects(&context.client, Some(gas_price), budget)
261        .await?;
262    Ok(ConstructionMetadataResponse {
263        metadata: ConstructionMetadata {
264            sender,
265            gas_coins,
266            extra_gas_coins,
267            objects,
268            total_coin_value: total_sui_balance,
269            gas_price,
270            budget,
271            currency,
272        },
273        suggested_fee: vec![Amount::new(budget as i128, None)],
274    })
275}
276
277///  This is run as a sanity check before signing (after /construction/payloads)
278/// and before broadcast (after /construction/combine).
279///
280/// [Rosetta API Spec](https://www.rosetta-api.org/docs/ConstructionApi.html#constructionparse)
281pub async fn parse(
282    Extension(env): Extension<SuiEnv>,
283    WithRejection(Json(request), _): WithRejection<Json<ConstructionParseRequest>, Error>,
284) -> Result<ConstructionParseResponse, Error> {
285    env.check_network_identifier(&request.network_identifier)?;
286
287    let data = if request.signed {
288        let tx: Transaction = bcs::from_bytes(&request.transaction.to_vec()?)?;
289        tx.into_data().intent_message().value.clone()
290    } else {
291        let intent: IntentMessage<TransactionData> =
292            bcs::from_bytes(&request.transaction.to_vec()?)?;
293        intent.value
294    };
295    let account_identifier_signers = if request.signed {
296        vec![data.sender().into()]
297    } else {
298        vec![]
299    };
300    let operations = data.try_into()?;
301    Ok(ConstructionParseResponse {
302        operations,
303        account_identifier_signers,
304        metadata: None,
305    })
306}