Skip to content

CBOR: Plutus data encodings change on re-encode, breaking the script data hash and the transaction id #576

Description

@solidsnakedev

Summary

Six encodings of Plutus data that the ledger accepts come back from Transaction.fromCBORHex then Transaction.toCBORHex in different bytes, with nothing changed:

Received Written back Value
d8799fff d87980 constructor 0, empty indefinite list
bfff a0 empty indefinite map
d8799800 d87980 empty list, 2-byte length header
c24101 01 1 as a bignum
5803cccccc 43cccccc 3 bytes, 2-byte length header
5f4201024103ff 43010203 bytes in chunks

The ledger keeps the original bytes of every datum and redeemer and hashes those:

  • plutus Data.hs L215-230 and L245-283 decode all six forms: indefinite and definite lists and maps, bignums through decodeBoundedBigInteger, and chunked bytes through decodeBoundedBytesIndef.
  • cardano-ledger Plutus/Data.hs L95: newtype Data era = MkData (MemoBytes (PlutusData era)).
  • Alonzo/Tx.hs L318-321: the script integrity hash is taken over originalBytes of the redeemers and datums.

So a changed witness datum or redeemer fails with PPViewHashesDontMatch, and a changed inline datum changes the body and the transaction id.

Affected

packages/evolution/src/CBOR.ts

  • encodeArraySync (L1314) and encodeMapSync (L1371): the empty fast paths return 80 and a0 before the captured format is read
  • the BoundedBytes branch (L962-968) calls encodeBoundedBytesSync with no format, so header width and chunking are dropped
  • encodeUintSync (L983-995) emits tag 2 only above 2^64-1 and honours only a uint format, so a captured tag 2 or 3 node is ignored

Fix

Give each of these paths the captured format, and use it only when it still fits the value:

  • read the format before the empty fast paths
  • pass the format to the bounded bytes encoder: keep the header width, and keep the chunk layout only when the chunk sizes add up to the value's length and none is over 64 bytes
  • when the captured node is tag 2 or 3, write the value as that bignum

Land this with or after #575 (stale chunk sizes). Passing the format into the bounded bytes encoder without that guard would carry the stale-chunk corruption into datums.

Regression test

Oracle is the node; CML agrees on every case below.

  • given: each encoding above as a witness datum (a1 04 d90102 81 [datum]) and as an inline datum ([1, #6.24(bytes)])
  • before fix: the bytes change in both places; for the inline datum the body hash changes
  • after fix: byte-identical in both places
  • control: d8799f01ff and a correctly chunked 65-byte value already round-trip and must stay unchanged; a builder-made datum is unchanged

Devnet, raw submission through Ogmios:

  • all six as inline datums: original accepted; after addVKeyWitnessesHex, rejected with 3100 invalid signatures
  • d8799fff as a witness datum, with a datum-hash output and the script data hash in the body: original accepted; after addVKeyWitnessesHex, rejected with 3113 script integrity hash mismatch

Must FAIL on main today and PASS after the fix.

Reference

#235 (fixed in #236) covered non-empty indefinite lists in redeemers; these are the cases it did not reach. Related: #530 (bignum chunking above 64 bytes), #395 (Data map default), #397 (duplicate keys in Data maps).

Activity

  1. solidsnakedev commented on Sep 30, 2026

    @solidsnakedev
    CollaboratorAuthor

    Before starting a fix here, see #581. If components keep their original bytes, an untouched part is no longer re-encoded, which settles this issue on the plain round trip and on addVKeyWitnesses. What would remain is the form written for an edited component, so a fix is better done after #581 is decided.

  2. solidsnakedev commented on Oct 7, 2026

    @solidsnakedev
    CollaboratorAuthor

    The inline-datum part is fixed in #603: decoded inline datums keep their original bytes, so adding a witness keeps the transaction id. The devnet accepted a witnessed transaction carrying both map layouts, and main rejected the same flow with 3100. Witness datums and redeemers remain open here.

  3. solidsnakedev commented on Oct 7, 2026

    @solidsnakedev
    CollaboratorAuthor

    One more case, found while working on #604: a map with byte-string keys inside a witness datum or a redeemer. Transaction.fromCBORHex then toCBORHex rewrites the value under each key with the transaction options:

    Received Written back
    d8799fa141019f0102ffff d8799fa14101820102ff
    d8799fbf41019f0102ffffff d8799fbf4101820102ffff

    Integer keys are not affected. The cause is in encodeMapSync: a key read back from the captured key order is a plain byte array, while the same key in a data map is a BoundedBytes node, so the two never compare equal. The encoder treats the key as new and drops its captured format. A fix that compares the bytes is coming in a separate PR, and #604 builds on it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions