Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refac/groth16 mpc #1372

Open
wants to merge 112 commits into
base: master
Choose a base branch
from
Open

Refac/groth16 mpc #1372

wants to merge 112 commits into from

Conversation

Tabaie
Copy link
Contributor

@Tabaie Tabaie commented Dec 25, 2024

Description

#1297, but using tools from Consensys/gnark-crypto#589.

Fixes # (issue)

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How has this been tested?

  • Test A
  • Test B

How has this been benchmarked?

  • Benchmark A, on Macbook pro M1, 32GB RAM
  • Benchmark B, on x86 Intel xxx, 16GB RAM

Checklist:

  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works
  • I did not modify files generated from templates
  • golangci-lint does not output errors locally
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

@Tabaie Tabaie requested review from gbotrel and ivokub December 25, 2024 21:37
Copy link
Collaborator

@gbotrel gbotrel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, minor changes maybe

r[i].SetRandom()
func areInSubGroupG1(s []curve.G1Affine) bool {
for i := range s {
if !s[i].IsInSubGroup() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's not cheap, we don't have a batch subgroup check yet (@yelhousni wanted to add one) but shouldn't we parallelize since we assume the nominal case is everything is in subgroup ?

internal/utils/test_utils/test_utils.go Outdated Show resolved Hide resolved
package utils

// AppendRefs returns append(s, &v[0], &v[1], ...).
func AppendRefs[T any](s []any, v []T) []any {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this still needed with golang slices generic stuff ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just went through the package docs. Nothing there does it compactly.

@Tabaie Tabaie requested a review from gbotrel January 22, 2025 21:38
Copy link
Collaborator

@ivokub ivokub left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Arya! It looks really nice. Imo I understood it mostly. There are a few comments though, see inline.

A few of the comments are nice to have and you don't need to address now. We could do it in separate PR:

  • optional randomness source for contribution
  • being able to change hash function for challenge computation for cool tricks a la providing SNARK of ceremony correctness

/*if a[0].IsOne() {
A = A[1:]
a = a[1:]
}*/
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In all usages in the package we have len(A) == len(a) I would add the check explicitly as a defensive measure. First to avoid out-of-bounds reads due to some calling error and secondly to ensure that all A are scaled. Compared to scalar muls below it is negligible cost, but adds clarity.

Copy link
Contributor Author

@Tabaie Tabaie Jan 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My concern with this is that its uses will become verbose and redundant. We'll get things like

scaleG1InPlace(c.G1.Tau[1:], tauUpdates[1:len(c.G1.Tau])
scaleG1InPlace(c.G1.AlphaTau, alphaUpdates[:len(c.G1.AlphaTau)])
scaleG1InPlace(c.G1.BetaTau, betaUpdates[:len(c.G1.BetaTau)])

where we previously had

scaleG1InPlace(c.G1.Tau[1:], tauUpdates[1:])
scaleG1InPlace(c.G1.AlphaTau, alphaUpdates)
scaleG1InPlace(c.G1.BetaTau, betaUpdates)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed - but currently the functions scaleG1InPlace and scaleG2InPlace are used only in one method in phase1.go where we know anyway that len(tauUpdates) == len(c.G1.Tau) (etc for AlphaTau and BetaTau).

Even more, now having a second look - I think we could just inline powers and scaleG1InPlace without defining utility methods at all and avoid too early generefication. Then we can avoid allocating the slices tauUpdates, betaUpdates and alphaUpdates.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And in order to make it parallel, we can use utils.Parallelize and then within compute s = f^start as the starting point and then at every loop multiply s by f and then do a scalar multiplication.

/*if a[0].IsOne() {
A = A[1:]
a = a[1:]
}*/
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly would add check len(A) == len(a) explicitly. And imo it is OK to panic here, no need to handle errors.

)

func ExtractKeys(srs1 *Phase1, srs2 *Phase2, evals *Phase2Evaluations, nConstraints int) (pk groth16.ProvingKey, vk groth16.VerifyingKey) {
// Seal performs the final contribution and outputs the proving and verifying keys.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add documentation what the beaconChallenge should be. Is it an identifier for the current ceremony? What happens if I run Seal independently on same Phase2 with different beaconChallenge - is there any dependence between the output Groth16 SRS?

backend/groth16/bls12-377/mpcsetup/setup_test.go Outdated Show resolved Hide resolved

func testAll(t *testing.T, nbContributionsPhase1, nbContributionsPhase2 int) {
assert := require.New(t)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is probably some import cycle involved, but couldn't you just do assert := test.NewAssert(t)? Then the returned test.Assert object has helper methods for proving etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which methods do you have in mind? Since the setup has to be in a custom way (imitating how an actual multiparty ceremony would go with objects being written to and read from the network) there is only the proof and verification stages to get help with, and those are short enough imo.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is method below proveVerifyCircuit, but indeed it uses fixed proving and verifying key and test.Assert doesn't currently have a way to test it like this. But still, test.Assert is a very simple wrapper around require.Assertions, keeping all the methods what require.Assertions has. And imo this unifies the testing style if we use same instance everywhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah so we generally better not use the testify repo directly?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least I would use the wrapper we have - then the test implementations across different gnark packages are more coherent imo.

@@ -0,0 +1,286 @@
package mpcsetup
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File only generated for BN254? I would code-generate just in case.

Copy link
Contributor Author

@Tabaie Tabaie Jan 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea was not to clog the CI with unit tests. The full test is still generated for all curves. Ignoring the unit tests saves 5 seconds per curve on my laptop. Perhaps not worth it?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case, I would still code generate, but maybe conditionally add test.Skip() in case the curve isn't BN254. Then atleast we ensure that everything is syntactically correct and the unit test is covered in code generation.

}

func (phase1 *Phase1) hash() []byte {
func (p *Phase1) hash() []byte {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And imo, the method could also be public and defined:
func (p *Phase) Challenge(previous []byte) []byte

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it perhaps be better to remove the challenge field entirely, and just feed it as input to the Contribute and Verify functions?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The downside of this approach is that when sending contributions on the wire the user has to read/write two objects rather than one.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But in practice do we need to send challenge on the wire? When the user returns contribution to the sequencer, then it doesn't need to send the challenge, sequencer can compute it on its own (and it actually already does it, assuming that it verifies the contributions).

Now, the question imo is if the user is sent the tip of the ceremony for starting their contribution, then should it verify the full state of the current ceremony. Imo for me it would make sense it should, to ensure that it doesn't contribute to some fork etc. From practical point of view, it is probably too heavy for the users to verify and it could only contribute based on current trusted tip (i.e. ceremony tip and running challenge). And imo then it makes sense that challenge is separate, to indicate its optionality. And in this case imo we can provide it separately to Contribute and Verify

But its only my gut feeling, maybe it is okay to keep it in, but add comment that it is "trusted" value and should be verfied in full.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But in practice do we need to send challenge on the wire?

Yes, any new contributor need to receive the challenge used by the last contribution in order to compute its own challenge (so that the channel reflects the entire protocol transcript)
You are right that the field can be omitted when a contribution is being sent back to the sequencer.

I don't think we should require contributors to verify the whole protocol transcript. That goes against two of the protocol's major design goals:

  1. Low entry barrier for contributions: I should be able to contribute relatively easily on a small-ish machine in a short amount of time
  2. Indefinite run: The protocol is never truly over. We should always be able to enhance our SRS with more contributions, so the number of contributions is not a constant. So especially in light of (1) the verification effort gets prohibitive.

but add comment that it is "trusted" value

A trusted value from the contributor's perspective you mean? Because the verifier does reconstruct it, in the same way as you described in the first paragraph:
https://github.com/Consensys/gnark/blob/refac/groth16-mpc/backend/groth16/bn254/mpcsetup/phase1.go#L168

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I think from practical point of view it doesn't make sense for the contributor to verify the whole chain, only the previous contribution on top it builds its contribution. And I guess it is fine to keep Challenge field inside the structs, makes easier to serialize and transport.

@@ -21,8 +22,8 @@ func RoundTripCheck(from any, to func() any) error {
if err != nil {
return err
}
if !reflect.DeepEqual(from, r) {
return errors.New("reconstructed object don't match original (ReadFrom)")
if err = equal(from, r); err != nil {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would keep it as is, imo there may be unwanted side-effects of using Equal if exists - there are already a few types in gnark which have the Equal method defined and they may be more strict than needed.

It seems like it is used to avoid comparing Challenge field in a test. But at least for me it would be clearer to manually empty the Challenge field manually when needed in the test. This current approach may break imo other roundtrip tests.

}
if !sameRatio(contribution.Parameters.G1.AlphaTau[0], current.Parameters.G1.AlphaTau[0], alphaR, contribution.PublicKeys.Alpha.XR) {
return errors.New("couldn't verify that [α]₁ is based on previous contribution")
next.Challenge = challenge
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My assumption was that verification wouldn't change the inputs, but it does as we write next.Challenge. This imo may have practical side effects that the contributions are not static (a la when hashing serialized proofs). Instead, I would provide the challenge as argument to Verify and then VerifyPhase1 automatically carries the continuation over to next round (with computing it using newChallenge := Phase1.Hash(challenge))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's only changed if it is empty.

if len(next.Challenge) != 0 && !bytes.Equal(next.Challenge, challenge) {
return errors.New("the challenge does not match the previous contribution's hash")
}
next.Challenge = challenge
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly as for Phase1 imo Phase2.Verify shouldn't change the inputs. See the corresponding comment.

@ivokub ivokub mentioned this pull request Jan 23, 2025
3 tasks
return ccs.(*cs.R1CS)
})

func getTestCircuit() *cs.R1CS {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And now, we don't even need to have getTestCircuit function separately, we can just remove the current getTestCircuit function, rename onceCircuit variable to getTestCircuit and it works exactly the same.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants