Repository navigation
Conversation
Decoding normalises choices that are part of the encoding, so re-encoding a value produces different bytes and therefore a different hash. #503 fixed this for arrays; the same loss still applied to indefinite-length strings and maps, and the array fix had gaps of its own. Indefinite strings and maps now survive a round trip. The scanner already recorded that a container was indefinite and the apply step discarded it, so that flag is carried through to four new types. Chunk boundaries of an indefinite string are part of the encoding, so their lengths are recorded and replayed rather than renormalised. This is what the ledger needs: Plutus byte strings longer than 64 bytes arrive chunked, and such a datum previously re-encoded to a different hash (fixes #507). Also fixes three defects in the array path: - Duplicate map keys collapse on decode, leaving the shape walk misaligned with the decoded dict and silently producing wrong bytes. Such payloads are now rejected, and the opt-in path raises rather than guessing. - Trailing bytes and a non-COSE message body were guarded by bare asserts, which vanish under python -O. They now raise DeserializeException. The COSE guard could never fire as written, since verify() prepends the tag byte itself; it now checks the message body, as pycose did before #503. - A tag consumed by a semantic decoder left its content immutable, so the self-describe tag decoded to a tuple instead of a list. Type guards keep decoders that return a non-container (Fraction, Decimal, bignum, datetime) untouched. Scalar shapes are now shared rather than allocated per decoded item, which offsets most of the cost of the added work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pipx install poetry` resolved to whatever was newest when a job ran, so an unchanged commit could pass one day and fail the next. 2.4.2 was published two days before #502 and broke the integration tests that way. It also matters for the lock file. Poetry stamps poetry.lock with a content hash, and versions differ in how they compute it, so a mismatched Poetry rejects the lock and refuses to sync the environment. Pinning to the generator recorded in the lock file's first line keeps the two in agreement; the version is declared once per workflow so there is a single place to bump it. publish.yml is pinned too, since it runs `poetry install` and `poetry check` before pushing to PyPI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #508 +/- ##
==========================================
+ Coverage 90.79% 90.96% +0.17%
==========================================
Files 37 37
Lines 5422 5537 +115
Branches 825 843 +18
==========================================
+ Hits 4923 5037 +114
Misses 309 309
- Partials 190 191 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The existing #402 fixture only holds byte strings short enough to fit one definite-length chunk, so it passed before this branch. Real DEX and escrow datums carry longer values, which the ledger requires to be chunked, and those still lost their chunk boundaries on decode: the body hash moved and a signature computed over the original body stopped verifying. The new test exercises that path end to end — sign the body hash as a wallet would before export, decode, then verify the same signature against the re-encoded body. It fails on 877be3a at the body hash assertion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nielstron
left a comment
There was a problem hiding this comment.
I think we can clean this up a bit by making all indefinite-classes inherit from one shared indefinite superclass. it should at least simplify the is instance call, maybe also the shared write/read logic for bytes and strings.
| """Opting back into duplicate keys must not silently mis-apply array shapes.""" | ||
| encoded = bytes.fromhex("a2018102019f02ff") | ||
|
|
||
| with pytest.raises(DeserializeException, match="Duplicate keys"): |
There was a problem hiding this comment.
this test looks weird to me should we not expect that allow duplicate keys returns an object with the right keys? or can we currently not handle that?
There was a problem hiding this comment.
Good catch, that test was asserting the wrong thing. Changed in 4698edc.
We genuinely cannot restore the representation of a duplicate-key map: the decoded dict holds one entry per distinct key, so the scanned shapes no longer line up with the values they came from, and re-encoding can't reproduce the input bytes no matter what we do. But you're right that the keys themselves are fine, and raising at a caller who explicitly passed allow_duplicate_keys=True was unhelpful — they asked for lenient decoding and got an exception telling them to pass False.
So the opt-in path now hands back the decoded map and the test asserts that:
decoded = loads(bytes.fromhex("a2018102019f02ff"), allow_duplicate_keys=True)
assert decoded == {1: [2]}
assert dumps(decoded) == bytes.fromhex("a1018102")The surviving value keeps no array shape, so the bytes differ from the input — that's the documented cost of opting in, and the reason the default still rejects duplicates. I moved that explanation onto loads' docstring.
Two changes from @nielstron's review. An `Indefinite` marker is now the common base of every indefinite type, and `IndefiniteString` holds the chunk bookkeeping that the byte and text variants share. `default_encoder` dispatches on those two bases instead of listing six concrete classes, its assertion lists one entry rather than four, and the two chunk-writing branches collapse into one shared path that the long-`ByteString` fallback reuses as well. Each concrete class is still registered with cbor2 individually, since it looks encoders up by exact type; that list is now a module-level tuple so the registration loop has a single source. Decoding with `allow_duplicate_keys=True` no longer raises. The caller asked for lenient decoding, so the decoded map is returned with its keys intact. What cannot be recovered is the representation: a map holds one entry per distinct key, so the scanned shapes can no longer be matched to the values they came from, and re-encoding will not reproduce the input bytes. That is the reason the default still rejects duplicates, and it is now stated on `loads` itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMubCsu1qpLrc6iMt8eq9z
|
Thanks — both addressed in 4698edc. Shared superclass.
The one thing that can't key off the base is the cbor2 registration, since it looks encoders up by exact type, so each concrete class is still claimed individually. That list is now a module-level Duplicate keys. Replied on the thread — short version is you were right, the opt-in path now returns the decoded map with correct keys instead of raising, and the limitation is documented on 698 passed, 5 xfailed; |
Follow-up to #503. Fixes #507, and closes the remaining case in #402.
Summary
#503 moved PyCardano onto cbor2 6 and restored definite vs indefinite arrays by re-scanning the raw payload after decoding. Two things were left:
Indefinite strings and maps
The scanner already recorded that a container was indefinite; the apply step discarded it. That flag is now carried through to four new types (
IndefiniteByteString,IndefiniteTextString,IndefiniteDict,IndefiniteFrozenDict). Each subclasses its builtin, soisinstance, equality and hashing are unchanged and only an added attribute carries the chunk lengths.Chunk boundaries are part of the encoding, so the scanner records each chunk length and the encoder replays exactly those rather than renormalising to 64. cbor2 encodes all four natively as their definite base types, which is why each is claimed explicitly in the encoder registration — the same reason indefinite lists already needed that treatment.
This is what the ledger needs. A Plutus byte string longer than 64 bytes arrives chunked, and such a datum previously came back with a different hash:
That is #507:
TransactionBuilderdeep-copies its outputs andRawPlutusData.__deepcopy__goes through CBOR, so the builder emitted datums the node rejected withByteString exceeds 64 bytes.It is also the remaining half of #402. The Liqwid fixture added in #493 only holds byte strings short enough for one definite-length chunk, so it passed before this branch — but a datum with a longer value still moved the body hash on decode, which is exactly the "cannot recover the signature from unsigned_cbor" symptom reported there:
Three defects in the array path
allow_duplicate_keys=False), and the opt-in path raisesDeserializeExceptionrather than guessing — guessing means a wrong transaction hash.python -O. Both now raiseDeserializeException. The COSE guard also could never fire as written, sinceverify()prepends the tag byte itself; it now validates the message body, as pycose's own decoder did before Support cbor2 6 #503.tupleinstead of alist. Type guards keep decoders that return a non-container (Fraction,Decimal, bignum,datetime) untouched.Performance
Scalar shapes are shared rather than allocated per decoded item, which offsets most of the added work. A real 3.6 KB transaction decodes in 0.050 ms against 0.037 ms on the old cbor2pure path. The pathological 190 KB synthetic payload went from 19.4 ms to 15.3 ms.
Poetry pin (second commit)
pipx install poetryresolved to whatever was newest when a job ran, so an unchanged commit could pass one day and fail the next — 2.4.2 was published two days before #502 and broke the integration tests that way. It also matters for the lock file: Poetry stampspoetry.lockwith a content hash and versions differ in how they compute it, so a mismatched Poetry rejects the lock and refuses to sync the environment. Pinned to the generator recorded in the lock file's first line, declared once per workflow.publish.ymltoo, since it runspoetry installandpoetry checkbefore pushing to PyPI.Happy to split this into its own PR if you'd rather keep the two separate.
Testing
make qaclean (flake8, mypy, black)Notes
Two pre-existing issues this does not address, both unchanged from before #503:
Valuewith an empty multi-asset map to a bare coin, the other reshapes the inputs field.RawPlutusDatabuilt directly from raw longbytes(rather than viafrom_dictor CBOR) still emits a definite byte string, since there is no chunking to preserve. RawPlutusData loses >64-byte bytestring chunking on deepcopy, so TransactionBuilder emits ledger-invalid datums #507's own suggested fix targets this case; worth a follow-up, and note that the snippet as written in the issue does not work on its own —_dfsnever recurses into a decodedCBORTag, because itsisinstance(obj.value, list)guard is false forIndefiniteList.🤖 Generated with Claude Code