iota_rosetta/
construction.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2024 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5//! This module implements the [Rosetta Construction API](https://www.rosetta-api.org/docs/ConstructionApi.html).
6
7use std::sync::Arc;
8
9use axum::{Extension, Json, extract::State};
10use axum_extra::extract::WithRejection;
11use fastcrypto::{
12    encoding::{Encoding, Hex},
13    hash::HashFunction,
14};
15use futures::StreamExt;
16use iota_json_rpc_types::{
17    IotaObjectDataOptions, IotaTransactionBlockEffectsAPI, IotaTransactionBlockResponseOptions,
18    StakeStatus,
19};
20use iota_sdk::rpc_types::IotaExecutionStatus;
21use iota_types::{
22    base_types::IotaAddress,
23    crypto::{DefaultHash, SignatureScheme, ToFromBytes},
24    error::IotaError,
25    signature::{GenericSignature, VerifyParams},
26    signature_verification::{VerifiedDigestCache, verify_sender_signed_data_message_signatures},
27    transaction::{Transaction, TransactionData, TransactionDataAPI},
28};
29use shared_crypto::intent::{Intent, IntentMessage};
30
31use crate::{
32    IotaEnv, OnlineServerContext,
33    errors::Error,
34    operations::Operations,
35    types::{
36        Amount, ConstructionCombineRequest, ConstructionCombineResponse, ConstructionDeriveRequest,
37        ConstructionDeriveResponse, ConstructionHashRequest, ConstructionMetadata,
38        ConstructionMetadataRequest, ConstructionMetadataResponse, ConstructionParseRequest,
39        ConstructionParseResponse, ConstructionPayloadsRequest, ConstructionPayloadsResponse,
40        ConstructionPreprocessRequest, ConstructionPreprocessResponse, ConstructionSubmitRequest,
41        InternalOperation, MetadataOptions, SignatureType, SigningPayload, TransactionIdentifier,
42        TransactionIdentifierResponse,
43    },
44};
45
46/// Derive returns the AccountIdentifier associated with a public key.
47///
48/// [Rosetta API Spec](https://www.rosetta-api.org/docs/ConstructionApi.html#constructionderive)
49pub async fn derive(
50    Extension(env): Extension<IotaEnv>,
51    WithRejection(Json(request), _): WithRejection<Json<ConstructionDeriveRequest>, Error>,
52) -> Result<ConstructionDeriveResponse, Error> {
53    env.check_network_identifier(&request.network_identifier)?;
54    let address: IotaAddress = request.public_key.try_into()?;
55    Ok(ConstructionDeriveResponse {
56        account_identifier: address.into(),
57    })
58}
59
60/// Payloads is called with an array of operations and the response from
61/// /construction/metadata. It returns an unsigned transaction blob and a
62/// collection of payloads that must be signed by particular AccountIdentifiers
63/// using a certain SignatureType.
64///
65/// [Rosetta API Spec](https://www.rosetta-api.org/docs/ConstructionApi.html#constructionpayloads)
66pub async fn payloads(
67    Extension(env): Extension<IotaEnv>,
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    let address = metadata.sender;
73
74    let data = request
75        .operations
76        .into_internal()?
77        .try_into_data(metadata)?;
78    let intent_msg = IntentMessage::new(Intent::iota_transaction(), data);
79    let intent_msg_bytes = bcs::to_bytes(&intent_msg)?;
80
81    let mut hasher = DefaultHash::default();
82    hasher.update(bcs::to_bytes(&intent_msg).expect("Message serialization should not fail"));
83    let digest = hasher.finalize().digest;
84
85    Ok(ConstructionPayloadsResponse {
86        unsigned_transaction: Hex::from_bytes(&intent_msg_bytes),
87        payloads: vec![SigningPayload {
88            account_identifier: address.into(),
89            hex_bytes: Hex::encode(digest),
90            signature_type: Some(SignatureType::Ed25519),
91        }],
92    })
93}
94
95/// Combine creates a network-specific transaction from an unsigned transaction
96/// and an array of provided signatures.
97///
98/// [Rosetta API Spec](https://www.rosetta-api.org/docs/ConstructionApi.html#constructioncombine)
99pub async fn combine(
100    Extension(env): Extension<IotaEnv>,
101    WithRejection(Json(request), _): WithRejection<Json<ConstructionCombineRequest>, Error>,
102) -> Result<ConstructionCombineResponse, Error> {
103    env.check_network_identifier(&request.network_identifier)?;
104    let unsigned_tx = request.unsigned_transaction.to_vec()?;
105    let intent_msg: IntentMessage<TransactionData> = bcs::from_bytes(&unsigned_tx)?;
106    let sig = request
107        .signatures
108        .first()
109        .ok_or_else(|| Error::MissingInput("Signature".to_string()))?;
110    let sig_bytes = sig.hex_bytes.to_vec()?;
111    let pub_key = sig.public_key.hex_bytes.to_vec()?;
112    let flag = vec![
113        match sig.signature_type {
114            SignatureType::Ed25519 => SignatureScheme::ED25519,
115            SignatureType::Ecdsa => SignatureScheme::Secp256k1,
116        }
117        .flag(),
118    ];
119
120    let signed_tx = Transaction::from_generic_sig_data(
121        intent_msg.value,
122        vec![GenericSignature::from_bytes(
123            &[&*flag, &*sig_bytes, &*pub_key].concat(),
124        )?],
125    );
126    // TODO: this will likely fail with zklogin authenticator, since we do not know
127    // the current epoch. As long as coinbase doesn't need to use zklogin for
128    // custodial wallets this is okay.
129    let place_holder_epoch = 0;
130    verify_sender_signed_data_message_signatures(
131        &signed_tx,
132        place_holder_epoch,
133        &VerifyParams::default(),
134        Arc::new(VerifiedDigestCache::new_empty()), // no need to use cache in rosetta
135    )?;
136    let signed_tx_bytes = bcs::to_bytes(&signed_tx)?;
137
138    Ok(ConstructionCombineResponse {
139        signed_transaction: Hex::from_bytes(&signed_tx_bytes),
140    })
141}
142
143/// Submit a pre-signed transaction to the node.
144///
145/// [Rosetta API Spec](https://www.rosetta-api.org/docs/ConstructionApi.html#constructionsubmit)
146pub async fn submit(
147    State(context): State<OnlineServerContext>,
148    Extension(env): Extension<IotaEnv>,
149    WithRejection(Json(request), _): WithRejection<Json<ConstructionSubmitRequest>, Error>,
150) -> Result<TransactionIdentifierResponse, Error> {
151    env.check_network_identifier(&request.network_identifier)?;
152    let signed_tx: Transaction = bcs::from_bytes(&request.signed_transaction.to_vec()?)?;
153
154    // According to RosettaClient.rosseta_flow() (see tests), this transaction has
155    // already passed through a dry_run with a possibly invalid budget (metadata
156    // endpoint), but the requirements are that it should pass from there and
157    // fail here.
158    let tx_data = signed_tx.data().transaction_data().clone();
159    let dry_run = context
160        .client
161        .read_api()
162        .dry_run_transaction_block(tx_data)
163        .await?;
164    if let IotaExecutionStatus::Failure { error } = dry_run.effects.status() {
165        return Err(Error::TransactionDryRun(error.clone()));
166    };
167
168    let response = context
169        .client
170        .quorum_driver_api()
171        .execute_transaction_block(
172            signed_tx,
173            IotaTransactionBlockResponseOptions::new()
174                .with_input()
175                .with_effects()
176                .with_balance_changes(),
177            None,
178        )
179        .await?;
180
181    if let IotaExecutionStatus::Failure { error } = response
182        .effects
183        .expect("Execute transaction should return effects")
184        .status()
185    {
186        return Err(Error::TransactionExecution(error.to_string()));
187    }
188
189    Ok(TransactionIdentifierResponse {
190        transaction_identifier: TransactionIdentifier {
191            hash: response.digest,
192        },
193        metadata: None,
194    })
195}
196
197/// Preprocess is called prior to /construction/payloads to construct a request
198/// for any metadata that is needed for transaction construction given (i.e.
199/// account nonce).
200///
201/// [Rosetta API Spec](https://www.rosetta-api.org/docs/ConstructionApi.html#constructionpreprocess)
202pub async fn preprocess(
203    Extension(env): Extension<IotaEnv>,
204    WithRejection(Json(request), _): WithRejection<Json<ConstructionPreprocessRequest>, Error>,
205) -> Result<ConstructionPreprocessResponse, Error> {
206    env.check_network_identifier(&request.network_identifier)?;
207
208    let internal_operation = request.operations.into_internal()?;
209    let sender = internal_operation.sender();
210    let budget = request.metadata.and_then(|m| m.budget);
211
212    Ok(ConstructionPreprocessResponse {
213        options: Some(MetadataOptions {
214            internal_operation,
215            budget,
216        }),
217        required_public_keys: vec![sender.into()],
218    })
219}
220
221/// TransactionHash returns the network-specific transaction hash for a signed
222/// transaction.
223///
224/// [Rosetta API Spec](https://www.rosetta-api.org/docs/ConstructionApi.html#constructionhash)
225pub async fn hash(
226    Extension(env): Extension<IotaEnv>,
227    WithRejection(Json(request), _): WithRejection<Json<ConstructionHashRequest>, Error>,
228) -> Result<TransactionIdentifierResponse, Error> {
229    env.check_network_identifier(&request.network_identifier)?;
230    let tx_bytes = request.signed_transaction.to_vec()?;
231    let tx: Transaction = bcs::from_bytes(&tx_bytes)?;
232
233    Ok(TransactionIdentifierResponse {
234        transaction_identifier: TransactionIdentifier { hash: *tx.digest() },
235        metadata: None,
236    })
237}
238
239/// Get any information required to construct a transaction for a specific
240/// network. For IOTA, we are returning the latest object refs for all the input
241/// objects, which will be used in transaction construction.
242///
243/// [Rosetta API Spec](https://www.rosetta-api.org/docs/ConstructionApi.html#constructionmetadata)
244pub async fn metadata(
245    State(context): State<OnlineServerContext>,
246    Extension(env): Extension<IotaEnv>,
247    WithRejection(Json(request), _): WithRejection<Json<ConstructionMetadataRequest>, Error>,
248) -> Result<ConstructionMetadataResponse, Error> {
249    env.check_network_identifier(&request.network_identifier)?;
250    let option = request.options.ok_or(Error::MissingMetadata)?;
251    let budget = option.budget;
252    let sender = option.internal_operation.sender();
253    let mut gas_price = context
254        .client
255        .governance_api()
256        .get_reference_gas_price()
257        .await?;
258    // make sure it works over epoch changes
259    gas_price += 100;
260
261    // Get amount, objects, for the operation
262    let (total_required_amount, objects) = match &option.internal_operation {
263        InternalOperation::PayIota { amounts, .. } => {
264            let amount = amounts.iter().sum::<u64>();
265            (Some(amount), vec![])
266        }
267        InternalOperation::Stake { amount, .. } => (*amount, vec![]),
268        InternalOperation::WithdrawStake { sender, stake_ids } => {
269            let stake_ids = if stake_ids.is_empty() {
270                // unstake all
271                context
272                    .client
273                    .governance_api()
274                    .get_stakes(*sender)
275                    .await?
276                    .into_iter()
277                    .flat_map(|s| {
278                        s.stakes.into_iter().filter_map(|s| {
279                            if let StakeStatus::Active { .. } = s.status {
280                                Some(s.staked_iota_id)
281                            } else {
282                                None
283                            }
284                        })
285                    })
286                    .collect()
287            } else {
288                stake_ids.clone()
289            };
290
291            if stake_ids.is_empty() {
292                return Err(Error::InvalidInput("No active stake to withdraw".into()));
293            }
294
295            let responses = context
296                .client
297                .read_api()
298                .multi_get_object_with_options(stake_ids, IotaObjectDataOptions::default())
299                .await?;
300            let stake_refs = responses
301                .into_iter()
302                .map(|stake| stake.into_object().map(|o| o.object_ref()))
303                .collect::<Result<Vec<_>, _>>()
304                .map_err(IotaError::from)?;
305
306            (Some(0), stake_refs)
307        }
308    };
309
310    // Get budget for suggested_fee and metadata.budget
311    let budget = match budget {
312        Some(budget) => budget,
313        None => {
314            // Dry run the transaction to get the gas used, amount doesn't really matter
315            // here when using mock coins. get gas estimation from dry-run, this
316            // will also return any tx error.
317            let data = option
318                .internal_operation
319                .try_into_data(ConstructionMetadata {
320                    sender,
321                    coins: vec![],
322                    objects: objects.clone(),
323                    // Mock coin have 1B IOTA
324                    total_coin_value: 1_000_000_000 * 1_000_000_000,
325                    gas_price,
326                    // MAX BUDGET
327                    budget: 50_000_000_000,
328                })?;
329
330            let dry_run = context
331                .client
332                .read_api()
333                .dry_run_transaction_block(data)
334                .await?;
335            let effects = dry_run.effects;
336
337            if let IotaExecutionStatus::Failure { error } = effects.status() {
338                return Err(Error::TransactionDryRun(error.to_string()));
339            }
340            effects.gas_cost_summary().computation_cost + effects.gas_cost_summary().storage_cost
341        }
342    };
343
344    // Try select coins for required amounts
345    let coins = if let Some(amount) = total_required_amount {
346        let total_amount = amount + budget;
347        context
348            .client
349            .coin_read_api()
350            .select_coins(sender, None, total_amount.into(), vec![])
351            .await
352            .ok()
353    } else {
354        None
355    };
356
357    // If required amount is None (all IOTA) or failed to select coin (might not
358    // have enough IOTA), select all coins.
359    let coins = if let Some(coins) = coins {
360        coins
361    } else {
362        context
363            .client
364            .coin_read_api()
365            .get_coins_stream(sender, None)
366            .collect::<Vec<_>>()
367            .await
368    };
369
370    let total_coin_value = coins.iter().fold(0, |sum, coin| sum + coin.balance);
371
372    let coins = coins
373        .into_iter()
374        .map(|c| c.object_ref())
375        .collect::<Vec<_>>();
376
377    Ok(ConstructionMetadataResponse {
378        metadata: ConstructionMetadata {
379            sender,
380            coins,
381            objects,
382            total_coin_value,
383            gas_price,
384            budget,
385        },
386        suggested_fee: vec![Amount::new(budget as i128)],
387    })
388}
389
390///  This is run as a sanity check before signing (after /construction/payloads)
391/// and before broadcast (after /construction/combine).
392///
393/// [Rosetta API Spec](https://www.rosetta-api.org/docs/ConstructionApi.html#constructionparse)
394pub async fn parse(
395    Extension(env): Extension<IotaEnv>,
396    WithRejection(Json(request), _): WithRejection<Json<ConstructionParseRequest>, Error>,
397) -> Result<ConstructionParseResponse, Error> {
398    env.check_network_identifier(&request.network_identifier)?;
399
400    let (tx_data, tx_digest) = if request.signed {
401        let tx: Transaction = bcs::from_bytes(&request.transaction.to_vec()?)?;
402        let tx_digest = *tx.digest();
403
404        (
405            tx.into_data().intent_message().value.clone(),
406            Some(tx_digest),
407        )
408    } else {
409        let intent: IntentMessage<TransactionData> =
410            bcs::from_bytes(&request.transaction.to_vec()?)?;
411
412        (intent.value, None)
413    };
414
415    let account_identifier_signers = if request.signed {
416        vec![tx_data.sender().into()]
417    } else {
418        vec![]
419    };
420
421    let operations = Operations::from_transaction_data(tx_data, tx_digest)?;
422
423    Ok(ConstructionParseResponse {
424        operations,
425        account_identifier_signers,
426        metadata: None,
427    })
428}