The Serialization Needle: Tracing a PoRep Bug Through Go's any Type and Missing Unmarshalers

Introduction

In the complex world of Filecoin proving infrastructure, a bug that manifests as "PSProve PoRep fails via cuzk but works via FFI" can hide in the most unexpected places. Message 1658 in this coding session captures a pivotal moment in a deep investigation: the assistant, after systematically ruling out enum mapping mismatches, struct field differences, and data flow discrepancies, finally spots a potential serialization vulnerability lurking in Go's type system. This message is a turning point — not because it finds the definitive root cause, but because it shifts the investigation's focus from structural parity to the subtle, byte-level behavior of JSON marshaling and unmarshaling across language boundaries.

The Investigation So Far

To understand the significance of message 1658, one must appreciate the ground already covered. The assistant had spent multiple rounds tracing RegisteredSealProof enum mappings across Go, C, and Rust, confirming they were all identical. It had compared every field of Rust's SealCommitPhase1Output against Go's Commit1OutRaw, finding no missing fields. It had analyzed the full data flow — from C1 generation through proofshare upload to provider fetch — and confirmed that both the working FFI path and the failing cuzk path used structurally identical data. The only remaining difference was that the working path used raw bytes from ffi.SealCommitPhase1() (Rust-serialized JSON), while the failing path used json.Marshal(Commit1OutRaw{...}) (Go-re-serialized JSON).

This narrowed the problem to a single question: does the Go JSON round-trip produce byte-identical JSON to the original Rust output? If not, the cuzk Rust service might deserialize the Go-produced JSON differently, leading to different public inputs and a failed verification.

The Critical Observation

Message 1658 begins with the assistant reading two files: porep_vproof_types.go and merkle.go. The latter contains a seemingly innocuous type alias that catches the assistant's attention:

type HasherDomain = any

The assistant immediately recognizes the implications:

This means all the generic types like Column[H HasherDomain], LabelingProof[H HasherDomain], etc. use any as the type parameter. When Go's json.Unmarshal fills a struct field of type any, it uses the default types — JSON arrays become []interface{} (with float64 elements), not [32]byte or PoseidonDomain.

This is the kind of observation that only comes from deep familiarity with Go's JSON serialization quirks. The any type (an alias for interface{}) is Go's escape hatch for generic programming, but it has a well-known interaction with encoding/json: when unmarshaling into an interface{} field, Go has no type information to guide the deserialization, so it uses default representations. A JSON array of integers like [1, 2, 3, ...] becomes []interface{} where each element is float64 — not [32]byte or PoseidonDomain or any typed byte array.

The Serialization Round-Trip Hypothesis

The assistant then walks through a specific example. The VanillaStackedProof struct uses concrete types:

CommDProofs    MerkleProof[Sha256Domain]
CommRLastProof MerkleProof[PoseidonDomain]

Here, Go's json.Unmarshal should know the target type is Sha256Domain or PoseidonDomain because the struct field is typed concretely. But these types are defined as [32]byte with custom MarshalJSON methods and no corresponding UnmarshalJSON:

type Sha256Domain [32]byte

func (s Sha256Domain) MarshalJSON() ([]byte, error) {
    if testMarshal {
        return json.Marshal(s[:])
    }
    return json.Marshal([32]byte(s))
}

type PoseidonDomain [32]byte // Fr

func (p PoseidonDomain) MarshalJSON() ([]byte, error) {
    // ...
}

The assistant asks the crucial question: "Let me check if Go's default unmarshal into [32]byte from a JSON integer array works, and then if MarshalJSON on [32]byte produces the same format."

This is a subtle but critical point. Go's encoding/json package has specific behavior for [N]byte types: when unmarshaling a JSON array of integers, it will attempt to fill each byte from the corresponding integer. But the custom MarshalJSON methods mean that serialization uses a custom path, while deserialization uses the default path. This asymmetry can produce a round-trip that is not byte-identical — especially if the custom marshaler produces a different JSON format than the default unmarshaler expects.

The assistant initially speculates that the round-trip "should be lossless" because MarshalJSON on PoseidonDomain produces json.Marshal([32]byte(p)) which is an integer array, and the default unmarshal of an integer array into [32]byte should work. But this reasoning is incomplete — it doesn't account for the testMarshal flag that switches between s[:] (a slice) and [32]byte(s) (an array), which produce different JSON formats (JSON array vs. possibly different representation).

The Pivot to Rust Verification

After this analysis, the assistant makes a strategic decision: instead of continuing to theorize about Go serialization, it pivots to examine the Rust side of cuzk. The message ends with reading prover.rs to check the prove_porep_c2 function:

Let me look at this from a completely different angle. Let me check what the cuzk Rust side does after generating the SNARK — specifically, does it verify internally?

