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

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