Agent skill
project-developer-guide
Development conventions and practices for this codebase. Use when writing PureScript code, tests, or FFI in this project.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/project-developer-guide
SKILL.md
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 assertionsquickcheck— property-based testing,Arbitraryinstancesspec-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 typesforeign 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 buildingm— 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 usingAccountMapMandMerkleRequestMtest/Test/Snarky/Example/Monad.purs—TransferRefM(proving) andTransferCompileM(compilation) instancestest/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 |
Didn't find tool you were looking for?