Engineering || Debugging a Ghost: Four Sessions to Fix a PlutusV3 Mint
How we hunted down a MalformedScriptWitnesses error in EchoCert's Cardano PlutusV3 minting flow — four debug sessions, one wrong hypothesis, and a single one-line fix that unlocked everything.
The certificate was minted. The QR code was printed. The wallet signed. And then — rejection.
Not a "user did something wrong" rejection. A cryptic, ledger-level rejection: ConwayUtxowFailure (MalformedScriptWitnesses).
This is the story of how we fixed it.
The Error
The Cardano node said our Plutus script was "malformed." Specifically:
MalformedScriptWitnesses [ScriptHash "b725d504..."]
The error message helpfully explained: Plutus scripts must be CBOR-encoded flat-encoded bytecode. Yes, there's a double binary encoding. The outer-most layer is a plain CBOR bytestring.
That hint — "double binary encoding" — turned out to be the entire bug. It took us four sessions to fully understand it.
What Is a Plutus Script, Exactly?
A PlutusV3 script as it appears in a Cardano transaction witness set is not just raw bytecode. It has three encoding layers:
- —flat_bytes (722 bytes): the raw UPLC (Untyped Plutus Lambda Calculus) program, starting with bytes 010100 (PlutusCore version 1.1.0)
- —singleCBOR (725 bytes): a CBOR bytestring wrapping the flat bytes — header 5902d2 followed by the flat content
- —doubleCBOR (728 bytes): a CBOR bytestring wrapping the singleCBOR — header 5902d5 followed by singleCBOR
The Cardano ledger (written in Haskell) does this when it processes a script:
1. FromCBOR BinaryPlutus: calls CBOR.decodeBytes — strips the outer bytestring layer. If the wire had doubleCBOR, this yields singleCBOR.
2. deserialiseScript(singleCBOR): calls decodeViaFlatWith, which calls CBOR.decodeBytes again — strips the inner bytestring layer, yielding 722 flat bytes, then flat-decodes them.
If the wire has singleCBOR instead of doubleCBOR, step 1 strips it to flat bytes. Step 2 then tries CBOR.decodeBytes(flat bytes) — sees byte 0x01 (a CBOR unsigned integer, not a bytestring) — and fails. That failure is MalformedScriptWitnesses.
The Wrong Fix We Applied First
In an earlier session, we encountered a different error: missingRequiredScripts from Ogmios evaluateTx. Our fix was to call normalizePlutusScript(applied, "SingleCBOR") — a MeshSDK utility that strips the outer CBOR layer, reducing doubleCBOR to singleCBOR.
This made Ogmios happy. But it silently introduced the wire encoding bug. We had fixed one error by creating a worse one.
The lesson: never fix two things at once. We couldn't tell which fix caused which outcome.
The Diagnostic Method That Found It
Before touching any code, we added five diagnostic log lines to mint.js that printed before every transaction submission:
- —The full signed transaction CBOR hex
- —script_data_hash from the tx body
- —The first 4 bytes of the redeemer (to confirm Conway MAP format)
- —The first 6 bytes of the PlutusV3 script as it appears in the witness set
- —The live cost model parameter count (to verify our cost model patch was working)
One line told us everything:
[diag:ws] key-7 script[0] toCbor first 12: 5902d2010100
The wire script started with 5902d2 — the singleCBOR header. It should have started with 5902d5 (doubleCBOR). The script was one layer short.
The One-Line Fix
Remove normalizePlutusScript. Pass appliedDouble directly.
Broken: normalizePlutusScript(appliedDouble, "SingleCBOR") strips one layer. singleCBOR enters wire. Ledger strips it -> flat bytes. CBOR.decodeBytes(flat) fails.
Fixed: appliedDouble enters wire. Ledger strips one layer -> singleCBOR (SerialisedScript). decodeViaFlatWith strips one more -> flat bytes -> flat-decodes successfully.
That's it. Remove the normalization call. Pass doubleCBOR directly to mintingScript().
Why This Also Fixes the Policy ID
A Cardano policy ID is blake2b_224(0x03 || SerialisedScript), where SerialisedScript is what the ledger holds after stripping the outer CBOR layer from the wire.
- —Wire = doubleCBOR → SerialisedScript = singleCBOR → policyId = blake2b_224(0x03 || singleCBOR) = 4df6ed7f...
- —Wire = singleCBOR → SerialisedScript = flat bytes → policyId = blake2b_224(0x03 || flat) = b725d504...
We had been using b725d504... as the policy ID — the hash that matched when our script was wrongly encoded. Once we fixed the encoding to doubleCBOR, the correct policy ID is 4df6ed7f... This is actually the same value the Aiken blueprint had computed all along.
What We Also Fixed
While diagnosing the mint flow, we found two additional bugs:
The QR code generated after a successful mint pointed to /lookup instead of /cert/<hash>. Recipients scanning the QR code were taken to a search page, not the certificate. One line in qrcode.js was ignoring its parameter entirely.
The Dashboard (EchoDash) was failing to load a user's issued certificates after connecting a wallet. The root cause: NEXT_PUBLIC_API_URL was missing from the .env file, so all API calls to the Express backend (port 8001) were silently hitting the Next.js server (port 3000) instead — and getting 404s.
Methodology Notes
Four things made this debugging process tractable:
Collect data before forming hypotheses. We added the diagnostic log block before touching a single line of production code.
One fix, one test. The normalizePlutusScript call and the policy ID were both wrong — but we fixed them separately and confirmed each independently.
Read the source. We read the actual Haskell source of decodeViaFlatWith in plutus-core to understand exactly what encoding the ledger expected. No amount of trial-and-error would have been faster.
Preserve the diagnostic block until the fix is confirmed. We left the logging in place for the first successful test run, then removed it.
Published May 17, 2026 · EchoForge · ENGINEERING Series