ENGINEERING2026-05-17· 4 min read

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:

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:

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.

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