1use 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, insufficient_gas_required_budget};
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
45pub 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
61pub 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 if metadata.estimate_only {
73 return Err(Error::InvalidInput(
74 "metadata from an `estimate_only` request is a gas quote and cannot be used to build \
75 a transaction; call /construction/metadata without `estimate_only`"
76 .to_string(),
77 ));
78 }
79
80 let internal = request.operations.into_internal()?;
81 let data = internal.clone().try_into_data(metadata)?;
82 let wrapper = RosettaTransaction {
83 transaction: transaction_envelope::encode_inner_proto(&data),
84 signatures: vec![],
85 aux: internal.aux(),
86 };
87 let unsigned = transaction_envelope::encode(&wrapper)?;
88
89 let sender = data.sender();
90 let intent_msg = IntentMessage::new(Intent::sui_transaction(), data);
91 let mut hasher = DefaultHash::default();
92 bcs::serialize_into(&mut hasher, &intent_msg).expect("Message serialization should not fail");
93 let digest = hasher.finalize().digest;
94
95 Ok(ConstructionPayloadsResponse {
96 unsigned_transaction: unsigned,
97 payloads: vec![SigningPayload {
98 account_identifier: sender.into(),
99 hex_bytes: Hex::encode(digest),
100 signature_type: Some(SignatureType::Ed25519),
101 }],
102 })
103}
104
105pub async fn combine(
110 Extension(env): Extension<SuiEnv>,
111 WithRejection(Json(request), _): WithRejection<Json<ConstructionCombineRequest>, Error>,
112) -> Result<ConstructionCombineResponse, Error> {
113 env.check_network_identifier(&request.network_identifier)?;
114
115 let unsigned = transaction_envelope::decode(&request.unsigned_transaction)?;
116 let proto = transaction_envelope::decode_inner_proto(&unsigned.transaction)?;
117 let data = transaction_envelope::proto_to_transaction_data(proto)?;
118
119 let sig = request
120 .signatures
121 .first()
122 .ok_or_else(|| Error::MissingInput("Signature".to_string()))?;
123 let sig_bytes = sig.hex_bytes.to_vec()?;
124 let pub_key = sig.public_key.hex_bytes.to_vec()?;
125 let flag = vec![
126 match sig.signature_type {
127 SignatureType::Ed25519 => SignatureScheme::ED25519,
128 SignatureType::Ecdsa => SignatureScheme::Secp256k1,
129 }
130 .flag(),
131 ];
132 let generic_sig_bytes = [&*flag, &*sig_bytes, &*pub_key].concat();
133 let generic_sig = GenericSignature::from_bytes(&generic_sig_bytes)?;
134
135 let signed_tx =
136 sui_types::transaction::Transaction::from_generic_sig_data(data, vec![generic_sig]);
137 let place_holder_epoch = 0;
140 verify_sender_signed_data_message_signatures(
141 &signed_tx,
142 place_holder_epoch,
143 &VerifyParams::default(),
144 Arc::new(VerifiedDigestCache::new_empty()), vec![],
147 )?;
148
149 let signed_wrapper = RosettaTransaction {
153 transaction: unsigned.transaction,
154 signatures: vec![Hex::from_bytes(&generic_sig_bytes)],
155 aux: unsigned.aux,
156 };
157
158 Ok(ConstructionCombineResponse {
159 signed_transaction: transaction_envelope::encode(&signed_wrapper)?,
160 })
161}
162
163pub async fn submit(
167 State(context): State<OnlineServerContext>,
168 Extension(env): Extension<SuiEnv>,
169 WithRejection(Json(request), _): WithRejection<Json<ConstructionSubmitRequest>, Error>,
170) -> Result<TransactionIdentifierResponse, Error> {
171 env.check_network_identifier(&request.network_identifier)?;
172
173 let wrapper = transaction_envelope::decode(&request.signed_transaction)?;
174 if wrapper.signatures.is_empty() {
175 return Err(Error::DataError(
176 "cannot submit an unsigned transaction: wrapper carries no signatures".to_string(),
177 ));
178 }
179 let proto_transaction = transaction_envelope::decode_inner_proto(&wrapper.transaction)?;
182
183 let signatures = wrapper
185 .signatures
186 .iter()
187 .map(|sig_hex| {
188 let bytes = sig_hex.to_vec()?;
189 let generic = GenericSignature::from_bytes(&bytes)?;
190 Ok::<UserSignature, Error>(UserSignature::from(&generic))
191 })
192 .collect::<Result<Vec<_>, Error>>()?;
193
194 let request = SimulateTransactionRequest::new(proto_transaction.clone())
203 .with_read_mask(FieldMask::from_paths([
204 "transaction.effects.status",
205 "transaction.balance_changes",
206 ]))
207 .with_checks(TransactionChecks::Enabled)
208 .with_do_gas_selection(false);
209
210 let response = context
211 .client
212 .clone()
213 .execution_client()
214 .simulate_transaction(request)
215 .await?
216 .into_inner();
217
218 let effects = response.transaction().effects();
219
220 if !effects.status().success() {
221 return Err(Error::TransactionDryRunError(Box::new(
222 effects.status().error().clone(),
223 )));
224 };
225
226 verify_pay_coin_currency(
234 &wrapper.aux,
235 response
236 .transaction()
237 .balance_changes()
238 .iter()
239 .map(|bc| bc.coin_type()),
240 )?;
241
242 let mut client = context.client.clone();
243 let mut execution_client = client.execution_client();
244
245 let exec_request = ExecuteTransactionRequest::default()
246 .with_transaction(proto_transaction)
247 .with_signatures(signatures)
248 .with_read_mask(FieldMask::from_paths(["*"]));
249
250 let grpc_response = execution_client
251 .execute_transaction(exec_request)
252 .await?
253 .into_inner();
254
255 let transaction = grpc_response.transaction();
256 let effects = transaction.effects();
257 if !effects.status().success() {
258 return Err(Error::TransactionExecutionError(Box::new(
259 effects.status().error().clone(),
260 )));
261 }
262
263 let digest = transaction
264 .digest()
265 .parse::<TransactionDigest>()
266 .map_err(|e| Error::DataError(format!("Invalid transaction digest: {}", e)))?;
267
268 Ok(TransactionIdentifierResponse {
269 transaction_identifier: TransactionIdentifier { hash: digest },
270 metadata: None,
271 })
272}
273
274pub async fn preprocess(
279 Extension(env): Extension<SuiEnv>,
280 WithRejection(Json(request), _): WithRejection<Json<ConstructionPreprocessRequest>, Error>,
281) -> Result<ConstructionPreprocessResponse, Error> {
282 env.check_network_identifier(&request.network_identifier)?;
283
284 let internal_operation = request.operations.into_internal()?;
285 let sender = internal_operation.sender();
286 let (budget, estimate_only) = request
287 .metadata
288 .map_or((None, false), |m| (m.budget, m.estimate_only));
289 check_estimate_only_without_budget(estimate_only, budget)?;
290 Ok(ConstructionPreprocessResponse {
291 options: Some(MetadataOptions {
292 internal_operation,
293 budget,
294 estimate_only,
295 }),
296 required_public_keys: vec![sender.into()],
297 })
298}
299
300pub async fn hash(
304 Extension(env): Extension<SuiEnv>,
305 WithRejection(Json(request), _): WithRejection<Json<ConstructionHashRequest>, Error>,
306) -> Result<TransactionIdentifierResponse, Error> {
307 env.check_network_identifier(&request.network_identifier)?;
308
309 let wrapper = transaction_envelope::decode(&request.signed_transaction)?;
310 if wrapper.signatures.is_empty() {
311 return Err(Error::DataError(
312 "cannot hash an unsigned transaction: wrapper carries no signatures".to_string(),
313 ));
314 }
315 let proto = transaction_envelope::decode_inner_proto(&wrapper.transaction)?;
316 let data = transaction_envelope::proto_to_transaction_data(proto)?;
317
318 let signatures = wrapper
323 .signatures
324 .iter()
325 .map(|sig_hex| {
326 let bytes = sig_hex.to_vec()?;
327 GenericSignature::from_bytes(&bytes).map_err(Error::from)
328 })
329 .collect::<Result<Vec<_>, Error>>()?;
330 let tx = sui_types::transaction::Transaction::from_generic_sig_data(data, signatures);
331
332 Ok(TransactionIdentifierResponse {
333 transaction_identifier: TransactionIdentifier { hash: *tx.digest() },
334 metadata: None,
335 })
336}
337
338pub async fn metadata(
344 State(mut context): State<OnlineServerContext>,
345 Extension(env): Extension<SuiEnv>,
346 WithRejection(Json(request), _): WithRejection<Json<ConstructionMetadataRequest>, Error>,
347) -> Result<ConstructionMetadataResponse, Error> {
348 env.check_network_identifier(&request.network_identifier)?;
349 let option = request.options.ok_or(Error::MissingMetadata)?;
350 let budget = option.budget;
351 let estimate_only = option.estimate_only;
352 check_estimate_only_without_budget(estimate_only, budget)?;
353 let sender = option.internal_operation.sender();
354 let currency = match &option.internal_operation {
355 InternalOperation::PayCoin(PayCoin { currency, .. }) => Some(currency.clone()),
356 _ => None,
357 };
358
359 let mut gas_price = context.client.get_reference_gas_price().await?;
360 gas_price += 100;
362
363 let is_pay_sui_or_stake = matches!(
365 &option.internal_operation,
366 InternalOperation::PaySui(_) | InternalOperation::Stake(_)
367 );
368
369 let fetched = option
370 .internal_operation
371 .try_fetch_needed_objects(&mut context.client.clone(), Some(gas_price), budget)
372 .await;
373
374 if estimate_only
379 && let Err(e) = &fetched
380 && let Some(required_budget) = insufficient_gas_required_budget(e)
381 {
382 return Ok(ConstructionMetadataResponse {
383 metadata: ConstructionMetadata {
384 sender,
385 gas_coins: vec![],
386 extra_gas_coins: vec![],
387 objects: vec![],
388 party_objects: vec![],
389 total_coin_value: 0,
390 gas_price,
391 budget: required_budget,
392 currency,
393 address_balance_withdrawal: 0,
394 epoch: None,
395 chain_id: None,
396 nonce: None,
397 fss_object_count: None,
398 redeem_token_amount: None,
399 redeem_plan: None,
400 bind_epoch: None,
401 estimate_only,
402 },
403 suggested_fee: vec![Amount::new(required_budget as i128, None)],
404 });
405 }
406 let needed_objects = fetched?;
407
408 if needed_objects.is_gasless() {
412 gas_price = 0;
413 }
414
415 let TransactionObjectData {
416 gas_coins,
417 objects,
418 party_objects,
419 total_sui_balance,
420 budget,
421 address_balance_withdrawal,
422 fss_object_count,
423 redeem_token_amount,
424 redeem_plan,
425 bind_epoch,
426 } = needed_objects;
427
428 let extra_gas_coins = if is_pay_sui_or_stake {
433 objects.clone()
434 } else {
435 vec![]
436 };
437
438 let nonce = if gas_coins.is_empty() {
439 Some(rand::random::<u32>())
440 } else {
441 None
442 };
443
444 let needs_address_balance_metadata = gas_coins.is_empty() || address_balance_withdrawal > 0;
454 let (epoch, chain_id) = if needs_address_balance_metadata {
455 let epoch = match bind_epoch {
456 Some(e) => e,
457 None => crate::get_current_epoch(&mut context.client.clone()).await?,
458 };
459 let chain_id_str =
460 sui_types::digests::CheckpointDigest::new(*context.chain_id.as_bytes()).base58_encode();
461 (Some(epoch), Some(chain_id_str))
462 } else {
463 (None, None)
464 };
465
466 Ok(ConstructionMetadataResponse {
467 metadata: ConstructionMetadata {
468 sender,
469 gas_coins,
470 extra_gas_coins,
471 objects,
472 party_objects,
473 total_coin_value: total_sui_balance,
474 gas_price,
475 budget,
476 currency,
477 address_balance_withdrawal,
478 epoch,
479 chain_id,
480 nonce,
481 fss_object_count,
482 redeem_token_amount,
483 redeem_plan,
484 bind_epoch,
485 estimate_only,
486 },
487 suggested_fee: vec![Amount::new(budget as i128, None)],
488 })
489}
490
491fn check_estimate_only_without_budget(
494 estimate_only: bool,
495 budget: Option<u64>,
496) -> Result<(), Error> {
497 if estimate_only && budget.is_some() {
498 return Err(Error::InvalidInput(
499 "`estimate_only` cannot be combined with `budget`".to_string(),
500 ));
501 }
502 Ok(())
503}
504
505pub async fn parse(
510 Extension(env): Extension<SuiEnv>,
511 WithRejection(Json(request), _): WithRejection<Json<ConstructionParseRequest>, Error>,
512) -> Result<ConstructionParseResponse, Error> {
513 env.check_network_identifier(&request.network_identifier)?;
514
515 let wrapper = transaction_envelope::decode(&request.transaction)?;
521 let (aux, transaction, signed) = (wrapper.aux, wrapper.transaction, request.signed);
522
523 let proto = transaction_envelope::decode_inner_proto(&transaction)?;
524 let operations = reconstruct_operations(&proto, &aux, None)?;
525
526 let account_identifier_signers = if signed {
528 vec![
529 SuiAddress::from_str(proto.sender())
530 .map_err(|e| Error::DataError(format!("invalid transaction sender: {e}")))?
531 .into(),
532 ]
533 } else {
534 vec![]
535 };
536
537 let _ = transaction_envelope::proto_to_transaction_data(proto)?;
543
544 Ok(ConstructionParseResponse {
545 operations,
546 account_identifier_signers,
547 metadata: None,
548 })
549}
550
551fn verify_pay_coin_currency<'a>(
558 aux: &AuxData,
559 moved_coin_types: impl IntoIterator<Item = &'a str>,
560) -> Result<(), Error> {
561 let AuxData::PayCoin { currency } = aux else {
562 return Ok(());
563 };
564 let want = TypeTag::from_str(¤cy.metadata.coin_type)
565 .map_err(|e| Error::DataError(format!("invalid PayCoin currency coin_type: {e}")))?;
566 let sui = TypeTag::from_str("0x2::sui::SUI").expect("0x2::sui::SUI is a valid type tag");
567 let matched = moved_coin_types.into_iter().any(|ct| {
568 TypeTag::from_str(ct)
569 .map(|t| t == want && t != sui)
570 .unwrap_or(false)
571 });
572 if matched {
573 Ok(())
574 } else {
575 Err(Error::DataError(format!(
576 "PayCoin currency {} does not match any non-SUI balance change in the simulated \
577 transaction",
578 currency.metadata.coin_type
579 )))
580 }
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586 use crate::types::{Currency, CurrencyMetadata};
587
588 fn pay_coin(coin_type: &str) -> AuxData {
589 AuxData::PayCoin {
590 currency: Currency {
591 symbol: "USDC".to_string(),
592 decimals: 6,
593 metadata: CurrencyMetadata {
594 coin_type: coin_type.to_string(),
595 },
596 },
597 }
598 }
599
600 #[test]
604 fn test_verify_pay_coin_currency() {
605 let usdc = "0x5::usdc::USDC";
606 let aux = pay_coin(usdc);
607
608 assert!(verify_pay_coin_currency(&aux, ["0x2::sui::SUI", usdc]).is_ok());
610
611 let err = verify_pay_coin_currency(&aux, ["0x2::sui::SUI", "0x9::other::OTHER"])
614 .expect_err("mismatched currency must be rejected");
615 assert!(format!("{err:?}").contains("does not match"));
616
617 assert!(verify_pay_coin_currency(&aux, ["0x2::sui::SUI"]).is_err());
619
620 assert!(verify_pay_coin_currency(&pay_coin("0x2::sui::SUI"), ["0x2::sui::SUI"]).is_err());
623
624 assert!(verify_pay_coin_currency(&AuxData::None, std::iter::empty()).is_ok());
626 }
627}