diff --git a/coordination-layer/constraint.py b/coordination-layer/constraint.py deleted file mode 100644 index 76c8066..0000000 --- a/coordination-layer/constraint.py +++ /dev/null @@ -1,8 +0,0 @@ -from dataclasses import dataclass - - -@dataclass -class Constraint: - - def hash(self) -> bytes: - raise NotImplementedError() diff --git a/coordination-layer/constraints/__init__.py b/coordination-layer/constraints/__init__.py index e69de29..6397f98 100644 --- a/coordination-layer/constraints/__init__.py +++ b/coordination-layer/constraints/__init__.py @@ -0,0 +1,2 @@ +from .constraint import Constraint +from .vacuous import Vacuous diff --git a/coordination-layer/constraints/constraint.py b/coordination-layer/constraints/constraint.py new file mode 100644 index 0000000..17eb8ac --- /dev/null +++ b/coordination-layer/constraints/constraint.py @@ -0,0 +1,15 @@ +""" +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. +""" + +from dataclasses import dataclass + + +class Constraint: + def hash(self) -> bytes: + raise NotImplementedError() diff --git a/coordination-layer/constraints/vacuous.py b/coordination-layer/constraints/vacuous.py new file mode 100644 index 0000000..b41b471 --- /dev/null +++ b/coordination-layer/constraints/vacuous.py @@ -0,0 +1,15 @@ +from noir_constraint import NoirProof + +from constraints import Constraint + + +class Vacuous(Constraint): + """ + This is the empty constraint, it return true for any proof + """ + + def prove(self) -> NoirProof: + return NoirProof("vacuous") + + def verify(self, _proof: NoirProof): + return True diff --git a/coordination-layer/crypto.py b/coordination-layer/crypto.py index 2ae1daa..9e545fa 100644 --- a/coordination-layer/crypto.py +++ b/coordination-layer/crypto.py @@ -1,4 +1,4 @@ -from keum import grumpkin +from keum import grumpkin, PrimeFiniteField import poseidon @@ -9,6 +9,10 @@ Point = grumpkin.AffineWeierstrass Field = grumpkin.Fq +class Field(PrimeFiniteField): + ORDER = poseidon.prime_64 + + def poseidon_grumpkin_field(): # TODO: These parameters are made up. # return poseidon.Poseidon( @@ -19,11 +23,23 @@ def poseidon_grumpkin_field(): # t=9, # ) h, _ = poseidon.case_simple() + # h, _ = poseidon.case_neptune() + # h = poseidon.Poseidon( + # p=Field.ORDER, + # security_level=128, + # alpha=5, + # input_rate=3, + # t=9, + # ) - # TODO: this is a hack to make poseidon take in arbitrary input length. + # 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): + assert all( + isinstance(d, Field) for d in data + ), f"{data}\n{[type(d) for d in data]}" + data = [d.v for d in 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]]) @@ -36,7 +52,7 @@ POSEIDON = poseidon_grumpkin_field() def prf(domain, *elements): - return POSEIDON(str_to_vec(domain) + elements) + return POSEIDON([*_str_to_vec(domain), *elements]) def comm(*elements): @@ -48,6 +64,10 @@ def comm(*elements): raise NotImplementedError() +def pederson_commit(value: Field, blinding: Field, domain: Point) -> Point: + return value * Point.generator() + blinding * domain + + def merkle_root(data) -> Field: data = _pad_to_power_of_2(data) nodes = [CRH(d) for d in data] @@ -64,8 +84,8 @@ def _pad_to_power_of_2(data): if 2**max_lower_bound == len(data): return data to_pad = 2 ** (max_lower_bound + 1) - len(data) - return data + [0] * to_pad + return data + [Field.zero()] * to_pad def _str_to_vec(s): - return list(map(ord, s)) + return [Field(ord(c)) for c in s] diff --git a/coordination-layer/note.py b/coordination-layer/note.py index 9e43ed5..5214b34 100644 --- a/coordination-layer/note.py +++ b/coordination-layer/note.py @@ -1,11 +1,15 @@ from dataclasses import dataclass -from crypto import Field, Point +from crypto import Field, Point, prf + +from constraints import Constraint @dataclass -class Commitment: - cm: bytes +class NoteCommitment: + cm: Field + blinding: Field + zero: Field @dataclass @@ -13,47 +17,98 @@ class Nullifier: nf: bytes -@dataclass -class SecretNote: - note: InnerNote - nf_sk: Field - - def to_public_note(self) -> PublicNote: - return PublicNote( - note=self.note, - nf_pk=Point.generator().mul(self.nf_sk), - ) +def nf_pk(nf_sk) -> Field: + return prf("CL_NOTE_NF", nf_sk) -@dataclass -class PublicNote: - note: InnerNote - nf_pk: Point - - def commit(self) -> Commitment: - return crypto.COMM( - self.note.birth_constraint.hash(), - self.note.death_constraints_root(), - self.note.value, - self.note.unit, - self.note.state, - self.note.nonce, - self.nf_pk, - ) - - -@dataclass +@dataclass(unsafe_hash=True) class InnerNote: value: Field unit: str birth_constraint: Constraint - death_constraints: set[Constraint] + death_constraints: list[Constraint] state: Field nonce: Field - rand: Field + rand: Field # source of randomness for note commitment + + def r(self, index: int): + prf("CL_NOTE_COMM_RAND", self.rand, index) + + def verify_value(self) -> bool: + return 0 <= self.value and value <= 2**64 + + @property + def fungibility_domain(self) -> Field: + """The fungibility domain of this note""" + return crypto.prf( + "CL_NOTE_NULL", self.birth_constraint.hash(), *crypto.str_to_vec(unit) + ) def death_constraints_root(self) -> Field: """ Returns the merkle root over the set of death constraints """ return crypto.merkle_root(self.death_constraints) + + +@dataclass(unsafe_hash=True) +class PublicNote: + note: InnerNote + nf_pk: Field + + def blinding(self, rand: Field) -> Field: + """Blinding factor used in balance commitments""" + return prf("CL_NOTE_BAL_BLIND", rand, self.nonce, self.nf_pk) + + def commit(self) -> Field: + # blinding factors between data elems ensure no information is leaked in merkle paths + return crypto.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_note(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) + + def balance(self, rand): + """ + Returns the pederson commitment to the notes value. + """ + return crypto.pederson_commit( + self.note.value, self.blinding(rand), self.note.fungibility_domain + ) + + def zero(self, rand): + """ + Returns the pederson commitment to zero using the same blinding as the balance + commitment. + """ + return crypto.pederson_commit( + 0, self.blinding(rand), self.note.fungibility_domain + ) diff --git a/coordination-layer/notes.org b/coordination-layer/notes.org new file mode 100644 index 0000000..1dcf09c --- /dev/null +++ b/coordination-layer/notes.org @@ -0,0 +1,73 @@ +* Open Issues + +** 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. + + +** 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 +** 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 diff --git a/coordination-layer/partial_transaction.py b/coordination-layer/partial_transaction.py new file mode 100644 index 0000000..84fb46e --- /dev/null +++ b/coordination-layer/partial_transaction.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass + +from note import PublicNote, SecretNote +from crypto import Field, Point + + +@dataclass +class Output: + note: PublicNote + + # pre-computed balance and zero commitment "SecretNote" here. + balance: Field + zero: Field + + +@dataclass(unsafe_hash=True) +class PartialTransaction: + inputs: list[SecretNote] + outputs: list[Output] + rand: Field + + def balance(self) -> Point: + output_balance = sum(n.balance for n in self.outputs) + input_balance = sum(n.note.balance() for n in self.inputs) + return output_balance - input_balance + + def blinding(self) -> Field: + return sum(outputs.blinding(self.rand)) - sum(outputs.blinding(self.rand)) + + def zero(self) -> Field: + return sum(outputs.note.zero(self.rand)) - sum(inputs.zero(self.rand)) diff --git a/coordination-layer/test_transfer.py b/coordination-layer/test_transfer.py new file mode 100644 index 0000000..c92d930 --- /dev/null +++ b/coordination-layer/test_transfer.py @@ -0,0 +1,61 @@ +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 +from transaction_bundle import TransactionBundle + +import constraints + + +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=constraints.Vacuous(), + death_constraints=[constraints.Vacuous()], + state=Field.zero(), + nonce=Field.random(), + rand=Field.random(), + ), + nf_sk=alice.sk, + ) + + bobs_note = PublicNote( + note=InnerNote( + value=100, + unit="NMO", + birth_constraint=constraints.Vacuous(), + death_constraints=[constraints.Vacuous()], + state=Field.zero(), + nonce=Field.random(), + rand=Field.random(), + ), + nf_pk=bob.pk, + ) + + ptx = PartialTransaction( + inputs=[alices_note], + outputs=[alices_note], + rand=Field.random(), + ) + + bundle = TransactionBundle(bundle=[ptx]) + + assert bundle.verify() diff --git a/coordination-layer/transaction_bundle.py b/coordination-layer/transaction_bundle.py new file mode 100644 index 0000000..899caed --- /dev/null +++ b/coordination-layer/transaction_bundle.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + +from partial_transaction import PartialTransaction +from crypto import Field + + +@dataclass +class TransactionBundle: + bundle: list[PartialTransaction] + + def is_balanced(self) -> bool: + # TODO: move this to a NOIR constraint + return Field.zero() == sum(ptx.balance() - ptx.zero() for ptx in self.bundle) + + def verify(self) -> bool: + return self.is_balanced() and all(ptx.verify() for ptx in self.bundle)