sui_rosetta/
construction.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::str::FromStr;
5use std::sync::Arc;
6
7use axum::extract::State;
8use axum::{Extension, Json};
9use axum_extra::extract::WithRejection;
10use fastcrypto::encoding::{Encoding, Hex};
11use fastcrypto::hash::HashFunction;
12use prost_types::FieldMask;
13use sui_rpc::field::FieldMaskUtil;
14use sui_rpc::proto::sui::rpc::v2::{
15    ExecuteTransactionRequest, SimulateTransactionRequest, UserSignature,
16    simulate_transaction_request::TransactionChecks,
17};
18
19use shared_crypto::intent::{Intent, IntentMessage};
20use sui_types::base_types::SuiAddress;
21use sui_types::crypto::{DefaultHash, SignatureScheme, ToFromBytes};
22use sui_types::digests::TransactionDigest;
23use sui_types::signature::{GenericSignature, VerifyParams};
24use sui_types::signature_verification::{
25    VerifiedDigestCache, verify_sender_signed_data_message_signatures,
26};
27use sui_types::transaction::TransactionDataAPI;
28
29use crate::errors::Error;
30use crate::operations::reconstruct_operations;
31use crate::types::internal_operation::{PayCoin, TransactionObjectData, TryConstructTransaction};
32use crate::types::transaction_envelope;
33use crate::types::{
34    Amount, AuxData, ConstructionCombineRequest, ConstructionCombineResponse,
35    ConstructionDeriveRequest, ConstructionDeriveResponse, ConstructionHashRequest,
36    ConstructionMetadata, ConstructionMetadataRequest, ConstructionMetadataResponse,
37    ConstructionParseRequest, ConstructionParseResponse, ConstructionPayloadsRequest,
38    ConstructionPayloadsResponse, ConstructionPreprocessRequest, ConstructionPreprocessResponse,
39    ConstructionSubmitRequest, InternalOperation, MetadataOptions, RosettaTransaction,
40    SignatureType, SigningPayload, TransactionIdentifier, TransactionIdentifierResponse,
41};
42use crate::{OnlineServerContext, SuiEnv};
43use move_core_types::language_storage::TypeTag;
44
45// This module implements the [Mesh Construction API](https://docs.cdp.coinbase.com/mesh/mesh-api-spec/api-reference#construction)
46
47/// Derive returns the AccountIdentifier associated with a public key.
48///
49/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/derive-accountidentifier-from-publickey)
50pub async fn derive(
51    Extension(env): Extension<SuiEnv>,
52    WithRejection(Json(request), _): WithRejection<Json<ConstructionDeriveRequest>, Error>,
53) -> Result<ConstructionDeriveResponse, Error> {
54    env.check_network_identifier(&request.network_identifier)?;
55    let address: SuiAddress = request.public_key.try_into()?;
56    Ok(ConstructionDeriveResponse {
57        account_identifier: address.into(),
58    })
59}
60
61/// Payloads is called with an array of operations and the response from /construction/metadata.
62/// It returns an unsigned transaction blob and a collection of payloads that must be signed by
63/// particular AccountIdentifiers using a certain SignatureType.
64///
65/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/generate-unsigned-transaction-and-signing-payloads)
66pub async fn payloads(
67    Extension(env): Extension<SuiEnv>,
68    WithRejection(Json(request), _): WithRejection<Json<ConstructionPayloadsRequest>, Error>,
69) -> Result<ConstructionPayloadsResponse, Error> {
70    env.check_network_identifier(&request.network_identifier)?;
71    let metadata = request.metadata.ok_or(Error::MissingMetadata)?;
72
73    let internal = request.operations.into_internal()?;
74    let data = internal.clone().try_into_data(metadata)?;
75    let wrapper = RosettaTransaction {
76        transaction: transaction_envelope::encode_inner_proto(&data),
77        signatures: vec![],
78        aux: internal.aux(),
79    };
80    let unsigned = transaction_envelope::encode(&wrapper)?;
81
82    let sender = data.sender();
83    let intent_msg = IntentMessage::new(Intent::sui_transaction(), data);
84    let mut hasher = DefaultHash::default();
85    bcs::serialize_into(&mut hasher, &intent_msg).expect("Message serialization should not fail");
86    let digest = hasher.finalize().digest;
87
88    Ok(ConstructionPayloadsResponse {
89        unsigned_transaction: unsigned,
90        payloads: vec![SigningPayload {
91            account_identifier: sender.into(),
92            hex_bytes: Hex::encode(digest),
93            signature_type: Some(SignatureType::Ed25519),
94        }],
95    })
96}
97
98/// Combine creates a network-specific transaction from an unsigned transaction
99/// and an array of provided signatures.
100///
101/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/create-network-transaction-from-signatures)
102pub async fn combine(
103    Extension(env): Extension<SuiEnv>,
104    WithRejection(Json(request), _): WithRejection<Json<ConstructionCombineRequest>, Error>,
105) -> Result<ConstructionCombineResponse, Error> {
106    env.check_network_identifier(&request.network_identifier)?;
107
108    let unsigned = transaction_envelope::decode(&request.unsigned_transaction)?;
109    let proto = transaction_envelope::decode_inner_proto(&unsigned.transaction)?;
110    let data = transaction_envelope::proto_to_transaction_data(proto)?;
111
112    let sig = request
113        .signatures
114        .first()
115        .ok_or_else(|| Error::MissingInput("Signature".to_string()))?;
116    let sig_bytes = sig.hex_bytes.to_vec()?;
117    let pub_key = sig.public_key.hex_bytes.to_vec()?;
118    let flag = vec![
119        match sig.signature_type {
120            SignatureType::Ed25519 => SignatureScheme::ED25519,
121            SignatureType::Ecdsa => SignatureScheme::Secp256k1,
122        }
123        .flag(),
124    ];
125    let generic_sig_bytes = [&*flag, &*sig_bytes, &*pub_key].concat();
126    let generic_sig = GenericSignature::from_bytes(&generic_sig_bytes)?;
127
128    let signed_tx =
129        sui_types::transaction::Transaction::from_generic_sig_data(data, vec![generic_sig]);
130    // TODO: this will likely fail with zklogin authenticator, since we do not know the current epoch.
131    // As long as coinbase doesn't need to use zklogin for custodial wallets this is okay.
132    let place_holder_epoch = 0;
133    verify_sender_signed_data_message_signatures(
134        &signed_tx,
135        place_holder_epoch,
136        &VerifyParams::default(),
137        Arc::new(VerifiedDigestCache::new_empty()), // no need to use cache in rosetta
138        // TODO: This will fail for tx sent from aliased addresses.
139        vec![],
140    )?;
141
142    // Pass the unchanged proto bytes and aux data through to the signed
143    // wrapper. Signatures live alongside; the inner transaction (and its
144    // aux data) is identical to what came in.
145    let signed_wrapper = RosettaTransaction {
146        transaction: unsigned.transaction,
147        signatures: vec![Hex::from_bytes(&generic_sig_bytes)],
148        aux: unsigned.aux,
149    };
150
151    Ok(ConstructionCombineResponse {
152        signed_transaction: transaction_envelope::encode(&signed_wrapper)?,
153    })
154}
155
156/// Submit a pre-signed transaction to the node.
157///
158/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/submit-signed-transaction)
159pub async fn submit(
160    State(context): State<OnlineServerContext>,
161    Extension(env): Extension<SuiEnv>,
162    WithRejection(Json(request), _): WithRejection<Json<ConstructionSubmitRequest>, Error>,
163) -> Result<TransactionIdentifierResponse, Error> {
164    env.check_network_identifier(&request.network_identifier)?;
165
166    let wrapper = transaction_envelope::decode(&request.signed_transaction)?;
167    if wrapper.signatures.is_empty() {
168        return Err(Error::DataError(
169            "cannot submit an unsigned transaction: wrapper carries no signatures".to_string(),
170        ));
171    }
172    // The wire-form proto has structured fields populated and bcs cleared,
173    // and gRPC accepts the structured form directly.
174    let proto_transaction = transaction_envelope::decode_inner_proto(&wrapper.transaction)?;
175
176    // Carry signatures straight from the wrapper.
177    let signatures = wrapper
178        .signatures
179        .iter()
180        .map(|sig_hex| {
181            let bytes = sig_hex.to_vec()?;
182            let generic = GenericSignature::from_bytes(&bytes)?;
183            Ok::<UserSignature, Error>(UserSignature::from(&generic))
184        })
185        .collect::<Result<Vec<_>, Error>>()?;
186
187    // According to RosettaClient.rosseta_flow() (see tests), this transaction has already passed
188    // through a dry_run with a possibly invalid budget (metadata endpoint), but the requirements
189    // are that it should pass from there and fail here.
190    //
191    // The balance-change read mask lets us verify, online, that a PayCoin
192    // wrapper's currency label matches the coin the transaction actually
193    // moves — the one part of the aux data that is fundamentally
194    // unverifiable offline (the source coin's on-chain type is not in the PTB).
195    let request = SimulateTransactionRequest::new(proto_transaction.clone())
196        .with_read_mask(FieldMask::from_paths([
197            "transaction.effects.status",
198            "transaction.balance_changes",
199        ]))
200        .with_checks(TransactionChecks::Enabled)
201        .with_do_gas_selection(false);
202
203    let response = context
204        .client
205        .clone()
206        .execution_client()
207        .simulate_transaction(request)
208        .await?
209        .into_inner();
210
211    let effects = response.transaction().effects();
212
213    if !effects.status().success() {
214        return Err(Error::TransactionDryRunError(Box::new(
215            effects.status().error().clone(),
216        )));
217    };
218
219    // Close the offline label-vs-reality gap (§7.7): if the wrapper claims
220    // a PayCoin currency, require the simulated balance changes to contain a
221    // non-SUI delta of that exact coin type. Otherwise the currency label
222    // disagrees with what the transaction actually moves — reject before
223    // broadcast. FSS validator / AtMost-cap online verification is deferred for
224    // v1 (those labels are display-only — the signed PTB determines execution,
225    // and `/block` re-derives the truth from chain).
226    verify_pay_coin_currency(
227        &wrapper.aux,
228        response
229            .transaction()
230            .balance_changes()
231            .iter()
232            .map(|bc| bc.coin_type()),
233    )?;
234
235    let mut client = context.client.clone();
236    let mut execution_client = client.execution_client();
237
238    let exec_request = ExecuteTransactionRequest::default()
239        .with_transaction(proto_transaction)
240        .with_signatures(signatures)
241        .with_read_mask(FieldMask::from_paths(["*"]));
242
243    let grpc_response = execution_client
244        .execute_transaction(exec_request)
245        .await?
246        .into_inner();
247
248    let transaction = grpc_response.transaction();
249    let effects = transaction.effects();
250    if !effects.status().success() {
251        return Err(Error::TransactionExecutionError(Box::new(
252            effects.status().error().clone(),
253        )));
254    }
255
256    let digest = transaction
257        .digest()
258        .parse::<TransactionDigest>()
259        .map_err(|e| Error::DataError(format!("Invalid transaction digest: {}", e)))?;
260
261    Ok(TransactionIdentifierResponse {
262        transaction_identifier: TransactionIdentifier { hash: digest },
263        metadata: None,
264    })
265}
266
267/// Preprocess is called prior to /construction/payloads to construct a request for any metadata
268/// that is needed for transaction construction given (i.e. account nonce).
269///
270/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/create-request-to-fetch-metadata)
271pub async fn preprocess(
272    Extension(env): Extension<SuiEnv>,
273    WithRejection(Json(request), _): WithRejection<Json<ConstructionPreprocessRequest>, Error>,
274) -> Result<ConstructionPreprocessResponse, Error> {
275    env.check_network_identifier(&request.network_identifier)?;
276
277    let internal_operation = request.operations.into_internal()?;
278    let sender = internal_operation.sender();
279    let budget = request.metadata.and_then(|m| m.budget);
280    Ok(ConstructionPreprocessResponse {
281        options: Some(MetadataOptions {
282            internal_operation,
283            budget,
284        }),
285        required_public_keys: vec![sender.into()],
286    })
287}
288
289/// TransactionHash returns the network-specific transaction hash for a signed transaction.
290///
291/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/get-hash-of-signed-transaction)
292pub async fn hash(
293    Extension(env): Extension<SuiEnv>,
294    WithRejection(Json(request), _): WithRejection<Json<ConstructionHashRequest>, Error>,
295) -> Result<TransactionIdentifierResponse, Error> {
296    env.check_network_identifier(&request.network_identifier)?;
297
298    let wrapper = transaction_envelope::decode(&request.signed_transaction)?;
299    if wrapper.signatures.is_empty() {
300        return Err(Error::DataError(
301            "cannot hash an unsigned transaction: wrapper carries no signatures".to_string(),
302        ));
303    }
304    let proto = transaction_envelope::decode_inner_proto(&wrapper.transaction)?;
305    let data = transaction_envelope::proto_to_transaction_data(proto)?;
306
307    // sui_types::transaction::Transaction::digest() is a hash over
308    // bcs(TransactionData) with the intent prefix — signatures don't affect it.
309    // Reconstruct the signatures purely to satisfy the constructor; the digest
310    // would be identical with a dummy signature too.
311    let signatures = wrapper
312        .signatures
313        .iter()
314        .map(|sig_hex| {
315            let bytes = sig_hex.to_vec()?;
316            GenericSignature::from_bytes(&bytes).map_err(Error::from)
317        })
318        .collect::<Result<Vec<_>, Error>>()?;
319    let tx = sui_types::transaction::Transaction::from_generic_sig_data(data, signatures);
320
321    Ok(TransactionIdentifierResponse {
322        transaction_identifier: TransactionIdentifier { hash: *tx.digest() },
323        metadata: None,
324    })
325}
326
327/// Get any information required to construct a transaction for a specific network.
328/// For Sui, we are returning the latest object refs for all the input objects,
329/// which will be used in transaction construction.
330///
331/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/get-metadata-for-transaction-construction)
332pub async fn metadata(
333    State(mut context): State<OnlineServerContext>,
334    Extension(env): Extension<SuiEnv>,
335    WithRejection(Json(request), _): WithRejection<Json<ConstructionMetadataRequest>, Error>,
336) -> Result<ConstructionMetadataResponse, Error> {
337    env.check_network_identifier(&request.network_identifier)?;
338    let option = request.options.ok_or(Error::MissingMetadata)?;
339    let budget = option.budget;
340    let sender = option.internal_operation.sender();
341    let currency = match &option.internal_operation {
342        InternalOperation::PayCoin(PayCoin { currency, .. }) => Some(currency.clone()),
343        _ => None,
344    };
345
346    let mut gas_price = context.client.get_reference_gas_price().await?;
347    // make sure it works over epoch changes
348    gas_price += 100;
349
350    // Check operation type before moving it
351    let is_pay_sui_or_stake = matches!(
352        &option.internal_operation,
353        InternalOperation::PaySui(_) | InternalOperation::Stake(_)
354    );
355
356    let needed_objects = option
357        .internal_operation
358        .try_fetch_needed_objects(&mut context.client.clone(), Some(gas_price), budget)
359        .await?;
360
361    // Gasless ("free tier") PayCoin: zero the gas price so the downstream tx is recognized as
362    // gasless (`is_gasless_transaction` requires `price == 0`). Priced address-balance gas keeps
363    // `gas_price > 0`.
364    if needed_objects.is_gasless() {
365        gas_price = 0;
366    }
367
368    let TransactionObjectData {
369        gas_coins,
370        objects,
371        party_objects,
372        total_sui_balance,
373        budget,
374        address_balance_withdrawal,
375        fss_object_count,
376        redeem_token_amount,
377        redeem_plan,
378        bind_epoch,
379    } = needed_objects;
380
381    // For backwards compatibility during rolling deployments, populate extra_gas_coins.
382    // Old clients expect this field to be present.
383    // For PaySui/Stake: extra_gas_coins contains the coins to merge (same as objects)
384    // For PayCoin/WithdrawStake: extra_gas_coins is empty
385    let extra_gas_coins = if is_pay_sui_or_stake {
386        objects.clone()
387    } else {
388        vec![]
389    };
390
391    // Fetch epoch and chain_id for address-balance gas transactions.
392    //
393    // Prefer `bind_epoch` (atomic with the rate snapshot from
394    // `get_validator_set_snapshot`) over a separate `get_current_epoch` RPC.
395    // If both are needed and `bind_epoch` is set, reusing it both saves an
396    // RPC and guarantees `metadata.epoch == bind_epoch`. Without this, an
397    // epoch transition between the two RPCs would leave them disagreeing,
398    // causing the bind-epoch mismatch check at signing time to reject the
399    // metadata even though both reads were individually valid.
400    let needs_address_balance_metadata = gas_coins.is_empty() || address_balance_withdrawal > 0;
401    let (epoch, chain_id) = if needs_address_balance_metadata {
402        let epoch = match bind_epoch {
403            Some(e) => e,
404            None => crate::get_current_epoch(&mut context.client.clone()).await?,
405        };
406        let chain_id_str =
407            sui_types::digests::CheckpointDigest::new(*context.chain_id.as_bytes()).base58_encode();
408        (Some(epoch), Some(chain_id_str))
409    } else {
410        (None, None)
411    };
412
413    Ok(ConstructionMetadataResponse {
414        metadata: ConstructionMetadata {
415            sender,
416            gas_coins,
417            extra_gas_coins,
418            objects,
419            party_objects,
420            total_coin_value: total_sui_balance,
421            gas_price,
422            budget,
423            currency,
424            address_balance_withdrawal,
425            epoch,
426            chain_id,
427            fss_object_count,
428            redeem_token_amount,
429            redeem_plan,
430            bind_epoch,
431        },
432        suggested_fee: vec![Amount::new(budget as i128, None)],
433    })
434}
435
436///  This is run as a sanity check before signing (after /construction/payloads)
437/// and before broadcast (after /construction/combine).
438///
439/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/parse-transaction)
440pub async fn parse(
441    Extension(env): Extension<SuiEnv>,
442    WithRejection(Json(request), _): WithRejection<Json<ConstructionParseRequest>, Error>,
443) -> Result<ConstructionParseResponse, Error> {
444    env.check_network_identifier(&request.network_identifier)?;
445
446    // /parse reconstructs operations *from the transaction* (the same parser
447    // the indexing/`/block` path uses), then applies the wrapper's aux data
448    // (the labels the PTB cannot encode). The PTB fields are signature-covered;
449    // the aux-data labels are server-supplied (PayCoin currency is verified
450    // online in `/submit`, FSS labels are display-only).
451    let wrapper = transaction_envelope::decode(&request.transaction)?;
452    let (aux, transaction, signed) = (wrapper.aux, wrapper.transaction, request.signed);
453
454    let proto = transaction_envelope::decode_inner_proto(&transaction)?;
455    let operations = reconstruct_operations(&proto, &aux, None)?;
456
457    // Signers come from the transaction sender, never the aux data.
458    let account_identifier_signers = if signed {
459        vec![
460            SuiAddress::from_str(proto.sender())
461                .map_err(|e| Error::DataError(format!("invalid transaction sender: {e}")))?
462                .into(),
463        ]
464    } else {
465        vec![]
466    };
467
468    // Force a full `TransactionData` decode so envelopes with a valid-looking
469    // `kind` but malformed gas payment / expiration / etc. are rejected here
470    // rather than at `/hash` / `/combine` / `/submit`. `/parse` is the spec's
471    // sanity check; it must surface structural decode failures the same way
472    // the downstream endpoints would.
473    let _ = transaction_envelope::proto_to_transaction_data(proto)?;
474
475    Ok(ConstructionParseResponse {
476        operations,
477        account_identifier_signers,
478        metadata: None,
479    })
480}
481
482/// For a `PayCoin` aux-data label, require that the transaction's (simulated)
483/// balance changes actually move a non-SUI coin of the labelled type. This is
484/// the one part of the aux data that cannot be verified offline — the source
485/// coin's on-chain type is not encoded in the PTB — so it is checked online in
486/// `/submit` against the simulate response. Non-`PayCoin` aux data is a
487/// no-op.
488fn verify_pay_coin_currency<'a>(
489    aux: &AuxData,
490    moved_coin_types: impl IntoIterator<Item = &'a str>,
491) -> Result<(), Error> {
492    let AuxData::PayCoin { currency } = aux else {
493        return Ok(());
494    };
495    let want = TypeTag::from_str(&currency.metadata.coin_type)
496        .map_err(|e| Error::DataError(format!("invalid PayCoin currency coin_type: {e}")))?;
497    let sui = TypeTag::from_str("0x2::sui::SUI").expect("0x2::sui::SUI is a valid type tag");
498    let matched = moved_coin_types.into_iter().any(|ct| {
499        TypeTag::from_str(ct)
500            .map(|t| t == want && t != sui)
501            .unwrap_or(false)
502    });
503    if matched {
504        Ok(())
505    } else {
506        Err(Error::DataError(format!(
507            "PayCoin currency {} does not match any non-SUI balance change in the simulated \
508             transaction",
509            currency.metadata.coin_type
510        )))
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use crate::types::{Currency, CurrencyMetadata};
518
519    fn pay_coin(coin_type: &str) -> AuxData {
520        AuxData::PayCoin {
521            currency: Currency {
522                symbol: "USDC".to_string(),
523                decimals: 6,
524                metadata: CurrencyMetadata {
525                    coin_type: coin_type.to_string(),
526                },
527            },
528        }
529    }
530
531    /// §12 test 14: the `/submit` PayCoin currency check accepts a balance
532    /// change of the labelled coin type and rejects when only SUI / a
533    /// different coin moves.
534    #[test]
535    fn test_verify_pay_coin_currency() {
536        let usdc = "0x5::usdc::USDC";
537        let aux = pay_coin(usdc);
538
539        // Labelled coin present among the balance changes → ok.
540        assert!(verify_pay_coin_currency(&aux, ["0x2::sui::SUI", usdc]).is_ok());
541
542        // A different non-SUI coin moves (currency label disagrees with
543        // reality) → reject.
544        let err = verify_pay_coin_currency(&aux, ["0x2::sui::SUI", "0x9::other::OTHER"])
545            .expect_err("mismatched currency must be rejected");
546        assert!(format!("{err:?}").contains("does not match"));
547
548        // Only SUI moves → reject.
549        assert!(verify_pay_coin_currency(&aux, ["0x2::sui::SUI"]).is_err());
550
551        // A PayCoin label that (wrongly) names SUI never matches — SUI is
552        // explicitly excluded as a non-SUI delta.
553        assert!(verify_pay_coin_currency(&pay_coin("0x2::sui::SUI"), ["0x2::sui::SUI"]).is_err());
554
555        // Non-PayCoin aux data is a no-op regardless of balance changes.
556        assert!(verify_pay_coin_currency(&AuxData::None, std::iter::empty()).is_ok());
557    }
558}