Skip to content

Commit 5ba80bb

Browse files
committed
fix(ledger): duplicate-input rejection only at PV>=9, matching Haskell Set decode (#759)
Phase-1 false positive on a confirmed mainnet Babbage block (epoch 482, PV8, slot 123,728,795): tx 5ca83e21… rejected with 'Duplicate input in transaction' — its spend-inputs array carried ab2829f03f…#1 twice. On-chain (Haskell-accepted), so dugite's rejection was wrong. Haskell cardano-ledger-binary decodeSet at PV<9 (Alonzo/Babbage) routes through Set.fromList ∘ decodeList, which SILENTLY drops duplicates (no DuplicateInput predicate exists in BabbageUtxoPredFailure). Only PV>=9 (Conway) uses decodeSetEnforceNoDuplicates and hard-fails. dugite's Rule 1b fired at all PVs. Fix (one line, phase1.rs Rule 1b): gate the DuplicateInput error on params.protocol_version_major >= 9. Conway+ rejection is unchanged (test_duplicate_inputs_rejected_at_conway_pv9 + proptests still pass); PV8 now accepts, pinned by the real on-chain tx fixture (test_mainnet_babbage_duplicate_input_5ca83e21_no_false_positive). 1574/1574 ledger tests.
1 parent ade35b0 commit 5ba80bb

3 files changed

Lines changed: 272 additions & 6 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
84ab0083825820ab2829f03f185af3eb048e1cd256899c7ddb575a112fcfe41a324e94d21707aa01825820ab2829f03f185af3eb048e1cd256899c7ddb575a112fcfe41a324e94d21707aa018258203bd13603e5e051f0b501da15260d26ad5948e4a3db54f4e3038416edf4f4d95e000183a300583901919e5c413a2e3d75a9b8d977d0c9818703a0b23d9ad87c29cf800de4d714cdab9f419c2c3d8b414a936a02fbeac8fe48dc841ba34e1e51c2011a00cce451028201d818582fd8799fd8799fd8799f5820ab2829f03f185af3eb048e1cd256899c7ddb575a112fcfe41a324e94d21707aaff01ffff825839015694f676db64afc70ff5b1cb0b3ce1b33d3d5b77a2a46a8f327dc6d35089ac70dc53a538a2052897ecdc45638776889783a46a2274fe59711ad57ce924825839015694f676db64afc70ff5b1cb0b3ce1b33d3d5b77a2a46a8f327dc6d35089ac70dc53a538a2052897ecdc45638776889783a46a2274fe59711a009549e0021a00034ca0031a07600fb3081a075ff3630b58206bc59041a3746e726ecc76cb6071a413c629644ab89bd91d67780960963d23120d818258203bd13603e5e051f0b501da15260d26ad5948e4a3db54f4e3038416edf4f4d95e000e82581c5694f676db64afc70ff5b1cb0b3ce1b33d3d5b77a2a46a8f327dc6d3581c19524fed350e6a9e928c231f51f737388eaf3f8d547732f68e02bab110825839015694f676db64afc70ff5b1cb0b3ce1b33d3d5b77a2a46a8f327dc6d35089ac70dc53a538a2052897ecdc45638776889783a46a2274fe59711a00895440111a000f42401281825820e92ac620bf095094d58a19616d1b6debb9b1cf305870264b1a58446c51d7f4b000a20083825820ca7c3c7870c113704ce57e6db6fd385e43e7ea399848cc682cc86b0a2965e1565840835b49da4857ef54b0d35fa66ae68df7722d7e241e18e111e287fb9b7aefa31c975dd61c8f032989937a7a49c3f4a9894b06e50a0e3fb05d07373257db08fd01825820bf8cca1c95f23d3864c21ce6301508644b883dfcf46ecc739ca2df382aaad2ff5840af72a39b186570860a16f88822cffb6e2ee90df35ef55350559853358a5d66508fdea32106be1a95a0ca40ac05879f487837b4ac1fd7bbaa569ed65686b6010182582037fb59abf680ba541f1664ce98cd20d5b730875fef834b4403fc483727db96d9584059fc744e3ac62b160547f6e366bf9a36ef912d2d5ba26f29921f241b72ceb237f3dff914e85eae5061727bd50200533b1403c9900c53b136000e7450100c7e000581840001d87b80821a00029d491a03b6aaadf5f6

crates/dugite-ledger/src/validation/phase1.rs

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! execution. Each numbered rule corresponds to a distinct ledger invariant:
55
//!
66
//! - 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)
88
//! - Rule 1c — auxiliary data hash / auxiliary data consistency
99
//! - Rule 1d — era gating (Conway-only certs/governance in pre-Conway eras)
1010
//! - Rule 2 — all inputs exist in the UTxO set
@@ -622,11 +622,31 @@ pub(super) fn run_phase1_rules(
622622

623623
// ------------------------------------------------------------------
624624
// 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`.
625642
// ------------------------------------------------------------------
626643
{
627644
let mut seen = HashSet::new();
628645
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 {
630650
errors.push(ValidationError::DuplicateInput(input.to_string()));
631651
}
632652
}
@@ -2869,20 +2889,110 @@ mod tests {
28692889
}
28702890

28712891
// -----------------------------------------------------------------------
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.
28732896
// -----------------------------------------------------------------------
28742897
#[test]
2875-
fn test_duplicate_inputs_rejected() {
2898+
fn test_duplicate_inputs_rejected_at_conway_pv9() {
28762899
let (utxo_set, mut tx, input) = make_valid_tx();
28772900
// Add the same input a second time.
28782901
tx.body.inputs.push(input.clone());
2879-
let params = ProtocolParameters::mainnet_defaults();
2902+
let params = ProtocolParameters::mainnet_defaults(); // PV9
28802903
let errors = validate_transaction(&tx, &utxo_set, &params, 100, 300, None).unwrap_err();
28812904
assert!(
28822905
errors
28832906
.iter()
28842907
.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, &params, 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, &params, 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:?}"
28862996
);
28872997
}
28882998

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# Issue #759 — Phase-1 False Positive: Duplicate Input in Babbage Tx
2+
3+
## Summary
4+
5+
**Classification:** SMALL+SAFE one-liner fix, strong regression tests, zero behavioral change for Conway+.
6+
7+
**Root cause:** Phase-1 Rule 1b fired unconditionally for all protocol versions. Haskell only enforces uniqueness at PV >= 9 (Conway+). The rule must be gated on `protocol_version_major >= 9`.
8+
9+
---
10+
11+
## Evidence
12+
13+
### Transaction under investigation
14+
15+
```
16+
tx hash : 5ca83e216eb4fce8e907ed3597bd290261136ae97fc4cd7fbd5eadf9bbedf09f
17+
block : 10294413
18+
epoch : 484 (PV8 = Babbage)
19+
slot : 123,728,795
20+
```
21+
22+
### Wire structure of body key 0 (spend inputs)
23+
24+
Decoded from Koios MAINNET REST (`/api/v1/tx_cbor`):
25+
26+
```
27+
Key 0 (inputs): plain array(3) — NO tag 258
28+
[0] ab2829f03f185af3eb048e1cd256899c7ddb575a112fcfe41a324e94d21707aa idx=1
29+
[1] ab2829f03f185af3eb048e1cd256899c7ddb575a112fcfe41a324e94d21707aa idx=1 ← IDENTICAL to [0]
30+
[2] 3bd13603e5e051f0b501da15260d26ad5948e4a3db54f4e3038416edf4f4d95e idx=0
31+
32+
Key 13 (collateral): array(1)
33+
[0] 3bd13603e5e051f0b501da15260d26ad5948e4a3db54f4e3038416edf4f4d95e idx=0
34+
35+
Key 18 (reference_inputs): array(1)
36+
[0] e92ac620bf095094d58a19616d1b6debb9b1cf305870264b1a58446c51d7f4b0 idx=0
37+
```
38+
39+
`ab2829f03f...#1` appears **twice in the spend inputs**. This is not a collateral/reference overlap — it is a genuine wire-level duplicate in a plain (non-tag-258) CBOR array.
40+
41+
---
42+
43+
## Haskell Behavior (PV8, Babbage)
44+
45+
### Reference: `cardano-ledger-binary` `decodeSet`
46+
47+
At PV < 9, `decodeSet` routes through the **lenient path**:
48+
49+
```haskell
50+
-- cardano-ledger-binary Cardano.Ledger.Binary.Decoding.Coders
51+
-- PV < 9: Set.fromList <$> decodeList decoder (silent dedup)
52+
-- PV >= 9: decodeSetEnforceNoDuplicates (hard fail)
53+
```
54+
55+
`Set.fromList [A, A, B]` = `{A, B}` — the duplicate is silently dropped. No exception, no predicate failure.
56+
57+
### Reference: `BabbageUtxoPredFailure` constructors
58+
59+
```haskell
60+
-- eras/babbage/impl/src/Cardano/Ledger/Babbage/Rules/Utxo.hs
61+
data BabbageUtxoPredFailure era
62+
= AlonzoInBabbageUtxoPredFailure !(AlonzoUtxoPredFailure era)
63+
| BabbageNonDisjointRefInputs !(Set TxIn) -- PV9-10 only
64+
```
65+
66+
`AlonzoUtxoPredFailure` has **no** `DuplicateInput` or `DuplicateInputs` constructor. The complete list of Alonzo UTXO failures is: OutsideValidityIntervalUTxO, InputSetEmptyUTxO, FeeTooSmallUTxO, BadInputsUTxO, ValueNotConservedUTxO, OutputTooSmallUTxO, OutputBootAddrAttrsTooBig, MaxTxSizeUTxO, WrongNetwork, WrongNetworkWithdrawal, OutputTooBigUTxO, InsufficientCollateral, ScriptsNotPaidUTxO, ExUnitsTooBigUTxO, CollateralContainsNonADA, WrongNetworkInTxBody, OutsideForecast, TooManyCollateralInputs, NoCollateralInputs.
67+
68+
**Conclusion:** Haskell silently accepts a Babbage tx with duplicate spend inputs. No predicate failure is produced. The duplicate is eliminated by `Set.fromList` before any validation sees it.
69+
70+
---
71+
72+
## Root Cause in Dugite
73+
74+
**File:** `crates/dugite-ledger/src/validation/phase1.rs`
75+
76+
**Location:** lines 624-633 (before fix)
77+
78+
```rust
79+
// Rule 1b: No duplicate inputs NO era/PV gate
80+
{
81+
let mut seen = HashSet::new();
82+
for input in &body.inputs {
83+
if !seen.insert(input) {
84+
errors.push(ValidationError::DuplicateInput(input.to_string()));
85+
}
86+
}
87+
}
88+
```
89+
90+
The check runs unconditionally for ALL protocol versions. For the Babbage tx at PV8 with `ab2829f03f...#1` appearing twice, dugite emits `DuplicateInput("ab2829f03f…")` and rejects the tx. Haskell accepts it.
91+
92+
**Note:** The stake-distribution deduplication in `eras/common.rs:184-189` (the `seen_inputs` HashSet filter) is already correct — it silently deduplicates before consuming UTxOs. That code path only needed the Phase-1 validation gate removed.
93+
94+
---
95+
96+
## The Fix
97+
98+
**File:** `crates/dugite-ledger/src/validation/phase1.rs`, Rule 1b (lines 624-653 after fix)
99+
100+
**Change:** Add `&& params.protocol_version_major >= 9` guard on the error push:
101+
102+
```rust
103+
{
104+
let mut seen = HashSet::new();
105+
for input in &body.inputs {
106+
// PV < 9 (Alonzo/Babbage): Haskell `Set.fromList` silently dedups —
107+
// no rejection. PV >= 9 (Conway+): hard-fail mirrors
108+
// `decodeSetEnforceNoDuplicates`.
109+
if !seen.insert(input) && params.protocol_version_major >= 9 {
110+
errors.push(ValidationError::DuplicateInput(input.to_string()));
111+
}
112+
}
113+
}
114+
```
115+
116+
This is a **one-line semantic change** with zero risk to Conway+. Conway/Dijkstra txs with duplicate inputs still get `DuplicateInput`. Alonzo/Babbage txs with wire-duplicate inputs are silently accepted (matching Haskell).
117+
118+
---
119+
120+
## Tests Added
121+
122+
Three new tests in `crates/dugite-ledger/src/validation/phase1.rs`:
123+
124+
| Test | Purpose |
125+
|------|---------|
126+
| `test_duplicate_inputs_rejected_at_conway_pv9` | Conway (PV9) still rejects — renamed from old Test 31 |
127+
| `test_duplicate_inputs_accepted_at_babbage_pv8` | Babbage (PV8) accepts duplicate inputs — negative control |
128+
| `test_mainnet_babbage_duplicate_input_5ca83e21_no_false_positive` | Real mainnet tx 5ca83e21 with fixture pinned at `crates/dugite-ledger/src/validation/fixtures/tx-5ca83e21.hex` |
129+
130+
All 3 new tests PASS. Full suite: 1574/1574 pass, 0 failures, 6 skipped (pre-existing skips).
131+
132+
**CI checks:**
133+
- `cargo clippy -p dugite-ledger --all-targets -- -D warnings`: CLEAN
134+
- `cargo fmt --all -- --check`: CLEAN
135+
- `cargo nextest run -p dugite-ledger`: 1574/1574 PASS
136+
137+
---
138+
139+
## Files Changed
140+
141+
| File | Change |
142+
|------|--------|
143+
| `crates/dugite-ledger/src/validation/phase1.rs` | Rule 1b: gate `DuplicateInput` on `pv >= 9`; add 3 tests |
144+
| `crates/dugite-ledger/src/validation/fixtures/tx-5ca83e21.hex` | Real mainnet Babbage tx CBOR fixture (issue #759 pin) |
145+
146+
---
147+
148+
## Risk Assessment: SMALL+SAFE
149+
150+
- **Blast radius:** One `&&` condition on one `errors.push()` call.
151+
- **Conway+ behavior:** Unchanged. PV9 test still fires and passes.
152+
- **Pre-Conway behavior:** Duplicate inputs now silently accepted (Haskell-exact).
153+
- **Downstream impact:** `eras/common.rs` `seen_inputs` dedup was already correct — this fix only removes the false Phase-1 rejection.
154+
- **No SNAPSHOT_VERSION bump needed** — no ledger state struct changes.
155+
- **Recommended placement:** v2.0.6 (already on `main`).

0 commit comments

Comments
 (0)