This pivot is significant. The assistant recognizes that theorizing about Go serialization can only go so far without understanding what the Rust side actually does with the JSON. If cuzk verifies the SNARK internally before returning it, then the verification failure might be happening inside cuzk itself — which would mean the Rust deserialization of the Go-produced JSON is the issue. If cuzk does not verify internally, the failure might be on the Go side when calling ffi.VerifySeal.

The prove_porep_c2 function signature reveals the key parameters:

pub fn prove_porep_c2(
    vanilla_proof_json: &[u8],
    sector_number: u64,
    miner_id: u64,
    job_id: &str,
) -> Result<(Vec<u8>, ProofTimings)>

The function takes vanilla_proof_json as raw bytes — meaning the JSON is parsed inside this function. The sector_number and miner_id are passed separately, which means they are not extracted from the JSON. This is important because it means the public inputs for the SNARK (sector number and miner ID) come from the gRPC request metadata, not from the JSON payload. If the JSON payload differs in a way that affects the SNARK proof itself (not the public inputs), the verification would still fail.

Assumptions and Reasoning

The assistant makes several assumptions in this message:

  1. The round-trip should be lossless: The assistant initially assumes that Go's default unmarshal of a JSON integer array into [32]byte combined with the custom MarshalJSON producing an integer array should be lossless. This assumption is reasonable but not guaranteed — it depends on the exact behavior of Go's JSON decoder for [N]byte types and whether the custom marshaler produces the exact same format the decoder expects.
  2. The testMarshal flag is not relevant: The assistant doesn't investigate the testMarshal flag in merkle.go. This flag switches between json.Marshal(s[:]) (a slice) and json.Marshal([32]byte(s)) (an array). These produce different JSON — a slice marshals as a JSON array, but [32]byte might marshal differently depending on Go version. If testMarshal is ever set to true in production, the serialization format would change.
  3. The Rust side might verify internally: The assistant hypothesizes that cuzk might verify the SNARK before returning it, which would explain the failure. This is a reasonable guess but needs confirmation.
  4. The HasherDomain = any issue might not affect concrete structs: The assistant correctly notes that VanillaStackedProof uses concrete types for its fields, so the any issue might not apply at the top level. But intermediate generic types like Column[H HasherDomain] with Rows []H could still be affected if they appear in the JSON.

Input Knowledge Required

To fully understand this message, one needs:

Output Knowledge Created

This message creates several important insights:

  1. A specific serialization vulnerability is identified: The HasherDomain = any alias combined with missing UnmarshalJSON methods on Sha256Domain and PoseidonDomain creates a potential round-trip instability.
  2. A new investigative direction is opened: Instead of continuing to compare struct fields and enum values, the investigation now needs to examine the actual byte-level behavior of the JSON round-trip — potentially by adding diagnostic logging to capture the exact bytes.
  3. The Rust verification path needs examination: Understanding whether cuzk verifies internally will determine where the fix needs to be applied.
  4. The testMarshal flag is a potential red flag: Even if it's only used for testing, the existence of this flag suggests the developers were aware of serialization format differences and were experimenting with alternatives.

The Thinking Process

The assistant's reasoning in this message follows a clear pattern:

  1. Observation: Notices the HasherDomain = any type alias and immediately connects it to Go's JSON unmarshaling behavior for interface{} fields.
  2. Hypothesis formation: Considers whether the round-trip through Go's JSON marshal/unmarshal could be lossless, given the custom marshalers and the concrete type parameters in VanillaStackedProof.
  3. Self-correction: Initially thinks the round-trip "should be lossless" but then hedges — "Wait — there's a deeper issue" — and re-examines the Column struct with its any-typed _h field.
  4. Strategic pivot: Decides to change approach from theorizing about Go serialization to examining the Rust side's verification behavior. This is a good investigative instinct — when stuck on a hypothesis, look at the problem from the other end.
  5. Action: Reads prover.rs to check prove_porep_c2, looking for internal verification logic. The message is a model of systematic debugging: it doesn't jump to conclusions, it acknowledges uncertainty ("should work" but "let me check"), and it knows when to pivot to a different angle of attack.

Conclusion

Message 1658 is a classic debugging pivot point. After days of tracing enum mappings, comparing struct fields, and analyzing data flows, the assistant finally identifies a concrete serialization vulnerability — the HasherDomain = any alias and the missing UnmarshalJSON methods. But rather than committing to this hypothesis prematurely, the assistant wisely pivots to examine the Rust verification path, recognizing that understanding the full picture requires both sides of the serialization boundary. This message captures the moment when a deep investigation transitions from structural analysis to behavioral analysis — from "what fields exist" to "what bytes are produced." It's a reminder that in distributed systems with multiple languages, the most insidious bugs often hide not in what data is exchanged, but in how it is serialized and deserialized across language boundaries.