diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d8eb6dd5..e83e6f2e1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased](https://github.com/o1-labs/snarkyjs/compare/3fbd9678e...HEAD) -> no unreleased changes yet +### Breaking changes + +- `Group` operations now generate a different set of constraints. This breaks deployed contracts, because the circuit changed. https://github.com/o1-labs/snarkyjs/pull/967 ## [0.11.0](https://github.com/o1-labs/snarkyjs/compare/a632313a...3fbd9678e) diff --git a/src/bindings b/src/bindings index 9f0acc0df1..adaddf68fc 160000 --- a/src/bindings +++ b/src/bindings @@ -1 +1 @@ -Subproject commit 9f0acc0df12912d4b092cba8e3e053a36a9da066 +Subproject commit adaddf68fc69b549bb0e5a08a01095d435998461 diff --git a/src/examples/primitive_constraint_system.ts b/src/examples/primitive_constraint_system.ts index f03bc3bbf1..a42776d12b 100644 --- a/src/examples/primitive_constraint_system.ts +++ b/src/examples/primitive_constraint_system.ts @@ -45,7 +45,7 @@ const GroupMock = { }, scale() { let g1 = Provable.witness(Group, () => Group.generator); - let s = Provable.witness(Scalar, () => Scalar.fromBigInt(5n)); + let s = Provable.witness(Scalar, () => Scalar.from(5n)); g1.scale(s); }, equals() { diff --git a/src/examples/regression_test.json b/src/examples/regression_test.json index 1736eed77e..0072acb530 100644 --- a/src/examples/regression_test.json +++ b/src/examples/regression_test.json @@ -140,24 +140,24 @@ "digest": "Group Primitive", "methods": { "add": { - "rows": 6, - "digest": "ef763443bed39d397cb02e683ea9a877" + "rows": 33, + "digest": "0cc19b3dc379f8706aedfd731f08145f" }, "sub": { - "rows": 6, - "digest": "329c0221e60d62aaf26ee1631f3f3c5a" + "rows": 34, + "digest": "ff55b39b569856e1c2025cae39d19e38" }, "scale": { - "rows": 106, - "digest": "1296dd40b232fb0bc4cd908d47fd88a9" + "rows": 114, + "digest": "17d1f373892d2c437dc9ce340e17e3d6" }, "equals": { - "rows": 27, - "digest": "adbcdc756d41853e24612e50b99be285" + "rows": 42, + "digest": "8d5ab248aa4602e6f77a8ab1015615df" }, "assertions": { - "rows": 4, - "digest": "8750eb002126017bc297e7b63d4429b9" + "rows": 20, + "digest": "f4be74b3b20a0f3c3279f0def39c6b35" } }, "verificationKey": { diff --git a/src/lib/group.test.ts b/src/lib/group.test.ts index ca61a6cceb..64faa9b93e 100644 --- a/src/lib/group.test.ts +++ b/src/lib/group.test.ts @@ -1,30 +1,46 @@ -import { - shutdown, - isReady, - Field, - Bool, - Circuit, - Group, - Scalar, - Provable, -} from 'snarkyjs'; +import { Bool, Group, Scalar, Provable } from 'snarkyjs'; describe('group', () => { let g = Group({ x: -1, y: 2, }); - beforeAll(async () => { - await isReady; - }); - - afterAll(async () => { - setTimeout(async () => { - await shutdown(); - }, 0); - }); describe('Inside circuit', () => { + describe('group membership', () => { + it('valid element does not throw', () => { + expect(() => { + Provable.runAndCheck(() => { + Provable.witness(Group, () => g); + }); + }).not.toThrow(); + }); + + it('valid element does not throw', () => { + expect(() => { + Provable.runAndCheck(() => { + Provable.witness(Group, () => Group.generator); + }); + }).not.toThrow(); + }); + + it('Group.zero element does not throw', () => { + expect(() => { + Provable.runAndCheck(() => { + Provable.witness(Group, () => Group.zero); + }); + }).not.toThrow(); + }); + + it('invalid group element throws', () => { + expect(() => { + Provable.runAndCheck(() => { + Provable.witness(Group, () => Group({ x: 2, y: 2 })); + }); + }).toThrow(); + }); + }); + describe('add', () => { it('g+g does not throw', () => { expect(() => { @@ -35,6 +51,55 @@ describe('group', () => { }); }).not.toThrow(); }); + + it('g+zero = g', () => { + expect(() => { + Provable.runAndCheck(() => { + const x = Provable.witness(Group, () => g); + const zero = Provable.witness(Group, () => Group.zero); + x.add(zero).assertEquals(x); + }); + }).not.toThrow(); + }); + + it('zero+g = g', () => { + expect(() => { + Provable.runAndCheck(() => { + const x = Provable.witness(Group, () => g); + const zero = Provable.witness(Group, () => Group.zero); + zero.add(x).assertEquals(x); + }); + }).not.toThrow(); + }); + + it('g+(-g) = zero', () => { + expect(() => { + Provable.runAndCheck(() => { + const x = Provable.witness(Group, () => g); + const zero = Provable.witness(Group, () => Group.zero); + x.add(x.neg()).assertEquals(zero); + }); + }).not.toThrow(); + }); + + it('(-g)+g = zero', () => { + expect(() => { + Provable.runAndCheck(() => { + const x = Provable.witness(Group, () => g); + const zero = Provable.witness(Group, () => Group.zero); + x.neg().add(x).assertEquals(zero); + }); + }).not.toThrow(); + }); + + it('zero + zero = zero', () => { + expect(() => { + Provable.runAndCheck(() => { + const zero = Provable.witness(Group, () => Group.zero); + zero.add(zero).assertEquals(zero); + }); + }).not.toThrow(); + }); }); describe('sub', () => { @@ -49,6 +114,35 @@ describe('group', () => { }); }).not.toThrow(); }); + + it('g-zero = g', () => { + expect(() => { + Provable.runAndCheck(() => { + const x = Provable.witness(Group, () => g); + const zero = Provable.witness(Group, () => Group.zero); + x.sub(zero).assertEquals(x); + }); + }).not.toThrow(); + }); + + it('zero - g = -g', () => { + expect(() => { + Provable.runAndCheck(() => { + const x = Provable.witness(Group, () => g); + const zero = Provable.witness(Group, () => Group.zero); + zero.sub(x).assertEquals(x.neg()); + }); + }).not.toThrow(); + }); + + it('zero - zero = zero', () => { + expect(() => { + Provable.runAndCheck(() => { + const zero = Provable.witness(Group, () => Group.zero); + zero.sub(zero).assertEquals(zero); + }); + }).not.toThrow(); + }); }); describe('neg', () => { @@ -60,6 +154,15 @@ describe('group', () => { }); }).not.toThrow(); }); + + it('neg(zero) = zero', () => { + expect(() => { + Provable.runAndCheck(() => { + const zero = Provable.witness(Group, () => Group.zero); + zero.neg().assertEquals(zero); + }); + }).not.toThrow(); + }); }); describe('scale', () => { @@ -161,6 +264,13 @@ describe('group', () => { g.neg(); }).not.toThrow(); }); + + it('zero.neg = zero', () => { + expect(() => { + const zero = Group.zero; + zero.neg().assertEquals(zero); + }).not.toThrow(); + }); }); describe('add', () => { @@ -169,6 +279,41 @@ describe('group', () => { g.add(g); }).not.toThrow(); }); + + it('g + zero = g', () => { + expect(() => { + const zero = Group.zero; + g.add(zero).assertEquals(g); + }).not.toThrow(); + }); + + it('zero + g = g', () => { + expect(() => { + const zero = Group.zero; + zero.add(g).assertEquals(g); + }).not.toThrow(); + }); + + it('g + (-g) = zero', () => { + expect(() => { + const zero = Group.zero; + g.add(g.neg()).assertEquals(zero); + }).not.toThrow(); + }); + + it('(-g) + g = zero', () => { + expect(() => { + const zero = Group.zero; + g.neg().add(g).assertEquals(zero); + }).not.toThrow(); + }); + + it('zero + zero = zero', () => { + expect(() => { + const zero = Group.zero; + zero.add(zero).assertEquals(zero); + }).not.toThrow(); + }); }); describe('sub', () => { @@ -177,6 +322,27 @@ describe('group', () => { Group.generator.sub(g); }).not.toThrow(); }); + + it('g - zero = g', () => { + expect(() => { + const zero = Group.zero; + g.sub(zero).assertEquals(g); + }).not.toThrow(); + }); + + it('zero - g = -g', () => { + expect(() => { + const zero = Group.zero; + zero.sub(g).assertEquals(g.neg()); + }).not.toThrow(); + }); + + it('zero - zero = -zero', () => { + expect(() => { + const zero = Group.zero; + zero.sub(zero).assertEquals(zero); + }).not.toThrow(); + }); }); describe('scale', () => { @@ -231,6 +397,7 @@ describe('group', () => { y.assertEquals(z); }); }); + it('sub', () => { let y = Provable.witness(Group, () => g).sub( Provable.witness(Group, () => Group.generator) @@ -238,11 +405,12 @@ describe('group', () => { let z = g.sub(Group.generator); y.assertEquals(z); }); + it('sub', () => { let y = Provable.witness(Group, () => g).assertEquals( Provable.witness(Group, () => g) ); - let z = g.assertEquals(g); + g.assertEquals(g); }); }); }); diff --git a/src/lib/group.ts b/src/lib/group.ts index 3dc98e2193..71f95fdae8 100644 --- a/src/lib/group.ts +++ b/src/lib/group.ts @@ -3,6 +3,7 @@ import { Scalar } from './scalar.js'; import { Snarky } from '../snarky.js'; import { Field as Fp } from '../provable/field-bigint.js'; import { Pallas } from '../bindings/crypto/elliptic_curve.js'; +import { Provable } from './provable.js'; import { Bool } from './bool.js'; export { Group }; @@ -21,6 +22,25 @@ class Group { return new Group({ x: Pallas.one.x, y: Pallas.one.y }); } + /** + * Unique representation of the `zero` element of the Group (the identity element of addition in this Group). + * + * **Note**: The `zero` element is represented as `(0, 0)`. + * + * ```typescript + * // g + -g = 0 + * g.add(g.neg()).assertEquals(zero); + * // g + 0 = g + * g.add(zero).assertEquals(g); + * ``` + */ + static get zero() { + return new Group({ + x: 0, + y: 0, + }); + } + /** * Coerces anything group-like to a {@link Group}. */ @@ -35,6 +55,9 @@ class Group { this.y = isField(y) ? y : new Field(y); if (this.#isConstant()) { + // we also check the zero element (0, 0) here + if (this.x.equals(0).and(this.y.equals(0)).toBoolean()) return; + const { add, mul, square } = Fp; let x_bigint = this.x.toBigInt(); @@ -53,8 +76,16 @@ class Group { } // helpers - static #fromAffine({ x, y }: { x: bigint; y: bigint; infinity: boolean }) { - return new Group({ x, y }); + static #fromAffine({ + x, + y, + infinity, + }: { + x: bigint; + y: bigint; + infinity: boolean; + }) { + return infinity ? Group.zero : new Group({ x, y }); } static #fromProjective({ x, y, z }: { x: bigint; y: bigint; z: bigint }) { @@ -77,6 +108,14 @@ class Group { }); } + /** + * Checks if this element is the `zero` element `{x: 0, y: 0}`. + */ + isZero() { + // only the zero element can have x = 0, there are no other (valid) group elements with x = 0 + return this.x.equals(0); + } + /** * Adds this {@link Group} element to another {@link Group} element. * @@ -87,25 +126,97 @@ class Group { */ add(g: Group) { if (this.#isConstant() && g.#isConstant()) { - if (this.x.toBigInt() === 0n) { + // we check if either operand is zero, because adding zero to g just results in g (and vise versa) + if (this.isZero().toBoolean()) { return g; - } else if (g.x.toBigInt() === 0n) { + } else if (g.isZero().toBoolean()) { return this; } else { let g_proj = Pallas.add(this.#toProjective(), g.#toProjective()); return Group.#fromProjective(g_proj); } } else { - let [, x, y] = Snarky.group.add(this.#toTuple(), g.#toTuple()); - return new Group({ x, y }); + const { x: x1, y: y1 } = this; + const { x: x2, y: y2 } = g; + + let zero = new Field(0); + + let same_x = Provable.witness(Field, () => x1.equals(x2).toField()); + + let inf = Provable.witness(Bool, () => + x1.equals(x2).and(y1.equals(y2).not()) + ); + + let inf_z = Provable.witness(Field, () => { + if (y1.equals(y2).toBoolean()) return zero; + else if (x1.equals(x2).toBoolean()) return y2.sub(y1).inv(); + else return zero; + }); + + let x21_inv = Provable.witness(Field, () => { + if (x1.equals(x2).toBoolean()) return zero; + else return x2.sub(x1).inv(); + }); + + let s = Provable.witness(Field, () => { + if (x1.equals(x2).toBoolean()) { + let x1_squared = x1.square(); + return x1_squared.add(x1_squared).add(x1_squared).div(y1.add(y1)); + } else return y2.sub(y1).div(x2.sub(x1)); + }); + + let x3 = Provable.witness(Field, () => { + return s.square().sub(x1.add(x2)); + }); + + let y3 = Provable.witness(Field, () => { + return s.mul(x1.sub(x3)).sub(y1); + }); + + let [, x, y] = Snarky.group.ecadd( + Group.from(x1.seal(), y1.seal()).#toTuple(), + Group.from(x2.seal(), y2.seal()).#toTuple(), + Group.from(x3, y3).#toTuple(), + inf.toField().value, + same_x.value, + s.value, + inf_z.value, + x21_inv.value + ); + + // similarly to the constant implementation, we check if either operand is zero + // and the implementation above (original OCaml implementation) returns something wild -> g + 0 != g where it should be g + 0 = g + let gIsZero = g.isZero(); + let thisIsZero = this.isZero(); + + let bothZero = gIsZero.and(thisIsZero); + + let onlyGisZero = gIsZero.and(thisIsZero.not()); + let onlyThisIsZero = thisIsZero.and(gIsZero.not()); + + let isNegation = inf; + + let isNewElement = bothZero + .not() + .and(isNegation.not()) + .and(onlyThisIsZero.not()) + .and(onlyGisZero.not()); + + const zero_g = Group.zero; + + return Provable.switch( + [bothZero, onlyGisZero, onlyThisIsZero, isNegation, isNewElement], + Group, + [zero_g, this, g, zero_g, new Group({ x, y })] + ); } } /** * Subtracts another {@link Group} element from this one. */ - sub(y: Group) { - return this.add(y.neg()); + sub(g: Group) { + return this.add(g.neg()); } /** @@ -113,6 +224,7 @@ class Group { */ neg() { let { x, y } = this; + return new Group({ x, y: y.neg() }); } @@ -151,7 +263,7 @@ class Group { let { x: x2, y: y2 } = g; x1.assertEquals(x2, message); - // y1.assertEquals(y2, message); need to enable this later on, but it breaks constraint backwards compatibility + y1.assertEquals(y2, message); } /** @@ -163,15 +275,10 @@ class Group { * ``` */ equals(g: Group) { - if (this.#isConstant() && g.#isConstant()) { - let { x: x1, y: y1 } = this; - let { x: x2, y: y2 } = g; + let { x: x1, y: y1 } = this; + let { x: x2, y: y2 } = g; - return x1.equals(x2).and(y1.equals(y2)); - } else { - let z = Snarky.group.equals(this.#toTuple(), g.#toTuple()); - return Bool.Unsafe.ofField(new Field(z)); - } + return x1.equals(x2).and(y1.equals(y2)); } /** @@ -346,7 +453,16 @@ class Group { */ static check(g: Group) { try { - Snarky.group.assertOnCurve(g.#toTuple()); + const { x, y } = g; + + let x2 = x.square(); + let x3 = x2.mul(x); + let ax = x.mul(Pallas.a); // this will obviously be 0, but just for the sake of correctness + + // we also check the zero element (0, 0) here + let isZero = x.equals(0).and(y.equals(0)); + + isZero.or(x3.add(ax).add(Pallas.b).equals(y.square())).assertTrue(); } catch (error) { if (!(error instanceof Error)) return error; throw `${`Element (x: ${g.x}, y: ${g.y}) is not an element of the group.`}\n${ diff --git a/src/lib/group.unit-test.ts b/src/lib/group.unit-test.ts new file mode 100644 index 0000000000..5a7102f078 --- /dev/null +++ b/src/lib/group.unit-test.ts @@ -0,0 +1,72 @@ +import { Group } from './core.js'; +import { test, Random } from './testing/property.js'; +import { Provable } from './provable.js'; +import { Poseidon } from '../provable/poseidon-bigint.js'; + +console.log('group consistency tests'); + +// tests consistency between in- and out-circuit implementations +test(Random.field, Random.field, (a, b, assert) => { + const { + x: x1, + y: { x0: y1 }, + } = Poseidon.hashToGroup([a])!; + + const { + x: x2, + y: { x0: y2 }, + } = Poseidon.hashToGroup([b])!; + + const zero = Group.zero; + const g1 = Group.from(x1, y1); + const g2 = Group.from(x2, y2); + + run(g1, g2, (x, y) => x.add(y), assert); + run(g1.neg(), g2.neg(), (x, y) => x.add(y), assert); + run(g1, g1.neg(), (x, y) => x.add(y), assert); + run(g1, zero, (x, y) => x.add(y), assert); + run(g1, zero.neg(), (x, y) => x.add(y), assert); + run(g1.neg(), zero, (x, y) => x.add(y), assert); + + run(zero, zero, (x, y) => x.add(y), assert); + run(zero, zero.neg(), (x, y) => x.add(y), assert); + run(zero.neg(), zero, (x, y) => x.add(y), assert); + run(zero.neg(), zero.neg(), (x, y) => x.add(y), assert); + + run(g1, g2, (x, y) => x.sub(y), assert); + run(g1.neg(), g2.neg(), (x, y) => x.sub(y), assert); + run(g1, g1.neg(), (x, y) => x.sub(y), assert); + run(g1, zero, (x, y) => x.sub(y), assert); + run(g1, zero.neg(), (x, y) => x.sub(y), assert); + run(g1.neg(), zero, (x, y) => x.sub(y), assert); + + run(zero, zero, (x, y) => x.sub(y), assert); + run(zero, zero.neg(), (x, y) => x.sub(y), assert); + run(zero.neg(), zero, (x, y) => x.sub(y), assert); + run(zero.neg(), zero.neg(), (x, y) => x.sub(y), assert); +}); + +function run( + g1: Group, + g2: Group, + f: (g1: Group, g2: Group) => Group, + assert: (b: boolean, message?: string | undefined) => void +) { + let result_out_circuit = f(g1, g2); + + Provable.runAndCheck(() => { + let result_in_circuit = f( + Provable.witness(Group, () => g1), + Provable.witness(Group, () => g2) + ); + + Provable.asProver(() => { + assert( + result_out_circuit.equals(result_in_circuit).toBoolean(), + `Result for x does not match. g1: ${JSON.stringify( + g1 + )}, g2: ${JSON.stringify(g2)}` + ); + }); + }); +} diff --git a/src/snarky.d.ts b/src/snarky.d.ts index d713c89063..e1d1d81305 100644 --- a/src/snarky.d.ts +++ b/src/snarky.d.ts @@ -45,6 +45,8 @@ declare interface ProvablePure extends Provable { check: (x: T) => void; } +type MlGroup = MlTuple; + declare namespace Snarky { type Main = (publicInput: MlArray) => void; type Keypair = unknown; @@ -186,24 +188,20 @@ declare const Snarky: { group: { /** - * Addition of two group elements, handles only variables. + * Low-level Elliptic Curve Addition gate. */ - add( - p1: MlTuple, - p2: MlTuple - ): MlTuple; - - assertOnCurve(p1: MlTuple): void; - - scale( - p: MlTuple, - s: MlArray - ): MlTuple; - - equals( - p1: MlTuple, - p2: MlTuple - ): BoolVar; + ecadd( + p1: MlGroup, + p2: MlGroup, + p3: MlGroup, + inf: FieldVar, + same_x: FieldVar, + slope: FieldVar, + inf_z: FieldVar, + x21_inv: FieldVar + ): MlGroup; + + scale(p: MlGroup, s: MlArray): MlGroup; }; /**