Explore more

Project
Market Heatmap: A Terminal-Style Dashboard Without the Frameworks
Welcome to mariociavarella.it v2.0.0
System ready.
~/site $ ./preview.sh
Project
mariociavarella.it: Building a Portfolio from Scratch
Article
Verifiable Intelligence: a Technical Framework for Auditable AI Governance

Building a Blockchain Prototype: What Whitepapers Don't Tell You

v0.9.0 Jun 2025 9 min

95 6 0

Python ZK Proofs Cryptography Blockchain

Why Build This

CFP is a research prototype developed to understand distributed systems and blockchain protocols by actually building them. The goal was not architectural innovation, but practical experience with components that whitepapers often gloss over: sequencers, ledgers, zero-knowledge proofs and auction mechanisms.

Development focused on solving problems that theoretical documentation tends to simplify. Finality in the DAG wasn’t just about the graph structure — it required defining ordering algorithms and transaction finality criteria. ZK implementation with Circom wasn’t about the underlying cryptography — it was about translating application logic into efficient arithmetic constraints. And auction mechanisms turned out to be far more complex than simple commitment schemes: commit-reveal systems require careful management of tie-break grinding attacks and timing vulnerabilities (sniping).


The DAG Sequencer

The sequencer maintains the DAG structure and handles vertex validation, tip management and linearization. A vertex is the atomic unit of the system:

@dataclass
class Vertex:
    vertex_id: bytes        # Hash of contents
    timestamp: int          # For ordering ties
    parents: List[bytes]    # References to 1+ parent vertices
    payload: bytes          # Transaction or intent data
    payload_type: PayloadType
    signature: bytes

New vertices reference existing ones. The graph grows, never shrinks. Independent transactions can exist in parallel:

        ┌─→ V2 ─┐
    V1 ─┤       ├─→ V4
        └─→ V3 ─┘

The tricky part isn’t the structure — it’s linearization. For state execution, you need a single sequence. I use Kahn’s algorithm with lexicographic tie-breaking:

def linearize(self) -> List[Vertex]:
    in_degree = {v: len(self.get_parents(v)) for v in self.vertices}
    ready = sorted([v for v, d in in_degree.items() if d == 0], 
                   key=lambda v: v.vertex_id)
    order = []
    
    while ready:
        vertex = ready.pop(0)
        order.append(vertex)
        for child in self.get_children(vertex):
            in_degree[child] -= 1
            if in_degree[child] == 0:
                ready.append(child)
        ready.sort(key=lambda v: v.vertex_id)
    
    return order

Every node running this on the same graph produces the identical order. Getting this right took several iterations.


UTXO: A Different Mental Model

I chose UTXO over accounts because each “coin” is independent. Spending one doesn’t affect another, which makes parallel execution natural. The model also maps cleanly to zero-knowledge proofs since state can be represented as a Merkle tree of commitments.

@dataclass
class UTXO:
    tx_hash: bytes
    output_index: int
    value: int
    owner: bytes          # Public key hash
    salt: bytes           # For commitment hiding
    
    def commitment(self) -> int:
        """[Poseidon hash](https://www.poseidon-hash.info/) for Merkle tree storage."""
        return poseidon_hash([self.value, 
                              bytes_to_field(self.owner), 
                              bytes_to_field(self.salt)])

Double-spend detection becomes trivial: you just track which commitments have been “nullified”. The nullifier is a hash that marks a UTXO as spent without revealing which one:

def compute_nullifier(self, owner_secret: bytes) -> int:
    return poseidon_hash([self.commitment(), 
                          bytes_to_field(owner_secret)])

If the same nullifier appears twice, the transaction is a double-spend attempt. This approach avoids global balance reconciliation and eliminates many race conditions inherent in account-based systems.


Auctions and Economic Incentives

This is where I spent the most time. My first auction design was broken within minutes of thinking about it. Open bids? Competitors just outbid you. So you hide them with commitments:

def create_commitment(intent_id: int, solver_id: int, 
                      score: int, solution_hash: int, salt: int) -> int:
    return poseidon_hash([
        DOMAIN_SOLVER_COMMIT,
        intent_id,
        solver_id,
        score,
        solution_hash,
        salt
    ])

