drop python cl spec

This commit is contained in:
David Rusu 2024-06-14 11:56:38 -04:00
parent 5ce7b253cf
commit 7ba69caccb
24 changed files with 0 additions and 939 deletions

View File

@ -1,25 +0,0 @@
# Coordination Layer
This module provides the executable specifications of the Coordination Layer (CL).
## Setup
We are currently experimenting with the Noir Language. In order to run the specification, you will need to install Noir.
Follow the instructions here:
https://noir-lang.org/docs/getting_started/installation/
Verify the installation by running
```bash
noirup
```
## Tests
From the repository root run:
```bash
python -m unittest discover -v coordination-layer
```

View File

@ -1,20 +0,0 @@
"""
This module holds the logic for building and verifying homomorphic balance commitments.
"""
from constraints import Constraint
from crypto import Field, Point, prf, hash_to_curve, pederson_commit, _str_to_vec
def balance_commitment(value: Field, blinding: Field, unit: Point):
return pederson_commit(value, blinding, unit)
def fungibility_domain(unit: str, birth_cm: Field) -> Point:
"""The fungibility domain of this note"""
return hash_to_curve("CL_NOTE_NULL", birth_cm, *_str_to_vec(unit))
def blinding(tx_rand: Field, nf_pk: Field) -> Field:
"""Blinding factor used in balance commitments"""
return prf("CL_NOTE_BAL_BLIND", tx_rand, nf_pk)

View File

@ -1,3 +0,0 @@
from .constraint import Constraint, Proof
from .vacuous import Vacuous

View File

@ -1,21 +0,0 @@
from dataclasses import dataclass
from .noir_constraint import NoirConstraint, NoirProof
@dataclass
class Bigger:
"""
The statement "I know an `x` that is bigger than `y`".
- `y` is a public parameter provided when the constraint is initialized
- `x` is a secret parameter provided at proving time
"""
y: int
_noir = NoirConstraint("bigger")
def prove(self, x: int) -> NoirProof:
return self._noir.prove({"x": str(x), "y": str(self.y)})
def verify(self, proof: NoirProof):
return self._noir.verify({"y": str(self.y)}, proof)

View File

@ -1,17 +0,0 @@
"""
This module defines the Constraint interface.
Constraints are the predicates that must be satisfied in order to destroy or create a note.
The logic of a constraint is implemented in a ZK Circuit, and then wrapped in a python interface
for interacting with the rest of the the system.
"""
class Proof:
pass
class Constraint:
def hash(self) -> bytes:
raise NotImplementedError()

View File

@ -1,105 +0,0 @@
"""
This module provides the interface for loading, proving and verifying constraints written in noir.
The assumptions of this module:
- noir constraints are defined as a noir package in `./noir/crates/<constraint>/`
- ./noir is relative to this file
For ergonomics, one should provide python wrappers that understands the API of
the corresponding constraint.
"""
from dataclasses import dataclass
from pathlib import Path
import sh
import portalocker
import tempfile
import toml
from constraints import Proof
NOIR_DIR = Path(__file__).resolve().parent.parent / "noir"
LOCK_FILE = NOIR_DIR / ".CL.lock"
CONSTRAINTS_DIR = NOIR_DIR / "crates"
NARGO = sh.Command("nargo")
@dataclass
class NoirProof(Proof):
proof: str
class NoirConstraint:
"""
Provides a wrapper around the `nargo` command for interacting with a constraint
written in Noir.
E.g. NoirConstraint("bigger") corresponds to the noir circuit defined
in "./noir/crates/bigger/".
Calling API methods on this object will map to shelling out to `nargo` to execute
the relevant `nargo` action.
Efforts are taken to make this wrapper thread safe.To prevent any race
conditions we limit one action at any one time across the `./noir` directory.
"""
def __init__(self, name: str):
self.name = name
assert self.noir_package_dir.exists() and self.noir_package_dir.is_dir()
self._prepare()
@property
def noir_package_dir(self):
return CONSTRAINTS_DIR / self.name
def prove(self, params: dict) -> NoirProof:
"""
Attempts to prove the noir constraint with the given paramaters.
1. Write the paramaters to the noir Prover.toml file
2. execute `nargo prove`
3. Retreive the proof written by nargo.
Returns the NoirProof containing the proof of the statment.
"""
with portalocker.TemporaryFileLock(LOCK_FILE):
with open(self.noir_package_dir / "Prover.toml", "w") as prover_f:
toml.dump(params, prover_f)
prove_res = self._nargo("prove", _return_cmd=True)
assert prove_res.exit_code == 0
with open(NOIR_DIR / "proofs" / f"{self.name}.proof", "r") as proof:
return NoirProof(proof.read())
def verify(self, params: dict, proof: NoirProof):
"""
Attempts to verify a proof given the public paramaters.
1. Write the public paramaters to the Verifier.toml
2. Write the proof to the location nargo expects it
3. Execute `nargo verify`
4. Check the process exit code.
"""
with portalocker.TemporaryFileLock(LOCK_FILE):
with open(self.noir_package_dir / "Verifier.toml", "w") as verifier_f:
toml.dump(params, verifier_f)
with open(NOIR_DIR / "proofs" / f"{self.name}.proof", "w") as proof_file:
proof_file.write(proof.proof)
verify_res = self._nargo("verify", _ok_code=[0, 1], _return_cmd=True)
return verify_res.exit_code == 0
def _nargo(self, *args, **kwargs):
return NARGO(*args, **kwargs, _cwd=self.noir_package_dir)
def _prepare(self):
"""
Verify that the Noir circuit is well defined.
"""
check = self._nargo("check", _return_cmd=True)
assert check.exit_code == 0
compile = self._nargo("compile", _return_cmd=True)
assert compile.exit_code == 0

