Project Developer Guide
These are the established conventions and practices for this codebase.
1. Library Source is Local — Look It Up
All PureScript dependencies are installed with full source code in .spago/p/. When uncertain about a function's behavior, signature, or available instances — look it up.
.spago/p/prelude-7.0.0/src/... .spago/p/transformers-6.1.0/src/... .spago/p/foldable-traversable-7.0.0/src/...
Use whatever tool fits the situation:
- •repomix to understand a whole library's structure
- •grep to find a specific function or type
- •read when you know exactly where to look
Do not guess at function signatures or behaviors. Do not rely on potentially stale knowledge. The source is right there.
2. Property-Based Testing Only
All testing uses spec, quickcheck, and spec-quickcheck. We do not write one-off unit tests or example-based tests.
Tests assert properties over randomly generated inputs. This is appropriate for the domain — cryptographic primitives and circuit correctness are about invariants holding universally, not about specific examples passing.
Consult .spago/p/ for these libraries when needed:
- •
spec— test organization and assertions - •
quickcheck— property-based testing,Arbitraryinstances - •
spec-quickcheck— integration between the two
3. Circuit Testing via snarky-test-utils
For testing arithmetic circuits, use packages/snarky-test-utils. Do not invent new testing infrastructure.
The core pattern is CircuitSpec:
type CircuitSpec f c r m a avar b =
{ builtState :: CircuitBuilderState c r
, solver :: SolverT f c m a b
, checker :: Checker f c
, testFunction :: a -> Expectation b -- pure reference function
, postCondition :: PostCondition f c r
}
The test framework:
- •Runs the circuit solver to produce output
- •Runs the pure
testFunctionto get expected output - •Verifies all constraints are satisfied
- •Compares: circuit output must equal pure function output
The utilities in snarky-test-utils are used throughout the codebase. If you need a testing pattern, it's almost certainly already there.
4. FFI: The Three-Layer Pattern
Cryptographic primitives come from Rust via packages/crypto-provider (the snarky-crypto node module). The FFI follows a three-layer architecture:
Layer 1: Rust (via napi-rs)
- •Exposes minimal surface from arkworks / o1labs/proof-systems
- •Functions take simple arguments (primitives, arrays) — not complex structs
- •Sharing Rust objects across FFI is painful, so we avoid it
Layer 2: JavaScript Wrapper
- •Each PureScript module with
foreign importhas a corresponding.jsfile - •Imports from
snarky-crypto - •Handles translation: restructures flat arrays into records, applies currying, shapes data for PureScript
- •Absorbs awkwardness so both Rust and PureScript have clean interfaces
Parse at the FFI Boundary, Not in PureScript
When FFI returns flat/unstructured data (like [x0, y0, x1, y1, ...]), transform it in the JS layer into properly structured records that match domain semantics.
Why:
- •PureScript only sees clean, typed structures —
Vector 16 (LrPair f)instead ofArray f - •Types are self-documenting —
{ l :: AffinePoint f, r :: AffinePoint f }immediately conveys meaning - •Parsing happens once at the boundary — not scattered throughout PureScript code
- •No unsafe array indexing in PureScript — the JS layer handles flat-to-structured conversion
Example: Rust returns lr pairs as flat coordinates [l0.x, l0.y, r0.x, r0.y, l1.x, ...]
// In .js file — parse into structured records
export const proofOpeningLr = (proof) => {
const flat = snarky.proofOpeningLrFlat(proof);
const pairs = [];
for (let i = 0; i < flat.length; i += 4) {
pairs.push({
l: { x: flat[i], y: flat[i + 1] },
r: { x: flat[i + 2], y: flat[i + 3] }
});
}
return pairs;
};
-- In .purs file — clean typed interface
foreign import proofOpeningLr :: Proof g f -> Array { l :: AffinePoint f, r :: AffinePoint f }
The JS layer acts as a marshalling layer between native representation and the PureScript type system.
Layer 3: PureScript
- •
foreign importdeclarations with proper types - •
foreign import datafor opaque Rust objects - •Type classes to abstract over variants (e.g., Pallas/Vesta curves)
- •Functional dependencies for type inference
Example: See packages/pickles/test/Test/Pickles/ProofFFI.{purs,js} for a comprehensive example of this pattern.
5. Circuit vs Pure Functions — Rust is Ground Truth
Many operations exist in two forms:
- •Circuit version — uses the snarky DSL, creates constraints
- •Pure version — regular computation, no constraints
Where pure functions come from
Pure functions are typically thin wrappers around Rust FFI, not PureScript implementations. The Rust code (especially o1labs/proof-systems) provides the ground truth.
Why? This is cryptography. Complex arithmetic sequences must match protocols exactly. Since we're defining circuits that compute these protocols, we need "one foot in truth" — the pure reference function must come from a known-correct implementation.
Where pure functions live
- •In library code when needed at circuit boundaries (constructing inputs/outputs) or reused across packages
- •In tests when only needed for testing a specific circuit
Testing enforces correctness
The snarky-test-utils pattern ensures circuits match their pure counterparts:
Circuit output == Pure function output (backed by Rust)
If these disagree, the test fails. This is how we know circuits are correct.
6. The Advice Pattern — Witness Data via the m Parameter
The Snarky c t m monad has three type parameters:
- •
c— constraint type (e.g.,KimchiConstraint f) - •
t— state type for circuit building - •
m— the advice monad for providing witness data
What is advice?
Circuits sometimes need data that can't be computed from circuit variables alone. For example:
- •Looking up an account address from a public key
- •Factoring a number
- •Fetching a Merkle path from a tree
This data must be "conjured up" by the prover during witness generation. The m parameter is how we abstract over this.
The pattern
1. Define a typeclass for your advice:
-- Simple example from snarky-test-utils/src/Test/Snarky/Circuit/Factors.purs
class Monad m <= FactorM f m where
factor :: F f -> m { a :: F f, b :: F f }
2. Use it in your circuit via exists:
factorsCircuit :: forall t m f c. FactorM f m => CircuitM f c t m => FVar f -> Snarky c t m Unit
factorsCircuit n = do
{ a, b } <- exists do
nVal <- read n -- read the circuit variable's concrete value
lift $ factor @f nVal -- call into the advice monad
-- Now a and b are circuit variables (FVar f)
-- The prover provided their values, the circuit constrains them
c1 <- equals_ n =<< mul_ a b
assert_ c1
3. Provide instances for different phases:
-- For proving: actually compute the witness
instance PrimeField f => FactorM f Gen where
factor n = do
a <- arbitrary `suchThat` \a -> a /= one && a /= n
pure { a, b: n / a }
-- For compilation: crash (should never be called)
instance FactorM f Effect where
factor _ = throw "unhandled request: Factor"
Two monads, two phases
| Phase | Monad | Advice behavior |
|---|---|---|
| Compile | Effect or Identity-based | Crashes — advice should not be requested during compilation |
| Prove | Gen, ReaderT Ref Effect, etc. | Provides actual witness data |
Testing with advice
Use circuitSpec' (not circuitSpecPure') when your circuit needs advice:
circuitSpec' 100 randomSampleOne -- randomSampleOne :: Gen ~> Effect
{ builtState: s
, checker: eval
, solver: solver
, testFunction: satisfied_
, postCondition: postCondition
}
gen
The second argument is a natural transformation m ~> Effect that runs the advice monad.
Full example
See packages/example/ for a complete worked example:
- •
src/Snarky/Example/Circuits.purs— circuits usingAccountMapMandMerkleRequestM - •
test/Test/Snarky/Example/Monad.purs—TransferRefM(proving) andTransferCompileM(compilation) instances - •
test/Test/Snarky/Example/Circuits.purs— testing withcircuitSpec'
When to use advice
Use advice when your circuit needs witness data that:
- •Comes from external state (databases, trees, maps)
- •Requires non-trivial computation (factoring, searching)
- •Depends on prover-only knowledge
Do not use advice for values that can be computed purely from circuit inputs — just compute them directly.
7. Prefer Statically Sized Types
In the context of circuits, proofs, and cryptographic verification, data is almost always statically sized. Use PureScript's type-level sized containers instead of dynamic collections.
The types
| Dynamic (avoid) | Static (prefer) | Package |
|---|---|---|
Array a | Vector n a | sized-vector |
BigInt (unconstrained) | SizedF n f | snarky |
Why this matters
- •Circuit data is fixed-size — IPA rounds, witness columns, polynomial evaluations all have known sizes at compile time
- •Type safety catches bugs — mismatched vector lengths are compile errors, not runtime surprises
- •Zero runtime cost — PureScript newtypes compile away;
Vector n ais justArray aat runtime
FFI boundary
Even when the underlying JavaScript/Rust type is dynamically sized (e.g., Array), use static types on the PureScript side:
-- The JS returns an Array, but we know it's always 15 elements foreign import proofWitnessEvals :: Proof g f -> Vector 15 (PointEval f) -- The challenges are always IPA_ROUNDS long (e.g., 16 for SRS size 2^16) foreign import proofBulletproofChallenges :: ... -> Array f -- Convert to Vector d f
The JS wrapper can return a plain array; the PureScript foreign import declares the static type. If sizes don't match, you'll get a runtime error — which is the correct behavior (it indicates a bug in the Rust/JS layer or a misunderstanding of the protocol).
When to determine sizes
Before writing code, investigate:
- •What is this data? (e.g., "bulletproof challenges")
- •What determines its size? (e.g., "IPA rounds = SRS log2 size")
- •Is it fixed for a given circuit/proof? (almost always yes)
Then use the appropriate type-level natural.
Example: Converting at boundaries
-- FFI returns Array (dynamic)
challengesArray :: Array f
challengesArray = proofBulletproofChallenges proverIndex { proof, publicInput }
-- Convert to Vector (static) — fails if length doesn't match
challenges :: Vector 16 f
challenges = unsafePartial $ fromJust $ Vector.toVector challengesArray
The unsafePartial is acceptable here because a length mismatch indicates a protocol violation, not normal program flow.
Summary
| Principle | Practice |
|---|---|
| Uncertain about a library function? | Look it up in .spago/p/ |
| Writing tests? | Property-based with quickcheck, use snarky-test-utils for circuits |
| Need crypto primitives? | FFI from crypto-provider, follow three-layer pattern |
| Implementing a circuit? | Test against pure function; pure function wraps Rust |
| Reimplementing crypto in PureScript? | Don't. Wrap the Rust. |
| Circuit needs external witness data? | Use the advice pattern with exists and a custom typeclass |
| Data has known size? | Use Vector n a or SizedF n f, not Array |