|
4 | 4 | //! execution. Each numbered rule corresponds to a distinct ledger invariant: |
5 | 5 | //! |
6 | 6 | //! - Rule 1 — at least one input |
7 | | -//! - Rule 1b — no duplicate inputs |
| 7 | +//! - Rule 1b — no duplicate inputs (Conway+ PV≥9 only; Haskell silently dedups at PV<9) |
8 | 8 | //! - Rule 1c — auxiliary data hash / auxiliary data consistency |
9 | 9 | //! - Rule 1d — era gating (Conway-only certs/governance in pre-Conway eras) |
10 | 10 | //! - Rule 2 — all inputs exist in the UTxO set |
@@ -622,11 +622,31 @@ pub(super) fn run_phase1_rules( |
622 | 622 |
|
623 | 623 | // ------------------------------------------------------------------ |
624 | 624 | // Rule 1b: No duplicate inputs |
| 625 | + // |
| 626 | + // Haskell semantic is protocol-version gated: |
| 627 | + // |
| 628 | + // PV < 9 (Alonzo/Babbage): `decodeSet` routes through the lenient path |
| 629 | + // (`Set.fromList`), which silently deduplicates. No `BabbageUtxoPredFailure` |
| 630 | + // constructor for duplicate inputs exists, so Haskell accepts such txs. |
| 631 | + // Real mainnet Babbage blocks contain transactions with duplicate spend |
| 632 | + // inputs encoded in a plain CBOR array (e.g. tx 5ca83e21… at epoch 484, |
| 633 | + // slot 123728795, PV8) — Haskell silently dedups and accepts. |
| 634 | + // |
| 635 | + // PV >= 9 (Conway+): `decodeSetEnforceNoDuplicates` hard-fails at the |
| 636 | + // binary layer. We mirror that by surfacing `DuplicateInput` at |
| 637 | + // Phase-1 time (the net effect is the same rejection). |
| 638 | + // |
| 639 | + // Reference: `cardano-ledger-binary` `decodeSet` / `decodeSetEnforceNoDuplicates` |
| 640 | + // (Cardano.Ledger.Binary.Decoding.Coders), and the absence of a |
| 641 | + // DuplicateInput constructor in `AlonzoUtxoPredFailure` / `BabbageUtxoPredFailure`. |
625 | 642 | // ------------------------------------------------------------------ |
626 | 643 | { |
627 | 644 | let mut seen = HashSet::new(); |
628 | 645 | for input in &body.inputs { |
629 | | - if !seen.insert(input) { |
| 646 | + // PV < 9 (Alonzo/Babbage): Haskell `Set.fromList` silently dedups — |
| 647 | + // no rejection. PV >= 9 (Conway+): hard-fail mirrors |
| 648 | + // `decodeSetEnforceNoDuplicates`. |
| 649 | + if !seen.insert(input) && params.protocol_version_major >= 9 { |
630 | 650 | errors.push(ValidationError::DuplicateInput(input.to_string())); |
631 | 651 | } |
632 | 652 | } |
@@ -2869,20 +2889,110 @@ mod tests { |
2869 | 2889 | } |
2870 | 2890 |
|
2871 | 2891 | // ----------------------------------------------------------------------- |
2872 | | - // Test 31 — Rule 1b: duplicate inputs rejected |
| 2892 | + // Test 31 — Rule 1b: duplicate inputs rejected at Conway PV9+ |
| 2893 | + // |
| 2894 | + // Haskell `decodeSetEnforceNoDuplicates` (PV >= 9) hard-fails on duplicates. |
| 2895 | + // Dugite mirrors this at Phase-1 time. `mainnet_defaults()` has PV9. |
2873 | 2896 | // ----------------------------------------------------------------------- |
2874 | 2897 | #[test] |
2875 | | - fn test_duplicate_inputs_rejected() { |
| 2898 | + fn test_duplicate_inputs_rejected_at_conway_pv9() { |
2876 | 2899 | let (utxo_set, mut tx, input) = make_valid_tx(); |
2877 | 2900 | // Add the same input a second time. |
2878 | 2901 | tx.body.inputs.push(input.clone()); |
2879 | | - let params = ProtocolParameters::mainnet_defaults(); |
| 2902 | + let params = ProtocolParameters::mainnet_defaults(); // PV9 |
2880 | 2903 | let errors = validate_transaction(&tx, &utxo_set, ¶ms, 100, 300, None).unwrap_err(); |
2881 | 2904 | assert!( |
2882 | 2905 | errors |
2883 | 2906 | .iter() |
2884 | 2907 | .any(|e| matches!(e, ValidationError::DuplicateInput(_))), |
2885 | | - "expected DuplicateInput, got {errors:?}" |
| 2908 | + "expected DuplicateInput at PV9, got {errors:?}" |
| 2909 | + ); |
| 2910 | + } |
| 2911 | + |
| 2912 | + // ----------------------------------------------------------------------- |
| 2913 | + // Test 31b — Rule 1b: duplicate inputs silently accepted at Babbage PV8 |
| 2914 | + // |
| 2915 | + // Haskell `decodeSet` at PV < 9 routes through `Set.fromList` which |
| 2916 | + // silently deduplicates. `BabbageUtxoPredFailure` has no DuplicateInput |
| 2917 | + // constructor. Real mainnet tx 5ca83e21… (epoch 484, slot 123728795, |
| 2918 | + // PV8) has body key 0 = array(3) with the same TxIn listed twice; it was |
| 2919 | + // accepted by cardano-node 8.x and is on-chain. |
| 2920 | + // ----------------------------------------------------------------------- |
| 2921 | + #[test] |
| 2922 | + fn test_duplicate_inputs_accepted_at_babbage_pv8() { |
| 2923 | + let (utxo_set, mut tx, input) = make_valid_tx(); |
| 2924 | + // Add the same input a second time — simulating the PV8 wire encoding. |
| 2925 | + tx.body.inputs.push(input.clone()); |
| 2926 | + let mut params = ProtocolParameters::mainnet_defaults(); |
| 2927 | + params.protocol_version_major = 8; // Babbage |
| 2928 | + // With a duplicate input and PV8, Phase-1 must NOT emit DuplicateInput. |
| 2929 | + // (Other errors may fire — the point is DuplicateInput is absent.) |
| 2930 | + let result = validate_transaction(&tx, &utxo_set, ¶ms, 100, 300, None); |
| 2931 | + let no_dup_error = match &result { |
| 2932 | + Ok(()) => true, |
| 2933 | + Err(errors) => !errors |
| 2934 | + .iter() |
| 2935 | + .any(|e| matches!(e, ValidationError::DuplicateInput(_))), |
| 2936 | + }; |
| 2937 | + assert!( |
| 2938 | + no_dup_error, |
| 2939 | + "DuplicateInput must not fire at PV8 (Babbage), got {result:?}" |
| 2940 | + ); |
| 2941 | + } |
| 2942 | + |
| 2943 | + // ----------------------------------------------------------------------- |
| 2944 | + // Test 31c — Rule 1b: real mainnet Babbage tx 5ca83e21 with duplicate |
| 2945 | + // spend input accepted (issue #759 regression pin) |
| 2946 | + // |
| 2947 | + // tx 5ca83e216eb4fce8e907ed3597bd290261136ae97fc4cd7fbd5eadf9bbedf09f |
| 2948 | + // mainnet epoch 484, slot 123728795, block 10294413 (PV8 = Babbage). |
| 2949 | + // Body key 0 = plain array(3): [ab2829f0…#1, ab2829f0…#1, 3bd13603…#0] |
| 2950 | + // The same TxIn `ab2829f03f…#1` appears twice. Haskell accepted it |
| 2951 | + // (it is on-chain); dugite must not reject with DuplicateInput. |
| 2952 | + // |
| 2953 | + // Note: this test decodes the raw CBOR and checks that DuplicateInput is |
| 2954 | + // absent; it does NOT reconstruct a full UTxO environment, so Phase-1 |
| 2955 | + // may emit BadInputs/ValueNotConserved. The critical invariant is that |
| 2956 | + // DuplicateInput is NEVER in the error list when PV < 9. |
| 2957 | + // ----------------------------------------------------------------------- |
| 2958 | + const TX_5CA83E21_HEX: &str = include_str!("fixtures/tx-5ca83e21.hex"); |
| 2959 | + |
| 2960 | + #[test] |
| 2961 | + fn test_mainnet_babbage_duplicate_input_5ca83e21_no_false_positive() { |
| 2962 | + let s = TX_5CA83E21_HEX.trim(); |
| 2963 | + let bytes: Vec<u8> = (0..s.len()) |
| 2964 | + .step_by(2) |
| 2965 | + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex")) |
| 2966 | + .collect(); |
| 2967 | + // Decode as era 5 (Babbage) |
| 2968 | + let tx = dugite_serialization::decode::decode_transaction(5, &bytes) |
| 2969 | + .expect("decode real mainnet Babbage tx 5ca83e21"); |
| 2970 | + |
| 2971 | + // Verify the wire-level duplicate is preserved by the decoder |
| 2972 | + assert_eq!( |
| 2973 | + tx.body.inputs.len(), |
| 2974 | + 3, |
| 2975 | + "wire has array(3); decoder must preserve physical element count" |
| 2976 | + ); |
| 2977 | + assert_eq!( |
| 2978 | + tx.body.inputs[0], tx.body.inputs[1], |
| 2979 | + "first two inputs must be identical (wire duplicate)" |
| 2980 | + ); |
| 2981 | + |
| 2982 | + // Validate against an empty UTxO (BadInputs expected, DuplicateInput forbidden) |
| 2983 | + let empty_utxo = UtxoSet::new(); |
| 2984 | + let mut params = ProtocolParameters::mainnet_defaults(); |
| 2985 | + params.protocol_version_major = 8; // PV8 = Babbage |
| 2986 | + let result = validate_transaction(&tx, &empty_utxo, ¶ms, 123_728_795, 500, None); |
| 2987 | + let has_dup_error = match &result { |
| 2988 | + Ok(()) => false, |
| 2989 | + Err(errors) => errors |
| 2990 | + .iter() |
| 2991 | + .any(|e| matches!(e, ValidationError::DuplicateInput(_))), |
| 2992 | + }; |
| 2993 | + assert!( |
| 2994 | + !has_dup_error, |
| 2995 | + "DuplicateInput must not fire for PV8 Babbage tx 5ca83e21 (issue #759): {result:?}" |
2886 | 2996 | ); |
2887 | 2997 | } |
2888 | 2998 |
|
|
0 commit comments