Skip to content

Commit 0885f39

Browse files
authored
fix(cbor): tolerate duplicate map keys in pre-Conway MultiAsset decode (#1850)
Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>
1 parent 03e5e25 commit 0885f39

7 files changed

Lines changed: 386 additions & 3 deletions

File tree

cbor/decode.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ var (
3434
cachedStrictDecMode _cbor.DecMode
3535
cachedStrictDecModeErr error
3636
cachedStrictDecModeOnce sync.Once
37+
38+
cachedLenientDecMode _cbor.DecMode
39+
cachedLenientDecModeErr error
40+
cachedLenientDecModeOnce sync.Once
3741
)
3842

3943
const cborMaxNestedLevels = 256
@@ -73,6 +77,13 @@ func Decode(dataBytes []byte, dest any) (int, error) {
7377
return dec.NumBytesRead(), err
7478
}
7579

80+
// IsDuplicateMapKeyError returns true when err wraps fxamacker's duplicate map
81+
// key error.
82+
func IsDuplicateMapKeyError(err error) bool {
83+
var dupErr *_cbor.DupMapKeyError
84+
return errors.As(err, &dupErr)
85+
}
86+
7687
// getStrictDecMode returns a cached DecMode with stricter limits for untrusted
7788
// network data. Uses sync.Once for thread-safe lazy initialization.
7889
// Returns the cached error if initialization failed.
@@ -109,6 +120,57 @@ func DecodeStrict(dataBytes []byte, dest any) (int, error) {
109120
return dec.NumBytesRead(), err
110121
}
111122

123+
// getLenientDecMode returns a cached DecMode that mirrors getDecMode() but keeps
124+
// DupMapKeyQuiet (last-wins) instead of DupMapKeyEnforcedAPF. This matches the
125+
// pre-Conway (protocol version < 9) cardano-ledger map decoding semantics, which
126+
// use Map.fromList (later duplicate key wins, earlier discarded) rather than
127+
// rejecting duplicates. Uses sync.Once for thread-safe lazy initialization.
128+
// Returns the cached error if initialization failed.
129+
func getLenientDecMode() (_cbor.DecMode, error) {
130+
cachedLenientDecModeOnce.Do(func() {
131+
decOptions := _cbor.DecOptions{
132+
ExtraReturnErrors: _cbor.ExtraDecErrorUnknownField,
133+
// DupMapKeyQuiet: keep the last value for a duplicate key without
134+
// erroring. fxamacker decodes into a Go map via SetMapIndex per
135+
// entry, so a repeated key overwrites, which is byte-for-byte the
136+
// same resolution as cardano-ledger's Map.fromList for PV < 9.
137+
DupMapKey: _cbor.DupMapKeyQuiet,
138+
MaxNestedLevels: cborMaxNestedLevels,
139+
// Match getDecMode() limits: Cardano ledger state snapshots contain
140+
// stake distribution maps that can exceed 1M entries on mainnet.
141+
MaxMapPairs: 10_000_000,
142+
MaxArrayElements: 10_000_000,
143+
}
144+
cachedLenientDecMode, cachedLenientDecModeErr = decOptions.DecModeWithTags(customTagSet)
145+
})
146+
return cachedLenientDecMode, cachedLenientDecModeErr
147+
}
148+
149+
// DecodeLenient decodes CBOR data without rejecting duplicate map keys. How a
150+
// duplicate key is resolved is destination-type dependent, because it is
151+
// delegated to the underlying CBOR library's DupMapKeyQuiet mode, which "uses
152+
// faster of keep first or keep last depending on Go data type": decoding into a
153+
// Go map (MultiAsset's only current use) resolves last-wins, matching pre-Conway
154+
// (protocol version < 9) cardano-ledger Map.fromList semantics; decoding into a
155+
// Go struct may instead keep the first value written for a repeated field.
156+
// Callers that require last-wins semantics MUST therefore decode into a map, not
157+
// a struct. It is intended only for structures where Cardano consensus
158+
// historically permitted duplicate keys (e.g. pre-Conway MultiAsset value/mint
159+
// maps). Use Decode (strict, rejects duplicates) everywhere else.
160+
func DecodeLenient(dataBytes []byte, dest any) (int, error) {
161+
data := bytes.NewReader(dataBytes)
162+
decMode, err := getLenientDecMode()
163+
if err != nil {
164+
return 0, err
165+
}
166+
if decMode == nil {
167+
return 0, errors.New("CBOR lenient decoder mode not initialized")
168+
}
169+
dec := decMode.NewDecoder(data)
170+
err = dec.Decode(dest)
171+
return dec.NumBytesRead(), err
172+
}
173+
112174
// Extract the first item from a CBOR list. This will return the first item from the
113175
// provided list if it's numeric and an error otherwise
114176
func DecodeIdFromList(cborData []byte) (int, error) {

ledger/common/common.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,8 @@ type (
267267
// MultiAsset represents a collection of policies, assets, and quantities. It's used for
268268
// TX outputs (uint64) and TX asset minting (int64 to allow for negative values for burning)
269269
type MultiAsset[T int64 | uint64 | *big.Int] struct {
270-
data map[Blake2b224]map[cbor.ByteString]T
270+
data map[Blake2b224]map[cbor.ByteString]T
271+
duplicateMapKeys bool
271272
}
272273

273274
// NewMultiAsset creates a MultiAsset with the specified data
@@ -290,8 +291,27 @@ type multiAssetJson[T int64 | uint64 | *big.Int] struct {
290291
}
291292

292293
func (m *MultiAsset[T]) UnmarshalCBOR(data []byte) error {
293-
_, err := cbor.Decode(data, &(m.data))
294-
return err
294+
var decoded map[Blake2b224]map[cbor.ByteString]T
295+
m.duplicateMapKeys = false
296+
if _, err := cbor.Decode(data, &decoded); err == nil {
297+
m.data = decoded
298+
return nil
299+
} else if !cbor.IsDuplicateMapKeyError(err) {
300+
return err
301+
}
302+
303+
// Pre-Conway (protocol version < 9) cardano-ledger decodes value/mint maps
304+
// with Map.fromList, accepting duplicate PolicyID / AssetName keys and
305+
// keeping the last one. Such transactions exist on canonical pre-Conway
306+
// chains, so keep lenient last-wins decoding while recording that Conway+
307+
// era decoders must reject this value.
308+
m.duplicateMapKeys = true
309+
decoded = nil
310+
if _, err := cbor.DecodeLenient(data, &decoded); err != nil {
311+
return err
312+
}
313+
m.data = decoded
314+
return nil
295315
}
296316

297317
func (m *MultiAsset[T]) MarshalCBOR() ([]byte, error) {
@@ -300,6 +320,13 @@ func (m *MultiAsset[T]) MarshalCBOR() ([]byte, error) {
300320
return cbor.Encode(m.data)
301321
}
302322

323+
func (m *MultiAsset[T]) CheckForDuplicateKeys() error {
324+
if m != nil && m.duplicateMapKeys {
325+
return errors.New("duplicate map key in multiasset")
326+
}
327+
return nil
328+
}
329+
303330
func (m MultiAsset[T]) MarshalJSON() ([]byte, error) {
304331
tmpAssets := make([]multiAssetJson[T], 0, len(m.data))
305332
for policyId, policyData := range m.data {
@@ -325,6 +352,7 @@ func (m *MultiAsset[T]) UnmarshalJSON(data []byte) error {
325352
if err := json.Unmarshal(data, &tmpAssets); err != nil {
326353
return err
327354
}
355+
m.duplicateMapKeys = false
328356
if m.data == nil {
329357
m.data = make(map[Blake2b224]map[cbor.ByteString]T)
330358
}

ledger/common/common_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

ledger/conway/conway.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,11 +620,35 @@ func (b *ConwayTransactionBody) UnmarshalCBOR(cborData []byte) error {
620620
return err
621621
}
622622
}
623+
if err := checkMultiAssetDuplicateKeys(tmp.TxMint); err != nil {
624+
return err
625+
}
626+
for idx := range tmp.TxOutputs {
627+
if err := checkMultiAssetDuplicateKeys(tmp.TxOutputs[idx].Assets()); err != nil {
628+
return fmt.Errorf("transaction output %d: %w", idx, err)
629+
}
630+
}
631+
if tmp.TxCollateralReturn != nil {
632+
if err := checkMultiAssetDuplicateKeys(
633+
tmp.TxCollateralReturn.Assets(),
634+
); err != nil {
635+
return fmt.Errorf("collateral return: %w", err)
636+
}
637+
}
623638
*b = ConwayTransactionBody(tmp)
624639
b.SetCborReference(cborData)
625640
return nil
626641
}
627642

643+
func checkMultiAssetDuplicateKeys[T int64 | uint64 | *big.Int](
644+
assets *common.MultiAsset[T],
645+
) error {
646+
if assets == nil {
647+
return nil
648+
}
649+
return assets.CheckForDuplicateKeys()
650+
}
651+
628652
func (b *ConwayTransactionBody) Inputs() []common.TransactionInput {
629653
ret := make([]common.TransactionInput, 0, len(b.TxInputs.Items()))
630654
for _, input := range b.TxInputs.Items() {

ledger/conway/conway_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,45 @@ func TestConwayTransactionBodyRejectsDuplicateTaggedInputs(t *testing.T) {
222222
assert.ErrorContains(t, err, "duplicate member in set")
223223
}
224224

225+
func TestConwayTransactionBodyRejectsDuplicateMultiAssetKeys(t *testing.T) {
226+
tests := []struct {
227+
name string
228+
body []byte
229+
}{
230+
{
231+
name: "mint duplicate policy",
232+
body: append(
233+
[]byte{0xa1, 0x09},
234+
testDuplicatePolicyMultiAssetCbor(0x11)...,
235+
),
236+
},
237+
{
238+
name: "mint duplicate asset name",
239+
body: append(
240+
[]byte{0xa1, 0x09},
241+
testDuplicateAssetNameMultiAssetCbor(0x22)...,
242+
),
243+
},
244+
{
245+
name: "output duplicate asset name",
246+
body: append(
247+
[]byte{0xa1, 0x01, 0x81},
248+
testBabbageOutputWithAssetsCbor(
249+
t,
250+
testDuplicateAssetNameMultiAssetCbor(0x33),
251+
)...,
252+
),
253+
},
254+
}
255+
for _, tt := range tests {
256+
t.Run(tt.name, func(t *testing.T) {
257+
var body ConwayTransactionBody
258+
err := body.UnmarshalCBOR(tt.body)
259+
assert.ErrorContains(t, err, "duplicate map key")
260+
})
261+
}
262+
}
263+
225264
func TestConwayTransactionBodyRejectsDuplicateTaggedSetFields(t *testing.T) {
226265
input := testConwayShelleyInput()
227266
var signer common.Blake2b224
@@ -291,6 +330,42 @@ func testConwayShelleyInput() shelley.ShelleyTransactionInput {
291330
}
292331
}
293332

333+
func testDuplicatePolicyMultiAssetCbor(policyByte byte) []byte {
334+
policy := bytes.Repeat([]byte{policyByte}, common.Blake2b224Size)
335+
ret := []byte{0xa2, 0x58, 0x1c}
336+
ret = append(ret, policy...)
337+
ret = append(ret, 0xa1, 0x41, 0xaa, 0x01, 0x58, 0x1c)
338+
ret = append(ret, policy...)
339+
ret = append(ret, 0xa1, 0x41, 0xbb, 0x02)
340+
return ret
341+
}
342+
343+
func testDuplicateAssetNameMultiAssetCbor(policyByte byte) []byte {
344+
policy := bytes.Repeat([]byte{policyByte}, common.Blake2b224Size)
345+
ret := []byte{0xa1, 0x58, 0x1c}
346+
ret = append(ret, policy...)
347+
ret = append(ret, 0xa2, 0x41, 0xcc, 0x01, 0x41, 0xcc, 0x09)
348+
return ret
349+
}
350+
351+
func testBabbageOutputWithAssetsCbor(t *testing.T, assets []byte) []byte {
352+
t.Helper()
353+
addr, err := common.NewAddressFromBytes(
354+
test.DecodeHexString(
355+
"40000000000000000000000000000000000000000000000000000000008198bd431b03",
356+
),
357+
)
358+
assert.NoError(t, err)
359+
addrCbor, err := cbor.Encode(addr)
360+
assert.NoError(t, err)
361+
362+
ret := []byte{0xa2, 0x00}
363+
ret = append(ret, addrCbor...)
364+
ret = append(ret, 0x01, 0x82, 0x01)
365+
ret = append(ret, assets...)
366+
return ret
367+
}
368+
294369
func TestConwayTx_WithReferenceInputs_CborRoundTrip(t *testing.T) {
295370
// Test CBOR round-trip for transactions with reference inputs (CIP-0031)
296371

0 commit comments

Comments
 (0)