Skip to content

Cryptography

Proof System: Groth16 on BLS12-381

The ZK proof system is Groth16 targeting the BLS12-381 curve. Plutus V3 provides native BLS12-381 pairing builtins, making on-chain verification efficient (~25% of per-transaction CPU budget).

graph LR
    subgraph "Off-chain (Phone)"
        CIRCUIT[Circom Circuit] --> WITNESS[Witness]
        WITNESS --> PROVER[Groth16 Prover]
        PROVER --> PROOF[Proof: 3 curve points]
    end
    subgraph "On-chain (Validator)"
        PROOF --> VERIFY[Groth16 Verifier]
        VK[Verification Key] --> VERIFY
        PUB[Public Inputs] --> VERIFY
        VERIFY --> OK[Valid / Invalid]
    end

Circuit Public Inputs

Input Type Binds
d integer Spend amount (customer's choice)
commit_S_old field element Old counter commitment
commit_S_new field element New counter commitment
user_id field element Poseidon(user_secret)
issuer_Ax, issuer_Ay field elements Issuer's EdDSA public key (shop that signed the cap)
pk_c_hi, pk_c_lo field elements Customer's Ed25519 public key, split across two field elements (pass-through; bound by proof so the validator can cross-check the redeemer-supplied customer_pubkey)

The acceptor's public key is not a circuit public input. Its binding to the spend is achieved off-chain by the customer's Ed25519 signature over the redeemer's signed_data, verified on-chain via Plutus's VerifyEd25519Signature builtin.

Circuit Private Inputs

Input Type Known to
S_old integer User only
S_new integer User only (= S_old + d)
cap integer User + issuer
r_old, r_new field elements User only (commitment randomness)
user_secret field element User only
sig_R8x, sig_R8y, sig_S field elements User only (EdDSA signature components)

Circuit Constraints

graph TD
    A["user_id == Poseidon(user_secret)"] --> VALID
    B["EdDSA.verify(issuer_pk, sig, Poseidon(user_id, cap))"] --> VALID
    C["S_new == S_old + d"] --> VALID
    D["S_new ≤ cap"] --> VALID
    E["commit_S_old == Poseidon(S_old, r_old)"] --> VALID
    F["commit_S_new == Poseidon(S_new, r_new)"] --> VALID
    VALID[All constraints satisfied]

    style VALID fill:#354,stroke:#698

Signature Scheme: EdDSA-Poseidon on Jubjub

Certificates are signed using EdDSA with Poseidon hash on the Jubjub curve (twisted Edwards curve over the BLS12-381 scalar field).

Parameter Value
Curve Jubjub (a=-1, d=192570...)
Field BLS12-381 scalar field
Hash Poseidon (field-native, ~250 constraints per hash)
Subgroup order 65544... (~254 bits)
Cofactor 8
In-circuit cost ~7000 constraints per signature verification

Why EdDSA-Poseidon inside the circuit?

The issuer's signature on the cap certificate must be verified inside the ZK proof because cap is private. If the signature were verified outside, the verifier would need to see cap, breaking privacy.

graph LR
    subgraph "Inside ZK Circuit"
        SIG["EdDSA.verify(issuer_pk, sig, Poseidon(user_id, cap))"]
        RANGE["S_new ≤ cap"]
    end
    subgraph "Outside ZK Circuit"
        PUB["issuer_pk is public input"]
        TRIE["validator checks issuer_pk in trie"]
    end

    SIG -->|cap stays hidden| RANGE
    PUB -->|issuer_pk visible| TRIE

Why not verify signatures outside?

Data Inside circuit Outside circuit
Cap Hidden (private input) Would be revealed
Issuer pk Passed through as public input Checked by validator
Signature Verified in circuit Would need cap visible

Customer Signature: Ed25519 (Outside the Circuit)

Per-transaction binding of the spending data to a specific Cardano tx is handled outside the ZK proof by an Ed25519 signature the customer produces on their phone and includes in the Aiken redeemer. The validator verifies it with Plutus's VerifyEd25519Signature builtin.

Field Purpose
sk_c, pk_c Customer's Ed25519 signing keypair, held on the phone alongside user_secret
signed_data Canonical byte layout: txid‖ix‖acceptor_Ax‖acceptor_Ay‖d (106 bytes)
customer_signature Ed25519 signature of signed_data under sk_c

Why Ed25519 outside instead of Poseidon inside?

  • Avoids implementing Poseidon on-chain (no Aiken-native Poseidon-BLS12-381 library exists; cost is unknown).
  • VerifyEd25519Signature is a Plutus builtin — cheap, vetted.
  • pk_c is still bound by the Groth16 proof as a pass-through public input (pk_c_hi, pk_c_lo), so the reificator cannot substitute a different customer key after the fact.
  • Replay, amount, and acceptor binding are all collapsed into one signature instead of needing a nonce circuit input + on-chain Poseidon check.

Binding summary

Binding Mechanism
d Public input + circuit constraint S_new = S_old + d
acceptor_pk Ed25519 signature over signed_data
TxOutRef / replay protection Ed25519 signature + validator checks TxOutRef consumed in this tx
user_id Public input + circuit proves user_id = Poseidon(user_secret)
pk_c Public input (pass-through) + cross-checked against redeemer's customer_pubkey

Commitment Scheme: Poseidon Hash

commit(v, r) = Poseidon(v, r) — a hash-based commitment.

Property Guaranteed
Binding Cannot find different (v', r') with same hash
Hiding Cannot determine v from commit(v, r) without r
Homomorphic No — counter update proven inside circuit, not algebraically

Poseidon is chosen because it is field-native: pure arithmetic over the BLS12-381 scalar field. No curve operations, no bit decomposition. ~250 constraints per hash, compared to ~25,000 for SHA-256 in a circuit.

Trusted Setup

Groth16 requires a circuit-specific trusted setup (powers of tau ceremony + phase 2). One setup per circuit variant. The verification key is a parameter of the on-chain validator — one VK for the entire coalition.

Multi-certificate spend (N certificates) requires a different circuit with a separate trusted setup. The coalition chooses N at setup time.