View File

@ -1,14 +0,0 @@
from unittest import TestCase
from .bigger import Bigger
class TestBigger(TestCase):
def test_bigger(self):
bigger = Bigger(3)
proof = bigger.prove(5)
bigger.verify(proof)
# If we try to reuse the proof for a different Bigger instance, it fails
bigger_4 = Bigger(4)
assert not bigger_4.verify(proof)

View File

@ -1,20 +0,0 @@
from unittest import TestCase
from .noir_constraint import NoirConstraint
class TestNoirCoinstraint(TestCase):
def test_bigger(self):
# simple constraint that proves we know a number bigger than the provided
# public input.
bigger = NoirConstraint("bigger")
# x is the secret input, y is the public input
proof = bigger.prove({"x": "5", "y": "3"})
# The proof that we know an `x` that is bigger than `y` should verify
# Note, we must provide the public input that was used in the proof.
assert bigger.verify({"y": "3"}, proof)
# If we change the public input, the proof fails to verify.
assert not bigger.verify({"y": "4"}, proof)

View File

@ -1,18 +0,0 @@
from constraints import Constraint, Proof
from crypto import Field
class Vacuous(Constraint):
"""
This is the empty constraint, it return true for any proof
"""
def hash(self):
# chosen by a fair 2**64 sided die.
return Field(14500592324922987342)
def prove(self) -> Proof:
return Proof()
def verify(self, _proof: Proof):
return True

View File

