ethcontract/transaction/
build.rs

1//! This module implements transaction finalization from partial transaction
2//! parameters. It provides futures for building `TransactionRequest` instances
3//! and raw `Bytes` transactions from partial transaction parameters, where the
4//! remaining parameters are queried from the node before finalizing the
5//! transaction.
6
7use crate::errors::ExecutionError;
8use crate::secret::{Password, PrivateKey};
9use crate::transaction::gas_price::GasPrice;
10#[cfg(feature = "aws-kms")]
11use crate::transaction::kms;
12use crate::transaction::{Account, TransactionBuilder};
13use web3::api::Web3;
14use web3::types::{
15    AccessList, Address, Bytes, CallRequest, RawTransaction, SignedTransaction,
16    TransactionCondition, TransactionParameters, TransactionRequest, H256, U256,
17};
18use web3::Transport;
19
20impl<T: Transport> TransactionBuilder<T> {
21    /// Build a prepared transaction that is ready to send.
22    ///
23    /// Can resolve into either a `TransactionRequest` for sending locally
24    /// signed transactions or raw signed transaction `Bytes` when sending a raw
25    /// transaction.
26    pub async fn build(self) -> Result<Transaction, ExecutionError> {
27        let options = TransactionOptions {
28            to: self.to,
29            gas: self.gas,
30            gas_price: self.gas_price,
31            value: self.value,
32            data: self.data,
33            nonce: self.nonce,
34            access_list: self.access_list,
35        };
36
37        let tx = match self.from {
38            None => Transaction::Request(
39                build_transaction_request_for_local_signing(
40                    self.web3,
41                    None,
42                    TransactionRequestOptions(options, None),
43                )
44                .await?,
45            ),
46            Some(Account::Local(from, condition)) => Transaction::Request(
47                build_transaction_request_for_local_signing(
48                    self.web3,
49                    Some(from),
50                    TransactionRequestOptions(options, condition),
51                )
52                .await?,
53            ),
54            Some(Account::Locked(from, password, condition)) => {
55                build_transaction_signed_with_locked_account(
56                    self.web3,
57                    from,
58                    password,
59                    TransactionRequestOptions(options, condition),
60                )
61                .await
62                .map(|signed| Transaction::Raw {
63                    bytes: signed.raw,
64                    hash: signed.tx.hash,
65                })?
66            }
67            Some(Account::Offline(key, chain_id)) => {
68                build_offline_signed_transaction(self.web3, key, chain_id, options)
69                    .await
70                    .map(|signed| Transaction::Raw {
71                        bytes: signed.raw_transaction,
72                        hash: signed.transaction_hash,
73                    })?
74            }
75            #[cfg(feature = "aws-kms")]
76            Some(Account::Kms(account, chain_id)) => {
77                build_kms_signed_transaction(self.web3, account, chain_id, options)
78                    .await
79                    .map(|signed| Transaction::Raw {
80                        bytes: signed.raw_transaction,
81                        hash: signed.transaction_hash,
82                    })?
83            }
84        };
85
86        Ok(tx)
87    }
88}
89
90/// Represents a prepared and optionally signed transaction that is ready for
91/// sending created by a `TransactionBuilder`.
92#[derive(Clone, Debug, PartialEq)]
93#[allow(clippy::large_enum_variant)]
94pub enum Transaction {
95    /// A structured transaction request to be signed locally by the node.
96    Request(TransactionRequest),
97    /// A signed raw transaction request.
98    Raw {
99        /// The raw signed transaction bytes
100        bytes: Bytes,
101        /// The transaction hash
102        hash: H256,
103    },
104}
105
106impl Transaction {
107    /// Unwraps the transaction into a transaction request, returning None if the
108    /// transaction is a raw transaction.
109    pub fn request(self) -> Option<TransactionRequest> {
110        match self {
111            Transaction::Request(tx) => Some(tx),
112            _ => None,
113        }
114    }
115
116    /// Unwraps the transaction into its raw bytes, returning None if it is a
117    /// transaction request.
118    pub fn raw(self) -> Option<Bytes> {
119        match self {
120            Transaction::Raw { bytes, .. } => Some(bytes),
121            _ => None,
122        }
123    }
124}
125
126/// Shared transaction options that are used when finalizing transactions into
127/// either `TransactionRequest`s or raw signed transaction `Bytes`.
128#[derive(Clone, Debug, Default)]
129struct TransactionOptions {
130    /// The receiver of the transaction.
131    pub to: Option<Address>,
132    /// The amount of gas to use for the transaction.
133    pub gas: Option<U256>,
134    /// Optional gas price to use for transaction.
135    pub gas_price: Option<GasPrice>,
136    /// The ETH value to send with the transaction.
137    pub value: Option<U256>,
138    /// The data for the transaction.
139    pub data: Option<Bytes>,
140    /// The transaction nonce.
141    pub nonce: Option<U256>,
142    /// The access list
143    pub access_list: Option<AccessList>,
144}
145
146/// Transaction options specific to `TransactionRequests` since they may also
147/// include a `TransactionCondition` that is not applicable to raw signed
148/// transactions.
149#[derive(Clone, Debug, Default)]
150struct TransactionRequestOptions(TransactionOptions, Option<TransactionCondition>);
151
152impl TransactionRequestOptions {
153    /// Builds a `TransactionRequest` from a `TransactionRequestOptions` by
154    /// specifying the missing parameters.
155    fn build_request(self, from: Address, gas: Option<U256>) -> TransactionRequest {
156        let resolved_gas_price = self
157            .0
158            .gas_price
159            .map(|gas_price| gas_price.resolve_for_transaction())
160            .unwrap_or_default();
161        TransactionRequest {
162            from,
163            to: self.0.to,
164            gas,
165            gas_price: resolved_gas_price.gas_price,
166            value: self.0.value,
167            data: self.0.data,
168            nonce: self.0.nonce,
169            condition: self.1,
170            transaction_type: resolved_gas_price.transaction_type,
171            access_list: self.0.access_list,
172            max_fee_per_gas: resolved_gas_price.max_fee_per_gas,
173            max_priority_fee_per_gas: resolved_gas_price.max_priority_fee_per_gas,
174        }
175    }
176}
177
178/// Build a transaction request to locally signed by the node before sending.
179async fn build_transaction_request_for_local_signing<T: Transport>(
180    web3: Web3<T>,
181    from: Option<Address>,
182    options: TransactionRequestOptions,
183) -> Result<TransactionRequest, ExecutionError> {
184    let from = match from {
185        Some(address) => address,
186        None => *web3
187            .eth()
188            .accounts()
189            .await?
190            .first()
191            .ok_or(ExecutionError::NoLocalAccounts)?,
192    };
193    let gas = resolve_gas_limit(&web3, from, &options.0).await?;
194    let request = options.build_request(from, Some(gas));
195
196    Ok(request)
197}
198
199/// Build a locally signed transaction with a locked account.
200async fn build_transaction_signed_with_locked_account<T: Transport>(
201    web3: Web3<T>,
202    from: Address,
203    password: Password,
204    options: TransactionRequestOptions,
205) -> Result<RawTransaction, ExecutionError> {
206    let gas = resolve_gas_limit(&web3, from, &options.0).await?;
207    let request = options.build_request(from, Some(gas));
208    let signed_tx = web3.personal().sign_transaction(request, &password).await?;
209
210    Ok(signed_tx)
211}
212
213/// Build an offline signed transaction.
214///
215/// Note that all transaction parameters must be finalized before signing. This
216/// means that things like account nonce, gas and gas price estimates, as well
217/// as chain ID must be queried from the node if not provided before signing.
218async fn build_offline_signed_transaction<T: Transport>(
219    web3: Web3<T>,
220    key: PrivateKey,
221    chain_id: Option<u64>,
222    options: TransactionOptions,
223) -> Result<SignedTransaction, ExecutionError> {
224    let gas = resolve_gas_limit(&web3, key.public_address(), &options).await?;
225    let resolved_gas_price = options
226        .gas_price
227        .map(|gas_price| gas_price.resolve_for_transaction())
228        .unwrap_or_default();
229    let signed = web3
230        .accounts()
231        .sign_transaction(
232            TransactionParameters {
233                nonce: options.nonce,
234                gas_price: resolved_gas_price.gas_price,
235                gas,
236                to: options.to,
237                value: options.value.unwrap_or_default(),
238                data: options.data.unwrap_or_default(),
239                chain_id,
240                transaction_type: resolved_gas_price.transaction_type,
241                access_list: options.access_list,
242                max_fee_per_gas: resolved_gas_price.max_fee_per_gas,
243                max_priority_fee_per_gas: resolved_gas_price.max_priority_fee_per_gas,
244            },
245            &key,
246        )
247        .await?;
248
249    Ok(signed)
250}
251
252/// Build a KMS signed transaction.
253///
254/// Note that all transaction parameters must be finalized before signing. This
255/// means that things like account nonce, gas and gas price estimates, as well
256/// as chain ID must be queried from the node if not provided before signing.
257#[cfg(feature = "aws-kms")]
258async fn build_kms_signed_transaction<T: Transport>(
259    web3: Web3<T>,
260    account: kms::Account,
261    chain_id: Option<u64>,
262    options: TransactionOptions,
263) -> Result<SignedTransaction, ExecutionError> {
264    let gas = resolve_gas_limit(&web3, account.public_address(), &options).await?;
265    let resolved_gas_price = options
266        .gas_price
267        .map(|gas_price| gas_price.resolve_for_transaction())
268        .unwrap_or_default();
269    let signed = account
270        .sign_transaction(
271            web3,
272            TransactionParameters {
273                nonce: options.nonce,
274                gas_price: resolved_gas_price.gas_price,
275                gas,
276                to: options.to,
277                value: options.value.unwrap_or_default(),
278                data: options.data.unwrap_or_default(),
279                chain_id,
280                transaction_type: resolved_gas_price.transaction_type,
281                access_list: options.access_list,
282                max_fee_per_gas: resolved_gas_price.max_fee_per_gas,
283                max_priority_fee_per_gas: resolved_gas_price.max_priority_fee_per_gas,
284            },
285        )
286        .await?;
287
288    Ok(signed)
289}
290
291async fn resolve_gas_limit<T: Transport>(
292    web3: &Web3<T>,
293    from: Address,
294    options: &TransactionOptions,
295) -> Result<U256, ExecutionError> {
296    let resolved_gas_price = options
297        .gas_price
298        .map(|gas_price| gas_price.resolve_for_transaction())
299        .unwrap_or_default();
300    match options.gas {
301        Some(value) => Ok(value),
302        None => Ok(web3
303            .eth()
304            .estimate_gas(
305                CallRequest {
306                    from: Some(from),
307                    to: options.to,
308                    gas: None,
309                    gas_price: resolved_gas_price.gas_price,
310                    value: options.value,
311                    data: options.data.clone(),
312                    transaction_type: resolved_gas_price.transaction_type,
313                    access_list: options.access_list.clone(),
314                    max_fee_per_gas: resolved_gas_price.max_fee_per_gas,
315                    max_priority_fee_per_gas: resolved_gas_price.max_priority_fee_per_gas,
316                },
317                None,
318            )
319            .await?),
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::test::prelude::*;
327
328    #[test]
329    fn tx_build_local() {
330        let mut transport = TestTransport::new();
331        let web3 = Web3::new(transport.clone());
332
333        let from = addr!("0x9876543210987654321098765432109876543210");
334
335        transport.add_response(json!("0x9a5")); // gas limit
336
337        let tx = build_transaction_request_for_local_signing(
338            web3,
339            Some(from),
340            TransactionRequestOptions::default(),
341        )
342        .immediate()
343        .expect("failed to build local transaction");
344
345        transport.assert_request(
346            "eth_estimateGas",
347            &[json!({"from": "0x9876543210987654321098765432109876543210"})],
348        );
349        transport.assert_no_more_requests();
350        assert_eq!(tx.from, from);
351    }
352
353    #[test]
354    fn tx_build_local_default_account() {
355        let mut transport = TestTransport::new();
356        let web3 = Web3::new(transport.clone());
357
358        let accounts = [
359            addr!("0x9876543210987654321098765432109876543210"),
360            addr!("0x1111111111111111111111111111111111111111"),
361            addr!("0x2222222222222222222222222222222222222222"),
362        ];
363
364        transport.add_response(json!(accounts)); // get accounts
365        transport.add_response(json!("0x9a5")); // gas limit
366        let tx = build_transaction_request_for_local_signing(
367            web3,
368            None,
369            TransactionRequestOptions::default(),
370        )
371        .immediate()
372        .expect("failed to build local transaction");
373
374        transport.assert_request("eth_accounts", &[]);
375        transport.assert_request(
376            "eth_estimateGas",
377            &[json!({"from": "0x9876543210987654321098765432109876543210"})],
378        );
379        transport.assert_no_more_requests();
380
381        assert_eq!(tx.from, accounts[0]);
382        assert_eq!(tx.gas_price, None);
383    }
384
385    #[test]
386    fn tx_build_local_default_account_with_extra_gas_price() {
387        let mut transport = TestTransport::new();
388        let web3 = Web3::new(transport.clone());
389
390        let accounts = [
391            addr!("0x9876543210987654321098765432109876543210"),
392            addr!("0x1111111111111111111111111111111111111111"),
393            addr!("0x2222222222222222222222222222222222222222"),
394        ];
395
396        transport.add_response(json!(accounts)); // get accounts
397        transport.add_response(json!("0x9a5")); // gas limit
398        let tx = build_transaction_request_for_local_signing(
399            web3,
400            None,
401            TransactionRequestOptions {
402                0: TransactionOptions {
403                    gas_price: Some(66.0.into()),
404                    ..Default::default()
405                },
406                ..Default::default()
407            },
408        )
409        .immediate()
410        .expect("failed to build local transaction");
411
412        transport.assert_request("eth_accounts", &[]);
413        transport.assert_request(
414            "eth_estimateGas",
415            &[json!({ "from": json!(accounts[0]), "gasPrice": format!("{:#x}", 66), })],
416        );
417        transport.assert_no_more_requests();
418
419        assert_eq!(tx.from, accounts[0]);
420        assert_eq!(tx.gas_price, Some(U256::from(0x42)));
421        assert_eq!(tx.gas, Some(U256::from(0x9a5)));
422    }
423
424    #[test]
425    fn tx_build_local_with_explicit_gas_price() {
426        let mut transport = TestTransport::new();
427        let web3 = Web3::new(transport.clone());
428
429        let from = addr!("0xffffffffffffffffffffffffffffffffffffffff");
430
431        transport.add_response(json!("0x9a5")); // gas limit
432
433        let options = TransactionRequestOptions {
434            0: TransactionOptions {
435                gas_price: Some(1337.0.into()),
436                ..Default::default()
437            },
438            ..Default::default()
439        };
440
441        let tx = build_transaction_request_for_local_signing(web3, Some(from), options)
442            .immediate()
443            .expect("failed to build local transaction");
444
445        transport.assert_request(
446            "eth_estimateGas",
447            &[json!({ "from": json!(from) , "gasPrice": format!("{:#x}", 1337)})],
448        );
449        transport.assert_no_more_requests();
450
451        assert_eq!(tx.from, from);
452        assert_eq!(tx.gas_price, Some(1337.into()));
453    }
454
455    #[test]
456    fn tx_build_local_no_local_accounts() {
457        let mut transport = TestTransport::new();
458        let web3 = Web3::new(transport.clone());
459
460        transport.add_response(json!([])); // get accounts
461        let err = build_transaction_request_for_local_signing(
462            web3,
463            None,
464            TransactionRequestOptions::default(),
465        )
466        .immediate()
467        .expect_err("unexpected success building transaction");
468
469        transport.assert_request("eth_accounts", &[]);
470        transport.assert_no_more_requests();
471
472        assert!(
473            matches!(err, ExecutionError::NoLocalAccounts),
474            "expected no local accounts error but got '{:?}'",
475            err
476        );
477    }
478
479    #[test]
480    fn tx_build_locked() {
481        let mut transport = TestTransport::new();
482        let web3 = Web3::new(transport.clone());
483
484        let from = addr!("0x9876543210987654321098765432109876543210");
485        let pw = "foobar";
486        let to = addr!("0x0000000000000000000000000000000000000000");
487        let signed = bytes!("0x0123456789"); // doesn't have to be valid, we don't check
488        let hash = H256::from_low_u64_be(1);
489        let gas = json!("0x9a5");
490
491        transport.add_response(gas.clone());
492        transport.add_response(json!({
493            "raw": signed,
494            "tx": {
495                "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
496                "nonce": "0x0",
497                "from": from,
498                "value": "0x0",
499                "gas": "0x0",
500                "gasPrice": "0x0",
501                "input": "0x",
502            }
503        })); // sign transaction
504        let tx = build_transaction_signed_with_locked_account(
505            web3,
506            from,
507            pw.into(),
508            TransactionRequestOptions(
509                TransactionOptions {
510                    to: Some(to),
511                    ..Default::default()
512                },
513                None,
514            ),
515        )
516        .immediate()
517        .expect("failed to build locked transaction");
518
519        transport.assert_request(
520            "eth_estimateGas",
521            &[json!({ "from": json!(from) , "to": json!(to)})],
522        );
523        transport.assert_request(
524            "personal_signTransaction",
525            &[
526                json!({
527                    "from": from,
528                    "to": to,
529                    "gas": gas
530                }),
531                json!(pw),
532            ],
533        );
534        transport.assert_no_more_requests();
535
536        assert_eq!(tx.raw, signed);
537        assert_eq!(tx.tx.hash, hash);
538    }
539
540    #[test]
541    fn tx_build_offline() {
542        let mut transport = TestTransport::new();
543        let web3 = Web3::new(transport.clone());
544
545        let key = key!("0x0102030405060708091011121314151617181920212223242526272829303132");
546        let from: Address = key.public_address();
547        let to = addr!("0x0000000000000000000000000000000000000000");
548
549        let gas = uint!("0x9a5");
550        let gas_price = uint!("0x1ce");
551        let nonce = uint!("0x42");
552        let chain_id = 77777;
553
554        transport.add_response(json!(gas));
555        transport.add_response(json!(nonce));
556        transport.add_response(json!(format!("{:#x}", chain_id)));
557
558        let tx1 = build_offline_signed_transaction(
559            web3.clone(),
560            key.clone(),
561            None,
562            TransactionOptions {
563                to: Some(to),
564                gas_price: Some(gas_price.into()),
565                ..Default::default()
566            },
567        )
568        .immediate()
569        .expect("failed to build offline transaction");
570
571        // assert that we ask the node for all the missing values
572        transport.assert_request(
573            "eth_estimateGas",
574            &[json!({
575                "from": from,
576                "to": to,
577                "gasPrice": gas_price,
578            })],
579        );
580        transport.assert_request("eth_getTransactionCount", &[json!(from), json!("latest")]);
581        transport.assert_request("eth_chainId", &[]);
582        transport.assert_no_more_requests();
583
584        let tx2 = build_offline_signed_transaction(
585            web3,
586            key,
587            Some(chain_id),
588            TransactionOptions {
589                to: Some(to),
590                gas: Some(gas),
591                gas_price: Some(gas_price.into()),
592                nonce: Some(nonce),
593                ..Default::default()
594            },
595        )
596        .immediate()
597        .expect("failed to build offline transaction");
598
599        // assert that if we provide all the values then we can sign right away
600        transport.assert_no_more_requests();
601
602        // check that if we sign with same values we get same results
603        assert_eq!(tx1, tx2);
604    }
605}