Skip to content

Commit bd5bb47

Browse files
committed
fix(conway): tolerate duplicate redeemer key
Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>
1 parent 57d0e89 commit bd5bb47

2 files changed

Lines changed: 61 additions & 2 deletions

File tree

ledger/conway/conway.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,8 +290,25 @@ func (r *ConwayRedeemers) UnmarshalCBOR(cborData []byte) error {
290290
// Modern map form — clear any stale legacy state
291291
r.legacy = false
292292
r.legacyRedeemers = alonzo.AlonzoRedeemers{}
293-
_, err := cbor.Decode(cborData, &(r.Redeemers))
294-
return err
293+
if _, err := cbor.Decode(cborData, &(r.Redeemers)); err != nil {
294+
if !cbor.IsDuplicateMapKeyError(err) {
295+
return err
296+
}
297+
// A Redeemers map with a duplicate (tag, index) key. cardano-ledger
298+
// decodes this map last-wins and cardano-node accepts such blocks,
299+
// so they appear on canonical chains (gouroboros #1860: a preview
300+
// block carries a duplicate (Spend, 0) redeemer that cardano-node
301+
// accepts). Reject-on-duplicate poisons every peer serving the block
302+
// and stalls sync, so decode leniently (last-wins) to match
303+
// consensus. Hash-safe: redeemer/tx/block hashes are computed over
304+
// the stored raw CBOR (SetCbor above), so lenient struct decoding
305+
// never changes them.
306+
r.Redeemers = nil
307+
if _, err := cbor.DecodeLenient(cborData, &(r.Redeemers)); err != nil {
308+
return err
309+
}
310+
}
311+
return nil
295312
}
296313
// Legacy array form — clear any stale map state
297314
r.Redeemers = nil

ledger/conway/conway_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,3 +599,45 @@ func TestConwayTx_WithReferenceScripts_CborRoundTrip(t *testing.T) {
599599
"CBOR round-trip should be byte-identical",
600600
)
601601
}
602+
603+
// TestConwayRedeemersDuplicateKeyLenient verifies that a Redeemers map carrying
604+
// a duplicate (tag, index) key — which cardano-node accepts and strict decoding
605+
// rejects — is decoded leniently (last-wins) instead of failing the block.
606+
// See gouroboros #1860.
607+
func TestConwayRedeemersDuplicateKeyLenient(t *testing.T) {
608+
// Redeemers map with two entries under the same key [Spend(0), index 0]:
609+
// { [0,0]: [0,[1,2]], [0,0]: [0,[3,4]] }
610+
// Plutus data is the bare integer 0; the value's second element is
611+
// ExUnits [mem, steps].
612+
dupCbor := []byte{
613+
0xA2, // map(2)
614+
0x82, 0x00, 0x00, // key [Spend, 0]
615+
0x82, 0x00, 0x82, 0x01, 0x02, // value [datum=0, exUnits=[1,2]]
616+
0x82, 0x00, 0x00, // key [Spend, 0] (duplicate)
617+
0x82, 0x00, 0x82, 0x03, 0x04, // value [datum=0, exUnits=[3,4]]
618+
}
619+
// Strict decode rejects the duplicate key (the pre-fix behavior that
620+
// wedged preview sync).
621+
var strict map[common.RedeemerKey]common.RedeemerValue
622+
_, strictErr := cbor.Decode(dupCbor, &strict)
623+
assert.Error(t, strictErr)
624+
assert.True(
625+
t,
626+
cbor.IsDuplicateMapKeyError(strictErr),
627+
"expected a duplicate-map-key error, got %v",
628+
strictErr,
629+
)
630+
// ConwayRedeemers decodes it, keeping the last value for the duplicate key.
631+
var r ConwayRedeemers
632+
if err := r.UnmarshalCBOR(dupCbor); err != nil {
633+
t.Fatalf("lenient decode of duplicate-key redeemers failed: %v", err)
634+
}
635+
key := common.RedeemerKey{Tag: common.RedeemerTagSpend, Index: 0}
636+
assert.Len(t, r.Redeemers, 1)
637+
assert.Equal(
638+
t,
639+
common.ExUnits{Memory: 3, Steps: 4},
640+
r.Redeemers[key].ExUnits,
641+
"duplicate key should resolve last-wins",
642+
)
643+
}

0 commit comments

Comments
 (0)