@ -1,108 +0,0 @@
from py_ecc import bn128
from keum import grumpkin
import poseidon
# !Important! The crypto primitives here must be in agreement with the proving system
# E.g. if you are using noir with the Barretenberg, we must use the Grumpkin curve.
# Point = grumpkin.AffineWeierstrass
# Field = grumpkin.Fq
Point = bn128.FQ
Field = bn128.Point2D[FQ]
def fake_algebraic_hash(data) -> Field:
"""
HACK: we'll fake the algebraic hash using sha256(data) % Field.ORDER
"""
assert all(isinstance(d, Field) for d in data), f"{data}\n{[type(d) for d in data]}"
data = b"".join(d.v.to_bytes(256 // 8) for d in data)
from hashlib import sha256
return Field(int(sha256(data).hexdigest(), 16))
def build_poseidon():
h = poseidon.Poseidon(
p=Field.ORDER,
security_level=128,
alpha=5,
input_rate=3,
t=9,
)
# TODO: this is hacks on hacks to make poseidon take in arbitrary input length.
# Fix is to implement a sponge as described in section 2.1 of
# https://eprint.iacr.org/2019/458.pdf
def inner(data):
digest = 0
for i in range(0, len(data), h.input_rate - 1):
digest = h.run_hash([digest, *data[i : i + h.input_rate - 1]])
return Field(int(digest))
return inner
# HASH = build_poseidon()
HASH = fake_algebraic_hash
def prf(domain, *elements) -> Field:
return HASH([*_str_to_vec(domain), *elements])
def hash_to_curve(domain, *elements) -> Point:
# HACK: we don't currently have a proper hash_to_curve implementation
# so we hack the Point.random() function.
#
# Point.random() calls into the global `random` module to generate a
# point. We will seed the random module with the result of hashing the
# elements and then call Point.random() to retreive the point
# corresponding to the mentioned elements.
r = prf(f"HASH_TO_CURVE_{domain}", *elements)
import random
random.seed(r.v)
return Point.random()
def comm(*elements):
"""
Returns a commitment to the sequence of elements.
The commitmtent can be opened at index 0..len(elements)
"""
raise NotImplementedError()
def pederson_commit(value: Field, blinding: Field, domain: Point) -> Point:
return Point.generator().mul(value) + domain.mul(blinding)
def merkle_root(data) -> Field:
data = _pad_to_power_of_2(data)
nodes = [CRH(d) for d in data]
while len(nodes) > 1:
nodes = [CRH(nodes[i], nodes[i + 1]) for i in range(0, len(nodes), 2)]
return nodes[0]
def _pad_to_power_of_2(data):
import math
max_lower_bound = int(math.log2(len(data)))
if 2**max_lower_bound == len(data):
return data
to_pad = 2 ** (max_lower_bound + 1) - len(data)
return data + [Field.zero()] * to_pad
def _str_to_vec(s):
return [Field(ord(c)) for c in s]

View File

@ -1,4 +0,0 @@
crates/*/Prover.toml
crates/*/Verifier.toml
proofs/*.proof
target/*

View File

@ -1,2 +0,0 @@
[workspace]
members = ["crates/bigger"]

View File

@ -1,18 +0,0 @@
# Noir Circuits
this directory holds all the circuits written in Noir, used by the CL specification.
Each circuit is it's own nargo package under the `crates/` directory.
## Creating a new circuit
1. inside `crates/`, run `nargo new <circuit name>`
2. update `./Nargo.toml` to include the new circuit in the workspace
## Testing circuits
Under `./noir`, simple run.
```
nargo test
```

View File

@ -1,7 +0,0 @@
[package]
name = "bigger"
type = "bin"
authors = [""]
compiler_version = ">=0.22.0"
[dependencies]

View File

@ -1,19 +0,0 @@
fn main(x: u32, y: pub u32) {
assert(x > y);
}
#[test]
fn test_bigger() {
main(3, 2);
}
#[test(should_fail)]
fn test_equal() {
main(2, 2);
}
#[test(should_fail)]
fn test_smaller() {
main(1, 2);
}

View File

@ -1,151 +0,0 @@
from dataclasses import dataclass
from crypto import (
Field,
Point,
prf,
pederson_commit,
_str_to_vec,
merkle_root,
hash_to_curve,
)
from constraints import Constraint, Proof
def nf_pk(nf_sk) -> Field:
return prf("CL_NOTE_NF", nf_sk)
def balance_commitment(value: Field, tx_rand: Field, funge: Point):
return pederson_commit(value, tx_rand, funge)
@dataclass(unsafe_hash=True)
class InnerNote:
value: Field
unit: str
# TODO: inner notes should hold commitments to constraints.
# Constraints themselves will be stored in a key-value store
birth_constraint: Constraint
death_constraints: list[Constraint]
state: Field
nonce: Field
rand: Field # source of randomness for note commitment
def __post_init__(self):
if isinstance(self.value, int):
self.value = Field(self.value)
assert isinstance(self.value, Field), f"value is {type(self.value)}"
assert isinstance(self.unit, str), f"unit is {type(self.unit)}"
assert isinstance(
self.birth_constraint, Constraint
), f"birth_constraint is {type(self.birth_constraint)}"
assert isinstance(
self.death_constraints, list
), f"death_constraints is {type(self.death_constraints)}"
assert all(
isinstance(d, Constraint) for d in self.death_constraints
), f"{[type(d) for d in self.death_constraints]}"
assert isinstance(self.state, Field), f"state is {type(self.state)}"
assert isinstance(self.nonce, Field), f"nonce is {type(self.nonce)}"
assert isinstance(self.rand, Field), f"rand is {type(self.rand)}"
def verify_death(self, death_cm: Field, death_proof: Proof) -> bool:
constraint = [d for d in self.death_constraints if d.hash() == death_cm]
if len(constraint) == 0:
# given commitment was not one of the allowed death constraints
return False
constraint = constraint[0]
# TODO: verifying the death constraint should include a commitment to the
# partial transaction so that the death constraint can make statements
# regarding the entire transaction.
return constraint.verify(death_proof)
def verify_birth(self, birth_proof: Proof) -> bool:
# TODO: Should verifying the birth constraint include a commitment
# to the partial transaction?
return self.birth_constraint.verify(birth_proof)
def verify_value(self) -> bool:
return 0 <= self.value and value <= 2**64
def r(self, index: int):
return prf("CL_NOTE_COMM_RAND", self.rand, index)
@property
def fungibility_domain(self) -> Point:
"""The fungibility domain of this note"""
return hash_to_curve(
"CL_NOTE_NULL", self.birth_constraint.hash(), *_str_to_vec(self.unit)
)
def death_constraints_root(self) -> Field:
"""
Returns the merkle root over the set of death constraints
"""
return merkle_root(self.death_constraints)
@dataclass(unsafe_hash=True)
class PublicNote:
note: InnerNote
nf_pk: Field
def blinding(self, tx_rand: Field) -> Field:
"""Blinding factor used in balance commitments"""
return prf("CL_NOTE_BAL_BLIND", tx_rand, self.note.nonce, self.nf_pk)
def balance(self, tx_rand):
"""
Returns the pederson commitment to the notes value.
"""
return balance_commitment(
self.note.value, self.blinding(tx_rand), self.note.fungibility_domain
)
def zero(self, tx_rand):
"""
Returns the pederson commitment to the notes value.
"""
return balance_commitment(
Field.zero(), self.blinding(tx_rand), self.note.fungibility_domain
)
def commit(self) -> Field:
# blinding factors between data elems ensure no information is leaked in merkle paths
return merkle_root(
self.note.r(0),
self.note.birth_constraint.hash(),
self.note.r(1),
self.note.death_constraints_root(),
self.note.r(2),
self.note.value,
self.note.r(3),
self.note.unit,
self.note.r(4),
self.note.state,
self.note.r(5),
self.note.nonce,
self.note.r(6),
self.nf_pk,
)
@dataclass(unsafe_hash=True)
class SecretNote:
note: InnerNote
nf_sk: Field
def to_public(self) -> PublicNote:
return PublicNote(note=self.note, nf_pk=nf_pk(self.nf_sk))
def nullifier(self):
"""
The nullifier that must be provided when spending this note along
with a proof that the nf_sk used to compute the nullifier corresponds
to the nf_pk in the public note commitment.
"""
return prf("NULLIFIER", self.nonce, self.nf_sk)

View File

@ -1,146 +0,0 @@
* Open Issues
** Do we need both Public, Secret and Inner Note?
Can't we simply have a Note (currently InnerNote) and the partial transaction will introduce
a wrapper over the Note struct that will have the extra fields necessary for using notes within
a transaction system:
#+begin_src python
# ----- note.py -----
@dataclass(unsafe_hash=True)
class Note:
value: Field
unit: str
birth_constraint: Constraint
death_constraints: list[Constraint]
state: Field
nonce: Field
rand: Field
def verify_death(self, death_cm: Field, death_proof: Proof) -> bool:
pass
def verify_birth(self, birth_proof: Proof) -> bool:
pass
def verify_value(self) -> bool:
pass
def fungibility_domain(self) -> Field:
pass
# ----- partial_transaction.py -----
@dataclass
class InputNote:
note: Note
nullifier: Note
death_cm: Field
death_proof: Proof
balance_cm: Point
@dataclass
class OutputNote:
note: PublicNote
birth_proof: Proof
balance_cm: Point
#+end_src
** provided commitment to zero may removing the blinding of the pederson commitment
Since you can subtract the randomness from the commitment to get just the binding part.
Ok, lets deal with these zero commitments.
In place of zero commitments, we can have the solver prove that the value part of a pederson
commitment is zero.
** Rename transaction randonmness to tx_rand and commitment randomness to cm_rand
Currently they are both called "rand" and it's confusing.
** Why does PartialTransaction have a "blinding"?
I believe it should only have a derived balance field.
** Poseidon Constants for Grumpkin Field
Generating the poseidon constants for the Grumpkin curve is very slow, like takes 1 minute.
I need to pre-generate these to make the curve work.
- I tried the other large curve where the params are already generated and it is still slow. Need to dig into this.
** Solvers need the value of a note in order to solve it
- do users provide a merkle proof showing the value is legit?
- merkle proofs also reveal positional information.
- if we are to reveal partial information to solvers
we will need to blind the other leaf nodes of the tree:
Note = VALUE || UNIT || ...
com = ..root..
/ \
h(h(V), h(U)) ...
/ \
h(VALUE) h(UNIT)
| |
VALUE UNIT
Revealing the value's merkle path would reveal h(UNIT) and since UNIT is well known, h(UNIT) is also easily computable.
Thus each component of the Note should have a blinding factor
Note = (r1, VALUE) || (r1, UNIT) || ....
** Transferring requires recipients sk
We need to be able to create a partial transaction where we don't know the output note's nullifier.
What we need: A public note + balance commitment
balance commitment is computed as
```
value <- known by transaction builder
unit <- known by transaction builder
blinding <- PRF_r(nullifier) !!!! <-- this is where the issue is
funge <- hash_to_curve(Unit)
balance_commit <- pedersen_commit(value, blinding, funge)
```
Why is the blinding a PRF of the nullifier? Can't we simply use the randomness of the transaction to derive a blinding factor?
*** How does zcash solve this?
They provide specific randomness for this when building the transaction.
** Note Unit should be a string hashed to curve point when needed.
We want human readable units (i.e. "ETH" "NMO")
I've gone and denoted it as bytes.
* Solved
** Note.verify() should take a proof
We should have two variants, verifyDeath(deathProof) verifyBirth(birthProof)
** Do we need note.randomness?
It's currently used in the note commitment. But perhaps it's not really necessary?
- it allows you to get away form content based addressing, this may be necessary for total privacy.
Yes, we need this

View File

@ -1,62 +0,0 @@
from dataclasses import dataclass
from constraints import Proof
from note import PublicNote, SecretNote
from crypto import Field, Point
@dataclass
class InputNote:
note: SecretNote
death_cm: Field # commitment to the death constraint we are using
death_proof: Proof
def verify(self):
# TODO: note.note is ugly
return self.note.note.verify_death(self.death_cm, self.death_proof)
@dataclass
class OutputNote:
note: PublicNote
birth_proof: Proof
def verify(self):
# TODO: note.note is ugly
return self.note.note.verify_birth(self.birth_proof)
@dataclass(unsafe_hash=True)
class PartialTransaction:
inputs: list[InputNote]
outputs: list[OutputNote]
rand: Field
def verify(self) -> bool:
valid_inputs = all(i.verify() for i in self.inputs)
valid_outputs = all(o.verify() for o in self.outputs)
return valid_inputs and valid_outputs
def balance(self) -> Point:
output_balance = sum(
(n.note.balance(self.rand) for n in self.outputs),
start=Point.zero(),
)
input_balance = sum(
(n.note.to_public().balance(self.rand) for n in self.inputs),
start=Point.zero(),
)
return output_balance + input_balance.negate()
def zero(self) -> Field:
output_zero = sum(
(n.note.zero(self.rand) for n in self.outputs),
start=Point.zero(),
)
input_zero = sum(
(n.note.to_public().zero(self.rand) for n in self.inputs),
start=Point.zero(),
)
return output_zero + input_zero.negate()

View File

@ -1,23 +0,0 @@
"""
This module maintains the state of the CL.
Namely we are interested in:
- the set of note commitments
- the set of note nullifiers (spent notes)
- the set of constraints
"""
from dataclasses import dataclass
import note
import constraint
@dataclass
class State:
# commitments: set[note.Commitment]
# nullifiers: set[note.Nullifier]
# constraints: dict[bytes, constraint.Constraint]
def add_constraint(self, c: constraint.Constraint):
self.constraints[c.hash()] = c

View File

@ -1,30 +0,0 @@
from unittest import TestCase
from hypothesis import example, given, settings, strategies as st
from crypto import Field, hash_to_curve
from balance_commitment import balance_commitment
@st.composite
def field(draw):
x = draw(st.integers(min_value=0, max_value=Field.ORDER - 1))
return Field(x)
@st.composite
def point(draw):
x = draw(field())
return hash_to_curve("T", x)
class TestBalanceCommitment(TestCase):
@given(r=field(), a=field(), b=field(), unit=point())
@settings(max_examples=3)
def test_value_additive(self, r, a, b, unit):
print(r, a, b, unit)
b1 = balance_commitment(r, a, unit)
b2 = balance_commitment(r, b, unit)
b3 = balance_commitment(r, a + b, unit)
assert b1 + b2 == b3

View File

@ -1,33 +0,0 @@
"""
This module tests that all the hacks we introduced in our crypto mocks give us
the basic behaviour that we need.
"""
from unittest import TestCase
from crypto import Field, Point, hash_to_curve, prf
class TestCrypto(TestCase):
def test_hash_to_curve(self):
p1 = hash_to_curve("TEST", Field(0), Field(1), Field(2))
p2 = hash_to_curve("TEST", Field(0), Field(1), Field(2))
assert isinstance(p1, Point)
assert p1 == p2
p3 = hash_to_curve("TEST", Field(0), Field(1), Field(3))
assert p1 != p3
def test_prf(self):
r1 = prf("TEST", Field(0), Field(1), Field(2))
r2 = prf("TEST", Field(0), Field(1), Field(2))
assert isinstance(r1, Field)
assert r1 == r2
r3 = prf("TEST", Field(0), Field(1), Field(3))
assert r1 != r3

View File

@ -1,73 +0,0 @@
from unittest import TestCase
from dataclasses import dataclass
from crypto import Field, prf
from note import InnerNote, PublicNote, SecretNote, nf_pk
from partial_transaction import PartialTransaction, InputNote, OutputNote
from transaction_bundle import TransactionBundle
from constraints import Vacuous
class TestTransfer(TestCase):
def test_1_to_1_transfer(self):
# Alice wants to transfer ownership of a note to Bob.
@dataclass
class User:
sk: Field
@property
def pk(self) -> Field:
return nf_pk(self.sk)
alice = User(sk=Field.random())
bob = User(sk=Field.random())
alices_note = SecretNote(
note=InnerNote(
value=100,
unit="NMO",
birth_constraint=Vacuous(),
death_constraints=[Vacuous()],
state=Field.zero(),
nonce=Field.random(),
rand=Field.random(),
),
nf_sk=alice.sk,
)
tx_rand = Field.random()
bobs_note = PublicNote(
note=InnerNote(
value=100,
unit="NMO",
birth_constraint=Vacuous(),
death_constraints=[Vacuous()],
state=Field.zero(),
nonce=Field.random(),
rand=Field.random(),
),
nf_pk=bob.pk,
)
ptx = PartialTransaction(
inputs=[
InputNote(
note=alices_note,
death_cm=Vacuous().hash(),
death_proof=Vacuous().prove(),
)
],
outputs=[
OutputNote(
note=bobs_note,
birth_proof=Vacuous().prove(),
)
],
rand=tx_rand,
)
bundle = TransactionBundle(bundle=[ptx])
assert bundle.verify()

View File

@ -1,20 +0,0 @@
from dataclasses import dataclass
from partial_transaction import PartialTransaction
from crypto import Field, Point
@dataclass
class TransactionBundle:
bundle: list[PartialTransaction]
def is_balanced(self) -> bool:
# TODO: move this to a NOIR constraint
balance_commitment = sum(
(ptx.balance() + ptx.zero().negate() for ptx in self.bundle),
start=Point.zero(),
)
return Point.zero() == balance_commitment
def verify(self) -> bool:
return self.is_balanced() and all(ptx.verify() for ptx in self.bundle)