@@ -1406,3 +1406,96 @@ func TestAddInt64Checked(t *testing.T) {
14061406 })
14071407 }
14081408}
1409+
1410+ // TestMultiAssetUnmarshalDuplicatePolicyLastWins verifies that decoding a
1411+ // MultiAsset whose outer (PolicyID) map contains a duplicate key does not error
1412+ // and resolves last-wins, matching pre-Conway (protocol version < 9)
1413+ // cardano-ledger Map.fromList semantics. Such transactions exist on the
1414+ // canonical preprod Babbage chain, so rejecting them would wedge sync.
1415+ func TestMultiAssetUnmarshalDuplicatePolicyLastWins (t * testing.T ) {
1416+ // 28-byte PolicyID, repeated as two outer map entries.
1417+ policyBytes := bytes .Repeat ([]byte {0x11 }, Blake2b224Size )
1418+ policyHex := hex .EncodeToString (policyBytes )
1419+ // a2 outer map, 2 pairs
1420+ // 581c <28-byte policy> key: PolicyID
1421+ // a1 41 aa 01 value: { 0xAA: 1 }
1422+ // 581c <28-byte policy> key: same PolicyID (duplicate)
1423+ // a1 41 bb 02 value: { 0xBB: 2 }
1424+ cborHex := "a2" +
1425+ "581c" + policyHex + "a141aa01" +
1426+ "581c" + policyHex + "a141bb02"
1427+ cborData , err := hex .DecodeString (cborHex )
1428+ assert .NoError (t , err )
1429+
1430+ var ma MultiAsset [MultiAssetTypeMint ]
1431+ if err := ma .UnmarshalCBOR (cborData ); err != nil {
1432+ t .Fatalf ("unexpected error decoding MultiAsset with duplicate PolicyID: %v" , err )
1433+ }
1434+ assert .ErrorContains (t , ma .CheckForDuplicateKeys (), "duplicate map key" )
1435+
1436+ policy := NewBlake2b224 (policyBytes )
1437+ // Last-wins: only the second entry survives, so the policy maps to {0xBB: 2}
1438+ // and the first entry's {0xAA: 1} is discarded entirely.
1439+ if len (ma .data ) != 1 {
1440+ t .Fatalf ("expected 1 policy after last-wins merge, got %d" , len (ma .data ))
1441+ }
1442+ inner , ok := ma .data [policy ]
1443+ if ! ok {
1444+ t .Fatalf ("expected surviving policy %x to be present" , policyBytes )
1445+ }
1446+ if len (inner ) != 1 {
1447+ t .Fatalf ("expected surviving inner asset map to have 1 entry, got %d" , len (inner ))
1448+ }
1449+ last := ma .Asset (policy , []byte {0xBB })
1450+ if last == nil || last .Int64 () != 2 {
1451+ t .Fatalf ("expected last-wins asset 0xBB == 2, got %v" , last )
1452+ }
1453+ if dropped := ma .Asset (policy , []byte {0xAA }); dropped != nil {
1454+ t .Fatalf ("expected first duplicate asset 0xAA to be discarded, got %v" , dropped )
1455+ }
1456+ }
1457+
1458+ // TestMultiAssetUnmarshalDuplicateAssetNameLastWins verifies that a duplicate
1459+ // key in the inner (AssetName) map is also tolerated and resolved last-wins,
1460+ // not just the outer (PolicyID) map. Both maps decode through the same lenient
1461+ // path into Go maps, so both follow pre-Conway (protocol version < 9)
1462+ // cardano-ledger Map.fromList semantics. This pins the behavior MultiAsset
1463+ // relies on; a future change to strict decoding must not silently regress it.
1464+ func TestMultiAssetUnmarshalDuplicateAssetNameLastWins (t * testing.T ) {
1465+ policyBytes := bytes .Repeat ([]byte {0x22 }, Blake2b224Size )
1466+ policyHex := hex .EncodeToString (policyBytes )
1467+ // a1 outer map, 1 pair
1468+ // 581c <28-byte policy> key: PolicyID
1469+ // a2 value: inner map, 2 pairs
1470+ // 41 cc 01 key: 0xCC -> 1
1471+ // 41 cc 09 key: 0xCC (duplicate) -> 9
1472+ cborHex := "a1" +
1473+ "581c" + policyHex +
1474+ "a2" + "41cc01" + "41cc09"
1475+ cborData , err := hex .DecodeString (cborHex )
1476+ assert .NoError (t , err )
1477+
1478+ var ma MultiAsset [MultiAssetTypeMint ]
1479+ if err := ma .UnmarshalCBOR (cborData ); err != nil {
1480+ t .Fatalf ("unexpected error decoding MultiAsset with duplicate AssetName: %v" , err )
1481+ }
1482+ assert .ErrorContains (t , ma .CheckForDuplicateKeys (), "duplicate map key" )
1483+
1484+ policy := NewBlake2b224 (policyBytes )
1485+ if len (ma .data ) != 1 {
1486+ t .Fatalf ("expected 1 policy, got %d" , len (ma .data ))
1487+ }
1488+ inner , ok := ma .data [policy ]
1489+ if ! ok {
1490+ t .Fatalf ("expected policy %x to be present" , policyBytes )
1491+ }
1492+ // Last-wins: the duplicate AssetName collapses to a single entry with the
1493+ // second value.
1494+ if len (inner ) != 1 {
1495+ t .Fatalf ("expected 1 inner asset after last-wins merge, got %d" , len (inner ))
1496+ }
1497+ last := ma .Asset (policy , []byte {0xCC })
1498+ if last == nil || last .Int64 () != 9 {
1499+ t .Fatalf ("expected last-wins asset 0xCC == 9, got %v" , last )
1500+ }
1501+ }
0 commit comments