Skip to main content

nautilus_blockchain/execution/
transaction.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! EIP-1559 transaction building, signing, and fee and gas policy for execution operations.
17
18use alloy::{
19    consensus::{SignableTransaction, TxEip1559, TxEnvelope},
20    eips::eip2718::{Decodable2718, Encodable2718},
21    primitives::{Address, B256, Bytes, TxKind, U256},
22    signers::{Signer, local::PrivateKeySigner},
23};
24use anyhow::Context;
25
26const BPS_DENOMINATOR: u128 = 10_000;
27
28/// Current version of the durable execution-intent schema.
29pub const EXECUTION_SCHEMA_VERSION: i16 = 2;
30
31/// The purpose of an execution transaction, persisted with the transaction record.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum TransactionPurpose {
34    /// WETH `deposit()` wrapping native currency into the wrapped native token.
35    Wrap,
36    /// ERC-20 `approve` granting the router an allowance.
37    Approve,
38    /// Uniswap V3 `exactInputSingle` swap executing an order.
39    Swap,
40}
41
42impl TransactionPurpose {
43    /// Returns the persisted string representation.
44    #[must_use]
45    pub const fn as_str(&self) -> &'static str {
46        match self {
47            Self::Wrap => "wrap",
48            Self::Approve => "approve",
49            Self::Swap => "swap",
50        }
51    }
52
53    /// Parses a persisted transaction purpose.
54    #[must_use]
55    pub fn parse(value: &str) -> Option<Self> {
56        match value {
57            "wrap" => Some(Self::Wrap),
58            "approve" => Some(Self::Approve),
59            "swap" => Some(Self::Swap),
60            _ => None,
61        }
62    }
63}
64
65/// The observation status of a persisted execution transaction.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum TransactionStatus {
68    /// Intent persisted before nonce reservation or signing.
69    Prepared,
70    /// Signed hash persisted before a broadcast attempt.
71    Signed,
72    /// Broadcast attempted or accepted, awaiting chain inclusion.
73    Broadcast,
74    /// Broadcast accepted (or possibly accepted), awaiting inclusion.
75    ///
76    /// Retained for records written by execution schema version 1.
77    Pending,
78    /// Definitively rejected by the RPC node before acceptance.
79    ///
80    /// Retained for records written by execution schema version 1.
81    Rejected,
82    /// Receipt observed in a canonical block, but not yet finalized.
83    Included,
84    /// Successful receipt proved canonical at the finalized boundary.
85    Finalized,
86    /// Failed receipt proved canonical at the finalized boundary.
87    Reverted,
88    /// A different transaction hash consumed the owned signer nonce.
89    Replaced,
90    /// No canonical receipt was found in the bounded observation window.
91    Dropped,
92    /// A previously observed inclusion is no longer canonical.
93    Reorged,
94    /// No signed or possibly broadcast transaction remains and ownership may be released.
95    Recoverable,
96}
97
98impl TransactionStatus {
99    /// Returns the persisted string representation.
100    #[must_use]
101    pub const fn as_str(&self) -> &'static str {
102        match self {
103            Self::Prepared => "prepared",
104            Self::Signed => "signed",
105            Self::Broadcast => "broadcast",
106            Self::Pending => "pending",
107            Self::Rejected => "rejected",
108            Self::Included => "included",
109            Self::Finalized => "finalized",
110            Self::Reverted => "reverted",
111            Self::Replaced => "replaced",
112            Self::Dropped => "dropped",
113            Self::Reorged => "reorged",
114            Self::Recoverable => "recoverable",
115        }
116    }
117}
118
119/// Builds an unsigned EIP-1559 transaction for a contract call.
120#[must_use]
121#[expect(clippy::too_many_arguments)]
122pub fn build_eip1559_transaction(
123    chain_id: u64,
124    nonce: u64,
125    gas_limit: u64,
126    max_fee_per_gas: u128,
127    max_priority_fee_per_gas: u128,
128    to: Address,
129    value: U256,
130    input: Bytes,
131) -> TxEip1559 {
132    TxEip1559 {
133        chain_id,
134        nonce,
135        gas_limit,
136        max_fee_per_gas,
137        max_priority_fee_per_gas,
138        to: TxKind::Call(to),
139        value,
140        access_list: Default::default(),
141        input,
142    }
143}
144
145/// Signs an unsigned EIP-1559 transaction locally and returns the transaction hash together
146/// with the raw EIP-2718 encoding accepted by `eth_sendRawTransaction`.
147///
148/// # Errors
149///
150/// Returns an error if signing fails.
151pub async fn sign_eip1559_transaction(
152    tx: TxEip1559,
153    signer: &PrivateKeySigner,
154) -> anyhow::Result<(B256, Vec<u8>)> {
155    let signature = signer
156        .sign_hash(&tx.signature_hash())
157        .await
158        .context("failed to sign transaction")?;
159    let signed = tx.into_signed(signature);
160    let tx_hash = *signed.hash();
161
162    Ok((tx_hash, signed.encoded_2718()))
163}
164
165/// Durable identity and configured policy required to authenticate a signed transaction.
166pub(super) struct SignedTransactionIntent {
167    pub hash: B256,
168    pub signer: Address,
169    pub durable_signer: Address,
170    pub chain_id: u32,
171    pub intent_chain_id: u32,
172    pub row_chain_id: u32,
173    pub nonce: u64,
174    pub to: Address,
175    pub value: U256,
176    pub input: Bytes,
177    pub gas_limit: u64,
178    pub max_fee_per_gas: u64,
179}
180
181/// Authenticated fields decoded from one complete signed EIP-1559 transaction.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub(super) struct DecodedSignedTransaction {
184    pub hash: B256,
185    pub signer: Address,
186    pub chain_id: u64,
187    pub nonce: u64,
188    pub to: Address,
189    pub value: U256,
190    pub input: Bytes,
191    pub gas_limit: u64,
192    pub max_fee_per_gas: u128,
193    pub max_priority_fee_per_gas: u128,
194}
195
196/// Decodes and authenticates one complete signed EIP-1559 transaction.
197pub(super) fn decode_signed_transaction(
198    raw_transaction: &[u8],
199) -> anyhow::Result<DecodedSignedTransaction> {
200    let envelope = TxEnvelope::decode_2718_exact(raw_transaction).map_err(|_| {
201        anyhow::anyhow!("Persisted signed transaction is not a complete EIP-2718 envelope")
202    })?;
203    let TxEnvelope::Eip1559(signed) = envelope else {
204        anyhow::bail!("Persisted signed transaction is not EIP-1559");
205    };
206    anyhow::ensure!(
207        signed.signature().normalize_s().is_none(),
208        "Persisted transaction signature is not EIP-2 normalized"
209    );
210    let signer = signed
211        .signature()
212        .recover_address_from_prehash(&signed.signature_hash())
213        .context("failed to recover persisted transaction signer")?;
214    let hash = *signed.hash();
215    let tx = signed.tx();
216    let TxKind::Call(to) = tx.to else {
217        anyhow::bail!("Signed transaction creates a contract instead of calling a destination");
218    };
219    anyhow::ensure!(
220        tx.access_list.is_empty(),
221        "Signed transaction access list is not empty"
222    );
223
224    Ok(DecodedSignedTransaction {
225        hash,
226        signer,
227        chain_id: tx.chain_id,
228        nonce: tx.nonce,
229        to,
230        value: tx.value,
231        input: tx.input.clone(),
232        gas_limit: tx.gas_limit,
233        max_fee_per_gas: tx.max_fee_per_gas,
234        max_priority_fee_per_gas: tx.max_priority_fee_per_gas,
235    })
236}
237
238/// Authenticates one complete signed EIP-1559 call against its durable intent and policy.
239pub(super) fn validate_signed_transaction(
240    raw_transaction: &[u8],
241    intent: &SignedTransactionIntent,
242) -> anyhow::Result<()> {
243    let tx = decode_signed_transaction(raw_transaction)?;
244
245    anyhow::ensure!(
246        tx.hash == intent.hash,
247        "Persisted transaction hash {} does not match signed transaction hash {}",
248        intent.hash,
249        tx.hash
250    );
251    anyhow::ensure!(
252        intent.durable_signer == intent.signer,
253        "Persisted transaction signer {} does not match configured wallet {}",
254        intent.durable_signer,
255        intent.signer
256    );
257    anyhow::ensure!(
258        tx.signer == intent.signer,
259        "Signed transaction signer {} does not match configured wallet {}",
260        tx.signer,
261        intent.signer
262    );
263    anyhow::ensure!(
264        intent.intent_chain_id == intent.chain_id,
265        "Persisted intent chain ID {} does not match configured chain ID {}",
266        intent.intent_chain_id,
267        intent.chain_id
268    );
269    anyhow::ensure!(
270        intent.row_chain_id == intent.chain_id,
271        "Persisted transaction row chain ID {} does not match configured chain ID {}",
272        intent.row_chain_id,
273        intent.chain_id
274    );
275
276    anyhow::ensure!(
277        tx.chain_id == u64::from(intent.chain_id),
278        "Signed transaction chain ID {} does not match configured chain ID {}",
279        tx.chain_id,
280        intent.chain_id
281    );
282    anyhow::ensure!(
283        tx.nonce == intent.nonce,
284        "Signed transaction nonce {} does not match persisted nonce {}",
285        tx.nonce,
286        intent.nonce
287    );
288    anyhow::ensure!(
289        tx.to == intent.to,
290        "Signed transaction destination {} does not match persisted destination {}",
291        tx.to,
292        intent.to
293    );
294    anyhow::ensure!(
295        tx.value == intent.value,
296        "Signed transaction value does not match persisted value"
297    );
298    anyhow::ensure!(
299        tx.input == intent.input,
300        "Signed transaction calldata does not match persisted calldata"
301    );
302    anyhow::ensure!(
303        tx.gas_limit <= intent.gas_limit,
304        "Signed transaction gas limit {} exceeds configured ceiling {}",
305        tx.gas_limit,
306        intent.gas_limit
307    );
308    anyhow::ensure!(
309        tx.max_fee_per_gas <= u128::from(intent.max_fee_per_gas),
310        "Signed transaction max fee per gas {} wei exceeds configured ceiling {} wei",
311        tx.max_fee_per_gas,
312        intent.max_fee_per_gas
313    );
314    anyhow::ensure!(
315        tx.max_priority_fee_per_gas <= tx.max_fee_per_gas,
316        "Signed transaction priority fee per gas {} wei exceeds max fee per gas {} wei",
317        tx.max_priority_fee_per_gas,
318        tx.max_fee_per_gas
319    );
320
321    Ok(())
322}
323
324/// Applies `gas_buffer_bps` over the `eth_estimateGas` result.
325///
326/// A buffered estimate above `gas_limit` rejects the transaction rather than clamping to the
327/// ceiling: on Arbitrum the estimate folds the L1 data fee into gas units, and clamping
328/// guarantees a paid-for out-of-gas revert.
329///
330/// # Errors
331///
332/// Returns an error if the buffered estimate exceeds `gas_limit` or the arithmetic overflows.
333pub fn derive_gas_limit(estimate: u64, gas_buffer_bps: u32, gas_limit: u64) -> anyhow::Result<u64> {
334    let buffered = apply_buffer_bps(u128::from(estimate), gas_buffer_bps)?;
335
336    if buffered > u128::from(gas_limit) {
337        anyhow::bail!(
338            "Estimated gas {estimate} with {gas_buffer_bps} bps buffer ({buffered}) exceeds gas limit {gas_limit}"
339        );
340    }
341
342    u64::try_from(buffered).context("buffered gas limit overflow")
343}
344
345/// Derives EIP-1559 fees from the latest base fee and the node's suggested priority fee.
346///
347/// `max_fee_per_gas` is the base fee with `base_fee_buffer_bps` applied plus the priority fee;
348/// `max_priority_fee_per_gas` is the priority fee itself. `max_fee_per_gas_wei` is a hard
349/// ceiling that rejects the transaction when current conditions exceed it.
350///
351/// # Errors
352///
353/// Returns an error if the derived max fee exceeds `max_fee_per_gas_wei` or the arithmetic
354/// overflows.
355pub fn derive_fees(
356    base_fee_per_gas_wei: u128,
357    priority_fee_per_gas_wei: u128,
358    base_fee_buffer_bps: u32,
359    max_fee_per_gas_wei: u128,
360) -> anyhow::Result<(u128, u128)> {
361    let max_fee = compute_max_fee(
362        base_fee_per_gas_wei,
363        priority_fee_per_gas_wei,
364        base_fee_buffer_bps,
365    )?;
366
367    if max_fee > max_fee_per_gas_wei {
368        anyhow::bail!(
369            "Derived max fee per gas {max_fee} wei exceeds ceiling {max_fee_per_gas_wei} wei"
370        );
371    }
372
373    Ok((max_fee, priority_fee_per_gas_wei))
374}
375
376/// Computes the EIP-1559 max fee per gas: the base fee with `base_fee_buffer_bps` applied
377/// plus the priority fee, without applying any ceiling.
378///
379/// # Errors
380///
381/// Returns an error if the arithmetic overflows.
382pub fn compute_max_fee(
383    base_fee_per_gas_wei: u128,
384    priority_fee_per_gas_wei: u128,
385    base_fee_buffer_bps: u32,
386) -> anyhow::Result<u128> {
387    let buffered_base_fee = apply_buffer_bps(base_fee_per_gas_wei, base_fee_buffer_bps)?;
388
389    buffered_base_fee
390        .checked_add(priority_fee_per_gas_wei)
391        .context("max fee per gas overflow")
392}
393
394fn apply_buffer_bps(value: u128, buffer_bps: u32) -> anyhow::Result<u128> {
395    let buffer_bps = u128::from(buffer_bps);
396    let buffer_whole = (value / BPS_DENOMINATOR)
397        .checked_mul(buffer_bps)
398        .context("buffered value overflow")?;
399
400    // Round up so the buffer is never silently reduced by integer division
401    let buffer_remainder = (value % BPS_DENOMINATOR)
402        .checked_mul(buffer_bps)
403        .and_then(|v| v.checked_add(BPS_DENOMINATOR - 1))
404        .map(|v| v / BPS_DENOMINATOR)
405        .context("buffered value overflow")?;
406
407    value
408        .checked_add(buffer_whole)
409        .and_then(|v| v.checked_add(buffer_remainder))
410        .context("buffered value overflow")
411}
412
413#[cfg(test)]
414mod tests {
415    use std::str::FromStr;
416
417    use alloy::{
418        consensus::TxEip2930,
419        eips::eip2930::{AccessList, AccessListItem},
420        primitives::{Signature, address, b256},
421    };
422    use nautilus_core::hex;
423    use rstest::rstest;
424
425    use super::*;
426
427    // Reference vector produced independently with eth_account 0.13.7 (Python):
428    // anvil development key 0xac0974...ff80 signing a WETH deposit() call on Arbitrum
429    const TEST_PRIVATE_KEY: &str =
430        "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
431    const EXPECTED_RAW_TX: &str = "02f87682a4b10783989680840bebc20082fde89482af49447d8a07e3bd95bd0d56f35241523fbab187038d7ea4c6800084d0e30db0c080a0ecbbf3b95a4509c94cf0fe219c93a404c09de776a7073f2765709fe04f32f024a07b6a1f8332b39ca80ad3e61d124147af984ffcba1dd5579dbcf11e921ea3cecb";
432
433    #[derive(Debug, Clone, Copy)]
434    enum InvalidSignedField {
435        Hash,
436        Signer,
437        DurableSigner,
438        IntentChain,
439        RowChain,
440        TransactionChain,
441        Nonce,
442        CallType,
443        Destination,
444        Value,
445        Input,
446        GasLimit,
447        MaxFee,
448        PriorityFee,
449        AccessList,
450    }
451
452    fn validation_transaction() -> TxEip1559 {
453        build_eip1559_transaction(
454            42161,
455            7,
456            65_000,
457            200_000_000,
458            10_000_000,
459            address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1"),
460            U256::from(1_000_000_000_000_000u64),
461            Bytes::from(hex::decode("d0e30db0").unwrap()),
462        )
463    }
464
465    fn validation_intent(hash: B256, signer: Address) -> SignedTransactionIntent {
466        SignedTransactionIntent {
467            hash,
468            signer,
469            durable_signer: signer,
470            chain_id: 42161,
471            intent_chain_id: 42161,
472            row_chain_id: 42161,
473            nonce: 7,
474            to: address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1"),
475            value: U256::from(1_000_000_000_000_000u64),
476            input: Bytes::from(hex::decode("d0e30db0").unwrap()),
477            gas_limit: 1_000_000,
478            max_fee_per_gas: 1_000_000_000,
479        }
480    }
481
482    #[rstest]
483    fn test_derive_gas_limit_applies_buffer_rounding_up() {
484        assert_eq!(derive_gas_limit(50_001, 2_000, 1_000_000).unwrap(), 60_002);
485    }
486
487    #[rstest]
488    fn test_derive_gas_limit_allows_estimate_at_ceiling() {
489        assert_eq!(
490            derive_gas_limit(1_000_000, 0, 1_000_000).unwrap(),
491            1_000_000
492        );
493    }
494
495    #[rstest]
496    fn test_derive_gas_limit_rejects_above_ceiling_without_clamping() {
497        let result = derive_gas_limit(900_000, 2_000, 1_000_000);
498
499        assert!(result.is_err());
500        assert!(
501            result
502                .unwrap_err()
503                .to_string()
504                .contains("exceeds gas limit 1000000")
505        );
506    }
507
508    #[rstest]
509    fn test_derive_fees_combines_buffered_base_fee_and_priority_fee() {
510        let (max_fee, priority_fee) = derive_fees(100, 5, 2_000, 1_000).unwrap();
511
512        assert_eq!(max_fee, 125);
513        assert_eq!(priority_fee, 5);
514    }
515
516    #[rstest]
517    fn test_derive_fees_rejects_above_ceiling() {
518        let result = derive_fees(100, 5, 2_000, 124);
519
520        assert!(result.is_err());
521        assert!(
522            result
523                .unwrap_err()
524                .to_string()
525                .contains("exceeds ceiling 124 wei")
526        );
527    }
528
529    #[rstest]
530    fn test_derive_fees_allows_fee_at_ceiling() {
531        assert!(derive_fees(100, 5, 2_000, 125).is_ok());
532    }
533
534    #[rstest]
535    fn test_compute_max_fee_zero_buffer_accepts_maximum_value() {
536        assert_eq!(compute_max_fee(u128::MAX, 0, 0).unwrap(), u128::MAX);
537    }
538
539    #[rstest]
540    fn test_compute_max_fee_rejects_buffered_value_overflow() {
541        let error = compute_max_fee(u128::MAX, 0, 1).unwrap_err();
542
543        assert_eq!(error.to_string(), "buffered value overflow");
544    }
545
546    #[rstest]
547    fn test_build_eip1559_transaction_populates_all_fields() {
548        let to = address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1");
549        let input = Bytes::from(hex::decode("d0e30db0").unwrap());
550
551        let tx = build_eip1559_transaction(
552            42161,
553            7,
554            65_000,
555            200_000_000,
556            10_000_000,
557            to,
558            U256::from(1_000_000_000_000_000u64),
559            input.clone(),
560        );
561
562        assert_eq!(tx.chain_id, 42161);
563        assert_eq!(tx.nonce, 7);
564        assert_eq!(tx.gas_limit, 65_000);
565        assert_eq!(tx.max_fee_per_gas, 200_000_000);
566        assert_eq!(tx.max_priority_fee_per_gas, 10_000_000);
567        assert_eq!(tx.to, TxKind::Call(to));
568        assert_eq!(tx.value, U256::from(1_000_000_000_000_000u64));
569        assert!(tx.access_list.is_empty());
570        assert_eq!(tx.input, input);
571    }
572
573    #[tokio::test]
574    async fn test_sign_eip1559_transaction_matches_reference_vector() {
575        let signer = PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap();
576        assert_eq!(
577            signer.address(),
578            address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266")
579        );
580
581        let tx = build_eip1559_transaction(
582            42161,
583            7,
584            65_000,
585            200_000_000,
586            10_000_000,
587            address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1"),
588            U256::from(1_000_000_000_000_000u64),
589            Bytes::from(hex::decode("d0e30db0").unwrap()),
590        );
591
592        let (tx_hash, raw_tx) = sign_eip1559_transaction(tx, &signer).await.unwrap();
593
594        assert_eq!(
595            tx_hash,
596            b256!("9da4b71be3336357259f56bda5cfbd3803c211ce09b510c43e6fb2af84088c6a")
597        );
598        assert_eq!(hex::encode(&raw_tx), EXPECTED_RAW_TX);
599    }
600
601    #[tokio::test]
602    async fn test_validate_signed_transaction_accepts_builder_output_at_policy_ceilings() {
603        let signer = PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap();
604        let mut transaction = validation_transaction();
605        transaction.gas_limit = 1_000_000;
606        transaction.max_fee_per_gas = 1_000_000_000;
607        transaction.max_priority_fee_per_gas = 1_000_000_000;
608        let (hash, raw_transaction) = sign_eip1559_transaction(transaction, &signer)
609            .await
610            .unwrap();
611        let intent = validation_intent(hash, signer.address());
612
613        validate_signed_transaction(&raw_transaction, &intent).unwrap();
614    }
615
616    #[rstest]
617    fn test_validate_signed_transaction_rejects_malformed_bytes() {
618        let signer = PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap();
619        let intent = validation_intent(B256::ZERO, signer.address());
620
621        let error = validate_signed_transaction(&[0x02, 0xc0], &intent).unwrap_err();
622
623        assert_eq!(
624            error.to_string(),
625            "Persisted signed transaction is not a complete EIP-2718 envelope"
626        );
627    }
628
629    #[tokio::test]
630    async fn test_validate_signed_transaction_rejects_trailing_bytes_without_exposing_them() {
631        let signer = PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap();
632        let (hash, mut raw_transaction) =
633            sign_eip1559_transaction(validation_transaction(), &signer)
634                .await
635                .unwrap();
636        raw_transaction.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef]);
637        let intent = validation_intent(hash, signer.address());
638
639        let error = validate_signed_transaction(&raw_transaction, &intent).unwrap_err();
640
641        assert_eq!(
642            error.to_string(),
643            "Persisted signed transaction is not a complete EIP-2718 envelope"
644        );
645        assert!(!error.to_string().contains("deadbeef"));
646    }
647
648    #[tokio::test]
649    async fn test_validate_signed_transaction_rejects_other_envelope_type() {
650        let signer = PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap();
651        let transaction = validation_transaction();
652        let transaction = TxEip2930 {
653            chain_id: transaction.chain_id,
654            nonce: transaction.nonce,
655            gas_price: transaction.max_fee_per_gas,
656            gas_limit: transaction.gas_limit,
657            to: transaction.to,
658            value: transaction.value,
659            access_list: transaction.access_list,
660            input: transaction.input,
661        };
662        let signature = signer
663            .sign_hash(&transaction.signature_hash())
664            .await
665            .unwrap();
666        let raw_transaction = transaction.into_signed(signature).encoded_2718();
667        let intent = validation_intent(B256::ZERO, signer.address());
668
669        let error = validate_signed_transaction(&raw_transaction, &intent).unwrap_err();
670
671        assert_eq!(
672            error.to_string(),
673            "Persisted signed transaction is not EIP-1559"
674        );
675    }
676
677    #[tokio::test]
678    async fn test_validate_signed_transaction_rejects_noncanonical_signature() {
679        let signer = PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap();
680        let transaction = validation_transaction();
681        let signature = signer
682            .sign_hash(&transaction.signature_hash())
683            .await
684            .unwrap();
685        let curve_order =
686            U256::from_str("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141")
687                .unwrap();
688        let signature = Signature::new(signature.r(), curve_order - signature.s(), !signature.v());
689        let signed = transaction.into_signed(signature);
690        let hash = *signed.hash();
691        let raw_transaction = signed.encoded_2718();
692        let intent = validation_intent(hash, signer.address());
693
694        let error = validate_signed_transaction(&raw_transaction, &intent).unwrap_err();
695
696        assert_eq!(
697            error.to_string(),
698            "Persisted transaction signature is not EIP-2 normalized"
699        );
700    }
701
702    #[rstest]
703    #[case::hash(InvalidSignedField::Hash, "Persisted transaction hash")]
704    #[case::signer(InvalidSignedField::Signer, "does not match configured wallet")]
705    #[case::durable_signer(InvalidSignedField::DurableSigner, "Persisted transaction signer")]
706    #[case::intent_chain(InvalidSignedField::IntentChain, "Persisted intent chain ID")]
707    #[case::row_chain(InvalidSignedField::RowChain, "Persisted transaction row chain ID")]
708    #[case::transaction_chain(InvalidSignedField::TransactionChain, "Signed transaction chain ID")]
709    #[case::nonce(InvalidSignedField::Nonce, "Signed transaction nonce")]
710    #[case::call_type(InvalidSignedField::CallType, "creates a contract")]
711    #[case::destination(InvalidSignedField::Destination, "Signed transaction destination")]
712    #[case::value(InvalidSignedField::Value, "Signed transaction value")]
713    #[case::input(InvalidSignedField::Input, "Signed transaction calldata")]
714    #[case::gas_limit(InvalidSignedField::GasLimit, "Signed transaction gas limit")]
715    #[case::max_fee(InvalidSignedField::MaxFee, "Signed transaction max fee per gas")]
716    #[case::priority_fee(
717        InvalidSignedField::PriorityFee,
718        "Signed transaction priority fee per gas"
719    )]
720    #[case::access_list(InvalidSignedField::AccessList, "Signed transaction access list")]
721    #[tokio::test]
722    async fn test_validate_signed_transaction_rejects_field_mismatch(
723        #[case] field: InvalidSignedField,
724        #[case] expected_error: &str,
725    ) {
726        let signer = PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap();
727        let other = address!("0000000000000000000000000000000000000001");
728        let mut transaction = validation_transaction();
729        let mut intent = validation_intent(B256::ZERO, signer.address());
730
731        match field {
732            InvalidSignedField::Hash => {}
733            InvalidSignedField::Signer => {
734                intent.signer = other;
735                intent.durable_signer = other;
736            }
737            InvalidSignedField::DurableSigner => intent.durable_signer = other,
738            InvalidSignedField::IntentChain => intent.intent_chain_id = 1,
739            InvalidSignedField::RowChain => intent.row_chain_id = 1,
740            InvalidSignedField::TransactionChain => transaction.chain_id = 1,
741            InvalidSignedField::Nonce => transaction.nonce = 8,
742            InvalidSignedField::CallType => transaction.to = TxKind::Create,
743            InvalidSignedField::Destination => transaction.to = TxKind::Call(other),
744            InvalidSignedField::Value => transaction.value = U256::from(2u64),
745            InvalidSignedField::Input => transaction.input = Bytes::from(vec![0x01]),
746            InvalidSignedField::GasLimit => transaction.gas_limit = 1_000_001,
747            InvalidSignedField::MaxFee => transaction.max_fee_per_gas = 1_000_000_001,
748            InvalidSignedField::PriorityFee => {
749                transaction.max_fee_per_gas = 10_000_000;
750                transaction.max_priority_fee_per_gas = 10_000_001;
751            }
752            InvalidSignedField::AccessList => {
753                transaction.access_list = AccessList(vec![AccessListItem {
754                    address: other,
755                    storage_keys: Vec::new(),
756                }]);
757            }
758        }
759
760        let (hash, raw_transaction) = sign_eip1559_transaction(transaction, &signer)
761            .await
762            .unwrap();
763
764        if !matches!(field, InvalidSignedField::Hash) {
765            intent.hash = hash;
766        }
767
768        let error = validate_signed_transaction(&raw_transaction, &intent).unwrap_err();
769
770        assert!(error.to_string().contains(expected_error), "was: {error}");
771    }
772}