Summary
A transaction whose sets are plain or indefinite arrays, with no tag 258, comes back from Transaction.fromCBORHex then Transaction.toCBORHex with d90102 added and a definite length. Nothing else was changed. The body bytes change, so the transaction id changes and every witness already collected no longer verifies. Transaction.addVKeyWitnessesHex does the same, because it decodes and re-encodes rather than splicing.
wallet sends inputs as: 00 81 [input]
SDK writes back: 00 d90102 81 [input]
The ledger accepts both forms and hashes the bytes as received:
- cardano-ledger
Decoder.hs L910-916: from protocol version 9, "Set tag 258 is permitted, but not enforced", with definite or indefinite length.
decodeSetLikeEnforceNoDuplicates L1053 and Annotated.hs L238, L258 use allowTag setTag for body and witness sets.
Core.hs L651: txIdTxBody = TxId . hashAnnotated, over the original body bytes.
Affected
packages/evolution/src/TransactionBody.ts
- decode accepts tagged and untagged sets (L323-328)
- encode always emits
CBOR.Tag 258 for keys 0, 4, 13, 14, 18, 20 (L227, L249, L275, L284, L301, L312)
packages/evolution/src/TransactionWitnessSet.ts
- encode always emits tag 258 for keys 0, 1, 2, 3, 4, 6, 7 (L323, L332, L341, L348, L357, L380, L387)
packages/evolution/src/CBOR.ts
encodeTagSync (L1545) uses a captured format only when it is a tag node. The captured node for an untagged set is an array, so the whole subtree is encoded with no format, and nested choices such as an input index written as 1800 are lost too.
Fix
Let the captured format decide whether the tag is written. When the format at a tag-258 position is an array node, encode the inner array with that node and omit the tag. With no captured format, keep writing the tag, so freshly built transactions do not change. This covers both uses of the format tree:
- a plain round trip reproduces the input exactly;
- an edit, such as a witness appended to an untagged list, keeps the original form of everything around it.
Regression test
Oracle is the node; CML agrees on every case below.
- given:
84 a3 00 81 [input] 01 81 [output] 02 [fee] a0 f5 f6, and the same with 00 9f [input] ff
- before fix:
toCBORHex(fromCBORHex(hex)) adds d90102; the body hash differs from blake2b-256 of the original body
- after fix: byte-identical, and
addVKeyWitnessesHex leaves the body bytes unchanged
- also: untagged vkey witnesses (
a1 00 81 ...) and untagged certificates, required signers, collateral, reference inputs
- control: tagged sets keep the tag; a builder-made transaction is unchanged
Devnet, raw submission through Ogmios:
- original bytes signed over their own body: accepted
- same transaction after
addVKeyWitnessesHex: rejected, 3100 invalid signatures
Must FAIL on main today and PASS after the fix.
Reference
Same class as #235 (fixed in #236): the format cache is replayed only where the encoder produces the same shape the decoder read.
Summary
A transaction whose sets are plain or indefinite arrays, with no tag 258, comes back from
Transaction.fromCBORHexthenTransaction.toCBORHexwithd90102added and a definite length. Nothing else was changed. The body bytes change, so the transaction id changes and every witness already collected no longer verifies.Transaction.addVKeyWitnessesHexdoes the same, because it decodes and re-encodes rather than splicing.The ledger accepts both forms and hashes the bytes as received:
Decoder.hsL910-916: from protocol version 9, "Set tag 258 is permitted, but not enforced", with definite or indefinite length.decodeSetLikeEnforceNoDuplicatesL1053 andAnnotated.hsL238, L258 useallowTag setTagfor body and witness sets.Core.hsL651:txIdTxBody = TxId . hashAnnotated, over the original body bytes.Affected
packages/evolution/src/TransactionBody.ts
CBOR.Tag258 for keys 0, 4, 13, 14, 18, 20 (L227, L249, L275, L284, L301, L312)packages/evolution/src/TransactionWitnessSet.ts
packages/evolution/src/CBOR.ts
encodeTagSync(L1545) uses a captured format only when it is a tag node. The captured node for an untagged set is an array, so the whole subtree is encoded with no format, and nested choices such as an input index written as1800are lost too.Fix
Let the captured format decide whether the tag is written. When the format at a tag-258 position is an array node, encode the inner array with that node and omit the tag. With no captured format, keep writing the tag, so freshly built transactions do not change. This covers both uses of the format tree:
Regression test
Oracle is the node; CML agrees on every case below.
84 a3 00 81 [input] 01 81 [output] 02 [fee] a0 f5 f6, and the same with00 9f [input] fftoCBORHex(fromCBORHex(hex))addsd90102; the body hash differs from blake2b-256 of the original bodyaddVKeyWitnessesHexleaves the body bytes unchangeda1 00 81 ...) and untagged certificates, required signers, collateral, reference inputsDevnet, raw submission through Ogmios:
addVKeyWitnessesHex: rejected, 3100 invalid signaturesMust FAIL on main today and PASS after the fix.
Reference
Same class as #235 (fixed in #236): the format cache is replayed only where the encoder produces the same shape the decoder read.