The Raw Bytes Tell the Truth: Tracing a Cross-Language Serialization Bug in Filecoin's PSProve Pipeline

Introduction

In distributed systems that span multiple programming languages, the most insidious bugs often live at the boundaries — where data crosses from one runtime to another, from one serialization format to another, from one team's assumptions to another's. Message 1619 of this opencode session captures a single, seemingly mundane act: reading a Go function signature. But that read was the culmination of a deep investigative journey into a production bug that had brought down the PoRep (Proof of Replication) proving pipeline in the Filecoin Curio project. The error message was deceptively simple — "porep failed to validate" — but the root cause was hiding in the silent transformation of data across a JSON round-trip between Rust and Go.

The Subject Message

The message at index 1619 is a single tool call:

[assistant] [read] /tmp/czk/extern/filecoin-ffi/proofs.go
<path>/tmp/czk/extern/filecoin-ffi/proofs.go</path>
<type>file</type>
<content>350: func SealCommitPhase1(
351:     proofType abi.RegisteredSealProof,
352:     sealedCID cid.Cid,
353:     unsealedCID cid.Cid,
354:     cacheDirPath string,
355:     sealedSectorPath string,
356:     sectorNum abi.SectorNumber,
357:     minerID abi.ActorID,
358:     ticket abi.SealRandomness,
359:     seed abi.InteractiveSealRandomness,
360:     pieces []abi.PieceInfo,
361: ) (phase1Output []byte, err error) {
362:     sp, err := toFilRegisteredSe...

On its surface, this is just the assistant reading a function definition — the Go wrapper for the FFI call to Rust's seal_commit_phase1. The function takes proof parameters and returns phase1Output []byte — a raw byte slice. But in the context of the investigation unfolding across the preceding 18 messages, this single line of code was the missing piece of a puzzle.

The Context: A Production Bug That Defied Easy Diagnosis

The story begins at message 1601, where the user reports a baffling production failure: PSProve tasks — the mechanism by which ProofShare providers generate SNARK proofs for market-issued challenges — were failing for PoRep challenges with the error "porep failed to validate." The same tasks worked perfectly for Snap proofs. The same PoRep proofs worked fine in the normal (non-PSProve) proving pipeline. The failure was specific to the intersection of PSProve and PoRep — a narrow corridor of failure that immediately suggested a plumbing issue rather than a fundamental cryptographic problem.

The user's initial report was remarkably thorough, including a detailed analysis of how PoRep challenges are derived, confirming that the challenge generation algorithm was identical between the normal path and the PSProve path. This ruled out the most obvious suspect: that the challenges themselves were somehow different. The user even noted an unused Go re-implementation of the challenge derivation with a latent endianness bug, but confirmed it was never called. The bug was real, and it was somewhere in the data pipeline.

The Investigation: Tracing Two Code Paths

The assistant's investigation, spanning messages 1602 through 1621, was a masterclass in systematic debugging across language boundaries. The approach was to trace both the working path (normal PoRep C2) and the failing path (PSProve PoRep) to find the exact point of divergence.

The normal PoRep C2 path, implemented in cuzk_funcs.go, was straightforward: it called ffi.SealCommitPhase1() which invoked the Rust proving code via CGO, received the output as raw JSON bytes, and passed those bytes directly to the CuZK proving engine through a wrapper function called wrapC1Output. The raw bytes from Rust were never deserialized into Go structs — they traveled untouched from the Rust heap to the CuZK gRPC request.

The PSProve PoRep path, implemented in task_prove.go, was different. The computePoRep function at line 264 received a *proof.Commit1OutRaw Go struct — a deserialized representation of the C1 output. It then re-marshaled this struct back to JSON at line 266 with json.Marshal(request) before passing it to CuZK. This meant the data had undergone a full round-trip: Rust JSON → Go struct (via json.Unmarshal) → Go JSON (via json.Marshal).

This structural difference was the smoking gun. But to confirm it, the assistant needed to verify that the normal path truly used raw bytes. That's what message 1619 was about.

The Significance of the Read

Reading the SealCommitPhase1 function signature confirmed the critical fact: the function returns []byte. Not a typed struct, not a pre-parsed object — just raw bytes. The comment in the Go wrapper at line 389-400 (visible in the subsequent message 1620) showed the implementation: it called the CGO function cgo.SealCommitPhase1(...) and returned the result directly. The Rust FFI produces JSON, and the Go wrapper passes it through without any deserialization.

This confirmed that the normal PoRep path never deserializes the C1 output. The raw JSON bytes from Rust go directly into the CuZK wrapper. The PSProve path, by contrast, deserializes them into Go structs on the client side (for upload to the market), then re-serializes them on the provider side. This round-trip is the fundamental difference.

The Round-Trip Problem: Where Data Gets Lost in Translation

With the structural difference confirmed, the assistant's investigation deepened into the specific serialization behaviors. The Go types used in Commit1OutRaw and its nested structures revealed a subtle and treacherous landscape.

The PoseidonDomain and Sha256Domain types (defined in merkle.go) are [32]byte with custom MarshalJSON methods that serialize as base64-encoded strings. But critically, they have no custom UnmarshalJSON methods (confirmed in messages 1615-1616 by grep searches that returned no results). The type alias HasherDomain = any further complicates matters: when Rust JSON containing arrays of integers (how serde_json serializes [u8; 32]) is deserialized into Go's any type, it becomes []interface{} containing float64 values. When re-serialized, it becomes a JSON array of numbers — which matches the Rust format. But the Commitment type (used for CommR, CommD, ReplicaID) is a concrete [32]byte without the any escape hatch. Rust's serde_json serializes [u8; 32] as a JSON array of integers, but Go's default JSON unmarshaler for [32]byte expects a base64 string, not an array.

This asymmetry means the round-trip is almost certainly lossy for some fields. The Rust JSON format and the Go re-serialized JSON format may differ in subtle but semantically significant ways — field ordering, whitespace, number representation (integers vs floats), and array vs base64 encoding for byte arrays. Any of these differences could cause the Rust CuZK engine's serde_json deserializer to produce a different SealCommitPhase1Output struct than the original, leading to a verification failure.

Assumptions and Knowledge Required

To understand this message and its significance, one needs substantial domain knowledge spanning multiple areas. First, familiarity with the Filecoin proving pipeline — the distinction between C1 (vanilla proof generation) and C2 (SNARK wrapping), the role of RegisteredProof types, and the challenge derivation mechanism. Second, deep understanding of cross-language FFI patterns: how Go calls Rust through CGO, how data is serialized at the boundary, and the implications of passing raw bytes vs typed structs. Third, knowledge of JSON serialization semantics in both Go and Rust — how serde_json handles [u8; 32] (as an array of integers) versus Go's encoding/json (which defaults to base64 for byte arrays). Fourth, familiarity with the Curio codebase structure, particularly the tasks/proofshare/, lib/proof/, and lib/ffi/ packages.

The assistant's key assumptions were validated by the investigation: that the normal path used raw bytes, that the PSProve path used a round-trip, and that the serialization behaviors of Go and Rust differ for the types involved. One assumption that was tested and partially ruled out was that the RegisteredProof enum mapping might differ between paths — the assistant checked whether the PSProve path used a different proof type string (e.g., StackedDrg32GiBV1_1 vs a synthetic or non-interactive variant). A subagent task (message 1608) confirmed the flow of proof data through the market, and another (message 1621) confirmed that Rust serializes Commitment as a JSON array of integers.

The Output Knowledge Created

This message, combined with the surrounding investigation, produced several critical pieces of knowledge. First, it definitively established the structural divergence between the two code paths: raw bytes vs round-tripped structs. Second, it identified the specific serialization asymmetry — custom MarshalJSON without corresponding UnmarshalJSON — as the likely root cause. Third, it narrowed the search space for the fix: either eliminate the round-trip by passing raw bytes through the PSProve path, or add custom UnmarshalJSON methods to ensure symmetric serialization, or modify the CuZK Rust deserializer to handle both formats.

The investigation also produced valuable negative knowledge: the RegisteredProof enum mapping was ruled out as the cause, the challenge derivation algorithm was confirmed identical, and the non-CuZK PSProve path was confirmed to work (meaning the round-trip is functionally correct for the non-CuZK SealCommitPhase2 call, which uses the Go FFI directly rather than the CuZK Rust engine).

The Thinking Process

What makes this investigation remarkable is the systematic, hypothesis-driven approach. The assistant didn't start by guessing at the cause — it traced both code paths in parallel, comparing them at every step. When the structural difference was found (raw bytes vs round-trip), it didn't stop there — it dug into the specific serialization behaviors of every type involved. When it found custom MarshalJSON without UnmarshalJSON, it didn't assume that was the problem — it checked whether the HasherDomain = any type alias might bypass the custom marshalers entirely. When it considered the RegisteredProof enum, it launched a subagent to trace the entire data flow from client upload to provider download.

The read at message 1619 sits at a pivot point in this investigation. Before it, the assistant had identified the round-trip as a potential issue but lacked confirmation that the normal path truly used raw bytes. After it, the investigation could proceed with confidence to the specific serialization mechanics. The message is a single data point, but it's the data point that made the entire theory coherent.

Conclusion

Message 1619 is a reminder that in complex systems, the most powerful debugging tool is often the simplest: reading the code. A function signature — func SealCommitPhase1(...) ([]byte, error) — contains worlds of information. It tells us that the Rust output is treated as opaque bytes in the normal path, that no Go-side deserialization occurs, and that any code path that does deserialize and re-serialize is doing something fundamentally different. In the hunt for a cross-language serialization bug, that single fact was the key that unlocked the entire investigation.