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;
11use prost_types::FieldMask;
12use sui_rpc::field::FieldMaskUtil;
13use sui_rpc::proto::sui::rpc::v2::{
14    Bcs, ExecuteTransactionRequest, SimulateTransactionRequest, Transaction, UserSignature,
15    simulate_transaction_request::TransactionChecks,
16};
17
18use shared_crypto::intent::{Intent, IntentMessage};
19use sui_types::base_types::SuiAddress;
20use sui_types::crypto::{DefaultHash, SignatureScheme, ToFromBytes};
21use sui_types::digests::TransactionDigest;
22use sui_types::signature::{GenericSignature, VerifyParams};
23use sui_types::signature_verification::{
24    VerifiedDigestCache, verify_sender_signed_data_message_signatures,
25};
26use sui_types::transaction::{TransactionData, TransactionDataAPI};
27
28use crate::errors::Error;
29use crate::operations::Operations;
30use crate::types::internal_operation::{PayCoin, TransactionObjectData, TryConstructTransaction};
31use crate::types::{
32    Amount, ConstructionCombineRequest, ConstructionCombineResponse, ConstructionDeriveRequest,
33    ConstructionDeriveResponse, ConstructionHashRequest, ConstructionMetadata,
34    ConstructionMetadataRequest, ConstructionMetadataResponse, ConstructionParseRequest,
35    ConstructionParseResponse, ConstructionPayloadsRequest, ConstructionPayloadsResponse,
36    ConstructionPreprocessRequest, ConstructionPreprocessResponse, ConstructionSubmitRequest,
37    InternalOperation, MetadataOptions, SignatureType, SigningPayload, TransactionIdentifier,
38    TransactionIdentifierResponse,
39};
40use crate::{OnlineServerContext, SuiEnv};
41
42// This module implements the [Mesh Construction API](https://docs.cdp.coinbase.com/mesh/mesh-api-spec/api-reference#construction)
43
44/// Derive returns the AccountIdentifier associated with a public key.
45///
46/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/derive-accountidentifier-from-publickey)
47pub async fn derive(
48    Extension(env): Extension<SuiEnv>,
49    WithRejection(Json(request), _): WithRejection<Json<ConstructionDeriveRequest>, Error>,
50) -> Result<ConstructionDeriveResponse, Error> {
51    env.check_network_identifier(&request.network_identifier)?;
52    let address: SuiAddress = request.public_key.try_into()?;
53    Ok(ConstructionDeriveResponse {
54        account_identifier: address.into(),
55    })
56}
57
58/// Payloads is called with an array of operations and the response from /construction/metadata.
59/// It returns an unsigned transaction blob and a collection of payloads that must be signed by
60/// particular AccountIdentifiers using a certain SignatureType.
61///
62/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/generate-unsigned-transaction-and-signing-payloads)
63pub async fn payloads(
64    Extension(env): Extension<SuiEnv>,
65    WithRejection(Json(request), _): WithRejection<Json<ConstructionPayloadsRequest>, Error>,
66) -> Result<ConstructionPayloadsResponse, Error> {
67    env.check_network_identifier(&request.network_identifier)?;
68    let metadata = request.metadata.ok_or(Error::MissingMetadata)?;
69    let address = metadata.sender;
70
71    let data = request
72        .operations
73        .into_internal()?
74        .try_into_data(metadata)?;
75    let intent_msg = IntentMessage::new(Intent::sui_transaction(), data);
76    let intent_msg_bytes = bcs::to_bytes(&intent_msg)?;
77
78    let mut hasher = DefaultHash::default();
79    hasher.update(bcs::to_bytes(&intent_msg).expect("Message serialization should not fail"));
80    let digest = hasher.finalize().digest;
81
82    Ok(ConstructionPayloadsResponse {
83        unsigned_transaction: Hex::from_bytes(&intent_msg_bytes),
84        payloads: vec![SigningPayload {
85            account_identifier: address.into(),
86            hex_bytes: Hex::encode(digest),
87            signature_type: Some(SignatureType::Ed25519),
88        }],
89    })
90}
91
92/// Combine creates a network-specific transaction from an unsigned transaction
93/// and an array of provided signatures.
94///
95/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/create-network-transaction-from-signatures)
96pub async fn combine(
97    Extension(env): Extension<SuiEnv>,
98    WithRejection(Json(request), _): WithRejection<Json<ConstructionCombineRequest>, Error>,
99) -> Result<ConstructionCombineResponse, Error> {
100    env.check_network_identifier(&request.network_identifier)?;
101    let unsigned_tx = request.unsigned_transaction.to_vec()?;
102    let intent_msg: IntentMessage<TransactionData> = bcs::from_bytes(&unsigned_tx)?;
103    let sig = request
104        .signatures
105        .first()
106        .ok_or_else(|| Error::MissingInput("Signature".to_string()))?;
107    let sig_bytes = sig.hex_bytes.to_vec()?;
108    let pub_key = sig.public_key.hex_bytes.to_vec()?;
109    let flag = vec![
110        match sig.signature_type {
111            SignatureType::Ed25519 => SignatureScheme::ED25519,
112            SignatureType::Ecdsa => SignatureScheme::Secp256k1,
113        }
114        .flag(),
115    ];
116
117    let signed_tx = sui_types::transaction::Transaction::from_generic_sig_data(
118        intent_msg.value,
119        vec![GenericSignature::from_bytes(
120            &[&*flag, &*sig_bytes, &*pub_key].concat(),
121        )?],
122    );
123    // TODO: this will likely fail with zklogin authenticator, since we do not know the current epoch.
124    // As long as coinbase doesn't need to use zklogin for custodial wallets this is okay.
125    let place_holder_epoch = 0;
126    verify_sender_signed_data_message_signatures(
127        &signed_tx,
128        place_holder_epoch,
129        &VerifyParams::default(),
130        Arc::new(VerifiedDigestCache::new_empty()), // no need to use cache in rosetta
131        // TODO: This will fail for tx sent from aliased addresses.
132        vec![],
133    )?;
134    let signed_tx_bytes = bcs::to_bytes(&signed_tx)?;
135
136    Ok(ConstructionCombineResponse {
137        signed_transaction: Hex::from_bytes(&signed_tx_bytes),
138    })
139}
140
141/// Submit a pre-signed transaction to the node.
142///
143/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/submit-signed-transaction)
144pub async fn submit(
145    State(context): State<OnlineServerContext>,
146    Extension(env): Extension<SuiEnv>,
147    WithRejection(Json(request), _): WithRejection<Json<ConstructionSubmitRequest>, Error>,
148) -> Result<TransactionIdentifierResponse, Error> {
149    env.check_network_identifier(&request.network_identifier)?;
150    let signed_tx: sui_types::transaction::Transaction =
151        bcs::from_bytes(&request.signed_transaction.to_vec()?)?;
152
153    let signatures = signed_tx
154        .tx_signatures()
155        .iter()
156        .map(UserSignature::from)
157        .collect();
158
159    let tx_data = signed_tx.into_data().into_inner().intent_message.value;
160    let proto_transaction =
161        Transaction::default().with_bcs(Bcs::default().with_value(bcs::to_bytes(&tx_data)?));
162
163    // According to RosettaClient.rosseta_flow() (see tests), this transaction has already passed
164    // through a dry_run with a possibly invalid budget (metadata endpoint), but the requirements
165    // are that it should pass from there and fail here.
166    let request = SimulateTransactionRequest::new(proto_transaction.clone())
167        .with_read_mask(FieldMask::from_paths(["transaction.effects.status"]))
168        .with_checks(TransactionChecks::Enabled)
169        .with_do_gas_selection(false);
170
171    let response = context
172        .client
173        .clone()
174        .execution_client()
175        .simulate_transaction(request)
176        .await?
177        .into_inner();
178
179    let effects = response.transaction().effects();
180
181    if !effects.status().success() {
182        return Err(Error::TransactionDryRunError(Box::new(
183            effects.status().error().clone(),
184        )));
185    };
186
187    let mut client = context.client.clone();
188    let mut execution_client = client.execution_client();
189
190    let exec_request = ExecuteTransactionRequest::default()
191        .with_transaction(proto_transaction)
192        .with_signatures(signatures)
193        .with_read_mask(FieldMask::from_paths(["*"]));
194
195    let grpc_response = execution_client
196        .execute_transaction(exec_request)
197        .await?
198        .into_inner();
199
200    let transaction = grpc_response.transaction();
201    let effects = transaction.effects();
202    if !effects.status().success() {
203        return Err(Error::TransactionExecutionError(Box::new(
204            effects.status().error().clone(),
205        )));
206    }
207
208    let digest = transaction
209        .digest()
210        .parse::<TransactionDigest>()
211        .map_err(|e| Error::DataError(format!("Invalid transaction digest: {}", e)))?;
212
213    Ok(TransactionIdentifierResponse {
214        transaction_identifier: TransactionIdentifier { hash: digest },
215        metadata: None,
216    })
217}
218
219/// Preprocess is called prior to /construction/payloads to construct a request for any metadata
220/// that is needed for transaction construction given (i.e. account nonce).
221///
222/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/create-request-to-fetch-metadata)
223pub async fn preprocess(
224    Extension(env): Extension<SuiEnv>,
225    WithRejection(Json(request), _): WithRejection<Json<ConstructionPreprocessRequest>, Error>,
226) -> Result<ConstructionPreprocessResponse, Error> {
227    env.check_network_identifier(&request.network_identifier)?;
228
229    let internal_operation = request.operations.into_internal()?;
230    let sender = internal_operation.sender();
231    let budget = request.metadata.and_then(|m| m.budget);
232    Ok(ConstructionPreprocessResponse {
233        options: Some(MetadataOptions {
234            internal_operation,
235            budget,
236        }),
237        required_public_keys: vec![sender.into()],
238    })
239}
240
241/// TransactionHash returns the network-specific transaction hash for a signed transaction.
242///
243/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/get-hash-of-signed-transaction)
244pub async fn hash(
245    Extension(env): Extension<SuiEnv>,
246    WithRejection(Json(request), _): WithRejection<Json<ConstructionHashRequest>, Error>,
247) -> Result<TransactionIdentifierResponse, Error> {
248    env.check_network_identifier(&request.network_identifier)?;
249    let tx_bytes = request.signed_transaction.to_vec()?;
250    let tx: sui_types::transaction::Transaction = bcs::from_bytes(&tx_bytes)?;
251
252    Ok(TransactionIdentifierResponse {
253        transaction_identifier: TransactionIdentifier { hash: *tx.digest() },
254        metadata: None,
255    })
256}
257
258/// Get any information required to construct a transaction for a specific network.
259/// For Sui, we are returning the latest object refs for all the input objects,
260/// which will be used in transaction construction.
261///
262/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/get-metadata-for-transaction-construction)
263pub async fn metadata(
264    State(mut context): State<OnlineServerContext>,
265    Extension(env): Extension<SuiEnv>,
266    WithRejection(Json(request), _): WithRejection<Json<ConstructionMetadataRequest>, Error>,
267) -> Result<ConstructionMetadataResponse, Error> {
268    env.check_network_identifier(&request.network_identifier)?;
269    let option = request.options.ok_or(Error::MissingMetadata)?;
270    let budget = option.budget;
271    let sender = option.internal_operation.sender();
272    let currency = match &option.internal_operation {
273        InternalOperation::PayCoin(PayCoin { currency, .. }) => Some(currency.clone()),
274        _ => None,
275    };
276
277    let mut gas_price = context.client.get_reference_gas_price().await?;
278    // make sure it works over epoch changes
279    gas_price += 100;
280
281    // Check operation type before moving it
282    let is_pay_sui_or_stake = matches!(
283        &option.internal_operation,
284        InternalOperation::PaySui(_) | InternalOperation::Stake(_)
285    );
286
287    let TransactionObjectData {
288        gas_coins,
289        objects,
290        party_objects,
291        total_sui_balance,
292        budget,
293    } = option
294        .internal_operation
295        .try_fetch_needed_objects(&mut context.client.clone(), Some(gas_price), budget)
296        .await?;
297
298    // For backwards compatibility during rolling deployments, populate extra_gas_coins.
299    // Old clients expect this field to be present.
300    // For PaySui/Stake: extra_gas_coins contains the coins to merge (same as objects)
301    // For PayCoin/WithdrawStake: extra_gas_coins is empty
302    let extra_gas_coins = if is_pay_sui_or_stake {
303        objects.clone()
304    } else {
305        vec![]
306    };
307
308    Ok(ConstructionMetadataResponse {
309        metadata: ConstructionMetadata {
310            sender,
311            gas_coins,
312            extra_gas_coins,
313            objects,
314            party_objects,
315            total_coin_value: total_sui_balance,
316            gas_price,
317            budget,
318            currency,
319        },
320        suggested_fee: vec![Amount::new(budget as i128, None)],
321    })
322}
323
324///  This is run as a sanity check before signing (after /construction/payloads)
325/// and before broadcast (after /construction/combine).
326///
327/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/parse-transaction)
328pub async fn parse(
329    Extension(env): Extension<SuiEnv>,
330    WithRejection(Json(request), _): WithRejection<Json<ConstructionParseRequest>, Error>,
331) -> Result<ConstructionParseResponse, Error> {
332    env.check_network_identifier(&request.network_identifier)?;
333
334    let (data, sender) = if request.signed {
335        let tx: sui_types::transaction::Transaction =
336            bcs::from_bytes(&request.transaction.to_vec()?)?;
337        let intent = tx.into_data().intent_message().value.clone();
338        let sender = intent.sender();
339        (intent, sender)
340    } else {
341        let intent: IntentMessage<TransactionData> =
342            bcs::from_bytes(&request.transaction.to_vec()?)?;
343        let sender = intent.value.sender();
344        (intent.value, sender)
345    };
346    let account_identifier_signers = if request.signed {
347        vec![sender.into()]
348    } else {
349        vec![]
350    };
351    let proto_tx: Transaction = data.into();
352    let tx_kind = proto_tx
353        .kind
354        .ok_or_else(|| Error::DataError("Transaction missing kind".to_string()))?;
355    let operations = Operations::new(Operations::from_transaction(tx_kind, sender, None)?);
356    Ok(ConstructionParseResponse {
357        operations,
358        account_identifier_signers,
359        metadata: None,
360    })
361}