Solvers submit commitments during the commit phase, then reveal the actual values later. If the reveal doesn’t match, it’s rejected. But identical bids need a tie-breaker. And if that tie-breaker is predictable, attackers grind through identities until they find a favorable hash. The fix uses an external random seed that’s fixed after all commitments:

def compute_tie_break(epoch_seed: int, intent_id: int, solver_id: int) -> int:
    """Ungrindable tie-break — epoch_seed fixed after commits."""
    return poseidon_hash([epoch_seed, intent_id, solver_id])

def select_winner(solutions: List[Solution], epoch_seed: int) -> Solution:
    def score_key(s):
        return (-s.utility, compute_tie_break(epoch_seed, s.intent_id, s.solver_id))
    return min(solutions, key=score_key)

Each fix revealed a new attack. That’s how I learned why real systems have registration costs, stake requirements and slashing. Solvers register by locking stake — without economic penalties, nothing prevents a solver from winning an auction and then disappearing:

class SolverRegistry:
    def slash(self, solver_id: int, amount: int, reason: str) -> bool:
        solver = self.solvers.get(solver_id)
        if not solver or solver.stake_available < amount:
            return False
        
        solver.stake_available -= amount
        solver.slash_count += 1
        self.slashed_total += amount
        
        log.warning(f"Slashed {solver_id}: {amount} for {reason}")
        return True

Failed execution? 50% of bond. Invalid solution? 100%. The specific numbers are less important than the structure: you don’t need trust if cheating costs more than it gains.


ZK Circuits

Circom circuits express the verification logic that will be proven by the ZK prover. The auction selection circuit handles pairwise comparison to find the winning bid:

template AuctionSelect(K) {
    signal input utility[K];
    signal input tie_break[K];
    signal output winner_index;
    
    // Find argmax(utility, -tie_break)
    component comparisons[K-1];
    signal running_best[K];
    signal running_index[K];
    
    running_best[0] <== utility[0];
    running_index[0] <== 0;
    
    for (var i = 1; i < K; i++) {
        comparisons[i-1] = GreaterThan(64);
        comparisons[i-1].in[0] <== utility[i];
        comparisons[i-1].in[1] <== running_best[i-1];
        // ... update running best
    }
    
    winner_index <== running_index[K-1];
}

The cryptography is abstracted away. The struggle is in witness generation — marshalling your Python data into the exact format the circuit expects. That’s where most debugging time goes. The prover orchestration shells out to snarkjs:

def generate_proof(self, witness_data: dict) -> ProofResult:
    # Write witness
    with open(self.input_path, 'w') as f:
        json.dump(witness_data, f)
    
    # Generate witness
    subprocess.run([
        "node", self.witness_gen_path,
        self.wasm_path, self.input_path, self.wtns_path
    ])
    
    # Prove
    subprocess.run([
        "snarkjs", "[groth16](https://eprint.iacr.org/2016/260)", "prove",
        self.zkey_path, self.wtns_path,
        self.proof_path, self.public_path
    ])
    
    return self._load_proof()

The integration between Python orchestration and the Circom/snarkjs toolchain required careful attention to data formats and error handling.


Scope and Takeaways

CFP is a research prototype. The trusted setup is single-party, the networking has no DoS protection and proof generation takes seconds. It works, but it’s designed for understanding, not production.

# Run the demo
cfp demo --scenario intent

# Run tests
pytest tests/ -v --cov=cfp

The data structures are the easy part. The hard part is the rules — when is something final? What happens on failure? Read the attack, not just the defense. Every design choice exists because the alternative was exploitable.

Implementation is where understanding happens. The protocol isn’t novel. But I can now look at a new L2 or intent system and ask specific questions about their design, because I’ve made those choices myself and seen the consequences.

This project note was last updated on June 02, 2025 and refers to project version v0.9.0.

Comments (0)

0 / 2000 characters

No comments yet. Be the first to share your thoughts!

Request Access

This repository is private. Please request access below.

By submitting this form, you agree that I will use your details to reply to your request. See Privacy Policy.

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.