diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eb508bf0b..1e64b6d357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Internal support for several custom gates (range check, bitwise operations, foreign field operations) and lookup tables https://github.com/o1-labs/o1js/pull/1176 +- `Gadgets.rangeCheck64()`, new provable method to do efficient 64-bit range checks using lookup tables https://github.com/o1-labs/o1js/pull/1181 + ## [0.13.1](https://github.com/o1-labs/o1js/compare/c2f392fe5...045faa7) ### Breaking changes @@ -41,6 +43,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Improve prover performance by ~25% https://github.com/o1-labs/o1js/pull/1092 - Change internal representation of field elements to be JS bigint instead of Uint8Array +- Consolidate internal framework for testing equivalence of two implementations ## [0.13.0](https://github.com/o1-labs/o1js/compare/fbd4b2717...c2f392fe5) diff --git a/src/bindings b/src/bindings index 851d3df238..69904ab544 160000 --- a/src/bindings +++ b/src/bindings @@ -1 +1 @@ -Subproject commit 851d3df2385c693bd4f652ad7a0a1af98491e99c +Subproject commit 69904ab5445b12bd8bf3e87f6ac34c70e64e1add diff --git a/src/examples/ex02_root.ts b/src/examples/ex02_root.ts index 31dd48e818..54e5f0841b 100644 --- a/src/examples/ex02_root.ts +++ b/src/examples/ex02_root.ts @@ -1,4 +1,4 @@ -import { Field, Circuit, circuitMain, public_, UInt64 } from 'o1js'; +import { Field, Circuit, circuitMain, public_, UInt64, Gadgets } from 'o1js'; /* Exercise 2: @@ -10,6 +10,7 @@ Prove: class Main extends Circuit { @circuitMain static main(@public_ y: Field, x: UInt64) { + Gadgets.rangeCheck64(x.value); let y3 = y.square().mul(y); y3.assertEquals(x.value); } diff --git a/src/examples/ex02_root_program.ts b/src/examples/ex02_root_program.ts index c4115dbd8d..b301a9f72f 100644 --- a/src/examples/ex02_root_program.ts +++ b/src/examples/ex02_root_program.ts @@ -1,11 +1,4 @@ -import { - Field, - Circuit, - circuitMain, - public_, - UInt64, - Experimental, -} from 'o1js'; +import { Field, UInt64, Experimental, Gadgets } from 'o1js'; let { ZkProgram } = Experimental; @@ -15,6 +8,7 @@ const Main = ZkProgram({ main: { privateInputs: [UInt64], method(y: Field, x: UInt64) { + Gadgets.rangeCheck64(x.value); let y3 = y.square().mul(y); y3.assertEquals(x.value); }, diff --git a/src/index.ts b/src/index.ts index b65a42c11f..ead07ffbea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ export { export { Provable } from './lib/provable.js'; export { Circuit, Keypair, public_, circuitMain } from './lib/circuit.js'; export { UInt32, UInt64, Int64, Sign } from './lib/int.js'; +export { Gadgets } from './lib/gadgets/gadgets.js'; export { Types } from './bindings/mina-transaction/types.js'; export * as Mina from './lib/mina.js'; diff --git a/src/lib/field.unit-test.ts b/src/lib/field.unit-test.ts index f28f688bb3..8f7d8843f5 100644 --- a/src/lib/field.unit-test.ts +++ b/src/lib/field.unit-test.ts @@ -7,7 +7,16 @@ import { Provable } from './provable.js'; import { Binable } from '../bindings/lib/binable.js'; import { ProvableExtended } from './circuit_value.js'; import { FieldType } from './field.js'; -import { createEquivalenceTesters, throwError } from './testing/equivalent.js'; +import { + equivalentProvable as equivalent, + oneOf, + field, + bigintField, + throwError, + unit, + bool, + Spec, +} from './testing/equivalent.js'; // types Field satisfies Provable; @@ -56,73 +65,75 @@ test(Random.field, Random.int(-5, 5), (x, k) => { deepEqual(Field(x + BigInt(k) * Field.ORDER), Field(x)); }); +// Field | bigint parameter +let fieldOrBigint = oneOf(field, bigintField); + // special generator let SmallField = Random.reject( Random.field, (x) => x.toString(2).length > Fp.sizeInBits - 2 ); - -let { equivalent1, equivalent2, equivalentVoid1, equivalentVoid2 } = - createEquivalenceTesters(Field, Field); +let smallField: Spec = { ...field, rng: SmallField }; +let smallBigint: Spec = { ...bigintField, rng: SmallField }; +let smallFieldOrBigint = oneOf(smallField, smallBigint); // arithmetic, both in- and outside provable code -equivalent2((x, y) => x.add(y), Fp.add); -equivalent1((x) => x.neg(), Fp.negate); -equivalent2((x, y) => x.sub(y), Fp.sub); -equivalent2((x, y) => x.mul(y), Fp.mul); +let equivalent1 = equivalent({ from: [field], to: field }); +let equivalent2 = equivalent({ from: [field, fieldOrBigint], to: field }); + +equivalent2(Fp.add, (x, y) => x.add(y)); +equivalent1(Fp.negate, (x) => x.neg()); +equivalent2(Fp.sub, (x, y) => x.sub(y)); +equivalent2(Fp.mul, (x, y) => x.mul(y)); equivalent1( - (x) => x.inv(), - (x) => Fp.inverse(x) ?? throwError('division by 0') + (x) => Fp.inverse(x) ?? throwError('division by 0'), + (x) => x.inv() ); equivalent2( - (x, y) => x.div(y), - (x, y) => Fp.div(x, y) ?? throwError('division by 0') + (x, y) => Fp.div(x, y) ?? throwError('division by 0'), + (x, y) => x.div(y) ); -equivalent1((x) => x.square(), Fp.square); +equivalent1(Fp.square, (x) => x.square()); equivalent1( - (x) => x.sqrt(), - (x) => Fp.sqrt(x) ?? throwError('no sqrt') + (x) => Fp.sqrt(x) ?? throwError('no sqrt'), + (x) => x.sqrt() ); -equivalent2( - (x, y) => x.equals(y).toField(), - (x, y) => BigInt(x === y) +equivalent({ from: [field, fieldOrBigint], to: bool })( + (x, y) => x === y, + (x, y) => x.equals(y) ); -equivalent2( - (x, y) => x.lessThan(y).toField(), - (x, y) => BigInt(x < y), - SmallField + +equivalent({ from: [smallField, smallFieldOrBigint], to: bool })( + (x, y) => x < y, + (x, y) => x.lessThan(y) ); -equivalent2( - (x, y) => x.lessThanOrEqual(y).toField(), - (x, y) => BigInt(x <= y), - SmallField +equivalent({ from: [smallField, smallFieldOrBigint], to: bool })( + (x, y) => x <= y, + (x, y) => x.lessThanOrEqual(y) ); -equivalentVoid2( - (x, y) => x.assertEquals(y), - (x, y) => x === y || throwError('not equal') +equivalent({ from: [field, fieldOrBigint], to: unit })( + (x, y) => x === y || throwError('not equal'), + (x, y) => x.assertEquals(y) ); -equivalentVoid2( - (x, y) => x.assertNotEquals(y), - (x, y) => x !== y || throwError('equal') +equivalent({ from: [field, fieldOrBigint], to: unit })( + (x, y) => x !== y || throwError('equal'), + (x, y) => x.assertNotEquals(y) ); -equivalentVoid2( - (x, y) => x.assertLessThan(y), +equivalent({ from: [smallField, smallFieldOrBigint], to: unit })( (x, y) => x < y || throwError('not less than'), - SmallField + (x, y) => x.assertLessThan(y) ); -equivalentVoid2( - (x, y) => x.assertLessThanOrEqual(y), +equivalent({ from: [smallField, smallFieldOrBigint], to: unit })( (x, y) => x <= y || throwError('not less than or equal'), - SmallField + (x, y) => x.assertLessThanOrEqual(y) ); -equivalentVoid1( - (x) => x.assertBool(), - (x) => x === 0n || x === 1n || throwError('not boolean') +equivalent({ from: [field], to: unit })( + (x) => x === 0n || x === 1n || throwError('not boolean'), + (x) => x.assertBool() ); -equivalent1( - (x) => x.isEven().toField(), - (x) => BigInt((x & 1n) === 0n), - SmallField +equivalent({ from: [smallField], to: bool })( + (x) => (x & 1n) === 0n, + (x) => x.isEven() ); // non-constant field vars diff --git a/src/lib/gadgets/gadgets.ts b/src/lib/gadgets/gadgets.ts new file mode 100644 index 0000000000..b4c799b2cb --- /dev/null +++ b/src/lib/gadgets/gadgets.ts @@ -0,0 +1,36 @@ +/** + * Wrapper file for various gadgets, with a namespace and doccomments. + */ +import { rangeCheck64 } from './range-check.js'; +import { Field } from '../core.js'; + +export { Gadgets }; + +const Gadgets = { + /** + * Asserts that the input value is in the range [0, 2^64). + * + * This function proves that the provided field element can be represented with 64 bits. + * If the field element exceeds 64 bits, an error is thrown. + * + * @param x - The value to be range-checked. + * + * @throws Throws an error if the input value exceeds 64 bits. + * + * @example + * ```ts + * const x = Provable.witness(Field, () => Field(12345678n)); + * rangeCheck64(x); // successfully proves 64-bit range + * + * const xLarge = Provable.witness(Field, () => Field(12345678901234567890123456789012345678n)); + * rangeCheck64(xLarge); // throws an error since input exceeds 64 bits + * ``` + * + * **Note**: Small "negative" field element inputs are interpreted as large integers close to the field size, + * and don't pass the 64-bit check. If you want to prove that a value lies in the int64 range [-2^63, 2^63), + * you could use `rangeCheck64(x.add(1n << 63n))`. + */ + rangeCheck64(x: Field) { + return rangeCheck64(x); + }, +}; diff --git a/src/lib/gadgets/gadgets.unit-test.ts b/src/lib/gadgets/gadgets.unit-test.ts new file mode 100644 index 0000000000..13a44a059d --- /dev/null +++ b/src/lib/gadgets/gadgets.unit-test.ts @@ -0,0 +1,47 @@ +import { mod } from '../../bindings/crypto/finite_field.js'; +import { Field } from '../field.js'; +import { ZkProgram } from '../proof_system.js'; +import { + Spec, + boolean, + equivalentAsync, + field, +} from '../testing/equivalent.js'; +import { Random } from '../testing/random.js'; +import { Gadgets } from './gadgets.js'; + +// TODO: make a ZkFunction or something that doesn't go through Pickles + +let RangeCheck64 = ZkProgram({ + methods: { + run: { + privateInputs: [Field], + method(x) { + Gadgets.rangeCheck64(x); + }, + }, + }, +}); + +await RangeCheck64.compile(); + +let maybeUint64: Spec = { + ...field, + rng: Random.map(Random.oneOf(Random.uint64, Random.uint64.invalid), (x) => + mod(x, Field.ORDER) + ), +}; + +// do a couple of proofs +// TODO: we use this as a test because there's no way to check custom gates quickly :( + +equivalentAsync({ from: [maybeUint64], to: boolean }, { runs: 3 })( + (x) => { + if (x >= 1n << 64n) throw Error('expected 64 bits'); + return true; + }, + async (x) => { + let proof = await RangeCheck64.run(x); + return await RangeCheck64.verify(proof); + } +); diff --git a/src/lib/gadgets/range-check.ts b/src/lib/gadgets/range-check.ts index 12a914ccd8..d27d4807a4 100644 --- a/src/lib/gadgets/range-check.ts +++ b/src/lib/gadgets/range-check.ts @@ -3,6 +3,9 @@ import * as Gates from '../gates.js'; export { rangeCheck64 }; +/** + * Asserts that x is in the range [0, 2^64), handles constant case + */ function rangeCheck64(x: Field) { if (x.isConstant()) { if (x.toBigInt() >= 1n << 64n) { diff --git a/src/lib/testing/equivalent.ts b/src/lib/testing/equivalent.ts index c50c662a0a..c19748624e 100644 --- a/src/lib/testing/equivalent.ts +++ b/src/lib/testing/equivalent.ts @@ -4,134 +4,244 @@ import { test, Random } from '../testing/property.js'; import { Provable } from '../provable.js'; import { deepEqual } from 'node:assert/strict'; +import { Bool, Field } from '../core.js'; -export { createEquivalenceTesters, throwError, handleErrors }; +export { + equivalent, + equivalentProvable, + equivalentAsync, + oneOf, + throwError, + handleErrors, + deepEqual as defaultAssertEqual, + id, +}; +export { field, bigintField, bool, boolean, unit }; +export { Spec, ToSpec, FromSpec, SpecFromFunctions, ProvableSpec }; -function createEquivalenceTesters( - Field: Provable, - newField: (x: bigint) => Field -) { - function equivalent1( - op1: (x: Field) => Field, - op2: (x: bigint) => bigint, - rng: Random = Random.field +// a `Spec` tells us how to compare two functions + +type FromSpec = { + // `rng` creates random inputs to the first function + rng: Random; + + // `there` converts to inputs to the second function + there: (x: In1) => In2; + + // `provable` tells us how to create witnesses, to test provable code + // note: we only allow the second function to be provable; + // the second because it's more natural to have non-provable types as random generator output + provable?: Provable; +}; + +type ToSpec = { + // `back` converts outputs of the second function back to match the first function + back: (x: Out2) => Out1; + + // `assertEqual` to compare outputs against each other; defaults to `deepEqual` + assertEqual?: (x: Out1, y: Out1, message: string) => void; +}; + +type Spec = FromSpec & ToSpec; + +type ProvableSpec = Spec & { provable: Provable }; + +type FuncSpec, Out1, In2 extends Tuple, Out2> = { + from: { + [k in keyof In1]: k extends keyof In2 ? FromSpec : never; + }; + to: ToSpec; +}; + +type SpecFromFunctions< + F1 extends AnyFunction, + F2 extends AnyFunction +> = FuncSpec, ReturnType, Parameters, ReturnType>; + +function id(x: T) { + return x; +} + +// unions of specs, to cleanly model function parameters that are unions of types + +type FromSpecUnion = { + _isUnion: true; + specs: Tuple>; + rng: Random<[number, T1]>; +}; + +type OrUnion = FromSpec | FromSpecUnion; + +type Union = T[keyof T & number]; + +function oneOf>>( + ...specs: In +): FromSpecUnion>, Union>> { + // the randomly generated value from a union keeps track of which spec it came from + let rng = Random.oneOf( + ...specs.map((spec, i) => + Random.map(spec.rng, (x) => [i, x] as [number, any]) + ) + ); + return { _isUnion: true, specs, rng }; +} + +function toUnion(spec: OrUnion): FromSpecUnion { + let specAny = spec as any; + return specAny._isUnion ? specAny : oneOf(specAny); +} + +// equivalence tester + +function equivalent< + In extends Tuple>, + Out extends ToSpec +>({ from, to }: { from: In; to: Out }) { + return function run( + f1: (...args: Params1) => Result1, + f2: (...args: Params2) => Result2, + label = 'expect equal results' ) { - test(rng, (x0, assert) => { - let x = newField(x0); - // outside provable code + let generators = from.map((spec) => spec.rng); + let assertEqual = to.assertEqual ?? deepEqual; + test(...(generators as any[]), (...args) => { + args.pop(); + let inputs = args as Params1; handleErrors( - () => op1(x), - () => op2(x0), - (a, b) => assert(a.toBigInt() === b, 'equal results') + () => f1(...inputs), + () => + to.back( + f2(...(inputs.map((x, i) => from[i].there(x)) as Params2)) + ), + (x, y) => assertEqual(x, y, label), + label ); - // inside provable code - Provable.runAndCheck(() => { - x = Provable.witness(Field, () => x); - handleErrors( - () => op1(x), - () => op2(x0), - (a, b) => - Provable.asProver(() => assert(a.toBigInt() === b, 'equal results')) - ); - }); }); - } - function equivalent2( - op1: (x: Field, y: Field | bigint) => Field, - op2: (x: bigint, y: bigint) => bigint, - rng: Random = Random.field + }; +} + +// async equivalence + +function equivalentAsync< + In extends Tuple>, + Out extends ToSpec +>({ from, to }: { from: In; to: Out }, { runs = 1 } = {}) { + return async function run( + f1: (...args: Params1) => Promise> | Result1, + f2: (...args: Params2) => Promise> | Result2, + label = 'expect equal results' ) { - test(rng, rng, (x0, y0, assert) => { - let x = newField(x0); - let y = newField(y0); - // outside provable code - handleErrors( - () => op1(x, y), - () => op2(x0, y0), - (a, b) => assert(a.toBigInt() === b, 'equal results') - ); - handleErrors( - () => op1(x, y0), - () => op2(x0, y0), - (a, b) => assert(a.toBigInt() === b, 'equal results') - ); - // inside provable code - Provable.runAndCheck(() => { - x = Provable.witness(Field, () => x); - y = Provable.witness(Field, () => y); - handleErrors( - () => op1(x, y), - () => op2(x0, y0), - (a, b) => - Provable.asProver(() => assert(a.toBigInt() === b, 'equal results')) - ); - handleErrors( - () => op1(x, y0), - () => op2(x0, y0), - (a, b) => - Provable.asProver(() => assert(a.toBigInt() === b, 'equal results')) + let generators = from.map((spec) => spec.rng); + let assertEqual = to.assertEqual ?? deepEqual; + + let nexts = generators.map((g) => g.create()); + + for (let i = 0; i < runs; i++) { + let args = nexts.map((next) => next()); + let inputs = args as Params1; + try { + await handleErrorsAsync( + () => f1(...inputs), + async () => + to.back( + await f2( + ...(inputs.map((x, i) => from[i].there(x)) as Params2) + ) + ), + (x, y) => assertEqual(x, y, label), + label ); - }); - }); - } - function equivalentVoid1( - op1: (x: Field) => void, - op2: (x: bigint) => void, - rng: Random = Random.field + } catch (err) { + console.log(...inputs); + throw err; + } + } + }; +} + +// equivalence tester for provable code + +function equivalentProvable< + In extends Tuple>, + Out extends ToSpec +>({ from: fromRaw, to }: { from: In; to: Out }) { + let fromUnions = fromRaw.map(toUnion); + return function run( + f1: (...args: Params1) => Result1, + f2: (...args: Params2) => Result2, + label = 'expect equal results' ) { - test(rng, (x0) => { - let x = newField(x0); - // outside provable code - handleErrors( - () => op1(x), - () => op2(x0) + let generators = fromUnions.map((spec) => spec.rng); + let assertEqual = to.assertEqual ?? deepEqual; + test(...generators, (...args) => { + args.pop(); + + // figure out which spec to use for each argument + let from = (args as [number, unknown][]).map( + ([j], i) => fromUnions[i].specs[j] ); - // inside provable code - Provable.runAndCheck(() => { - x = Provable.witness(Field, () => x); - handleErrors( - () => op1(x), - () => op2(x0) - ); - }); - }); - } - function equivalentVoid2( - op1: (x: Field, y: Field | bigint) => void, - op2: (x: bigint, y: bigint) => void, - rng: Random = Random.field - ) { - test(rng, rng, (x0, y0) => { - let x = newField(x0); - let y = newField(y0); + let inputs = (args as [number, unknown][]).map( + ([, x]) => x + ) as Params1; + let inputs2 = inputs.map((x, i) => from[i].there(x)) as Params2; + // outside provable code handleErrors( - () => op1(x, y), - () => op2(x0, y0) - ); - handleErrors( - () => op1(x, y0), - () => op2(x0, y0) + () => f1(...inputs), + () => f2(...inputs2), + (x, y) => assertEqual(x, to.back(y), label), + label ); + // inside provable code Provable.runAndCheck(() => { - x = Provable.witness(Field, () => x); - y = Provable.witness(Field, () => y); + let inputWitnesses = inputs2.map((x, i) => { + let provable = from[i].provable; + return provable !== undefined + ? Provable.witness(provable, () => x) + : x; + }) as Params2; handleErrors( - () => op1(x, y), - () => op2(x0, y0) - ); - handleErrors( - () => op1(x, y0), - () => op2(x0, y0) + () => f1(...inputs), + () => f2(...inputWitnesses), + (x, y) => Provable.asProver(() => assertEqual(x, to.back(y), label)) ); }); }); - } - - return { equivalent1, equivalent2, equivalentVoid1, equivalentVoid2 }; + }; } +// some useful specs + +let unit: ToSpec = { back: id, assertEqual() {} }; + +let field: ProvableSpec = { + rng: Random.field, + there: Field, + back: (x) => x.toBigInt(), + provable: Field, +}; + +let bigintField: Spec = { + rng: Random.field, + there: id, + back: id, +}; + +let bool: ProvableSpec = { + rng: Random.boolean, + there: Bool, + back: (x) => x.toBoolean(), + provable: Bool, +}; +let boolean: Spec = { + rng: Random.boolean, + there: id, + back: id, +}; + +// helper to ensure two functions throw equivalent errors + function handleErrors( op1: () => T, op2: () => S, @@ -162,6 +272,73 @@ function handleErrors( } } +async function handleErrorsAsync( + op1: () => T, + op2: () => S, + useResults?: (a: Awaited, b: Awaited) => R, + label?: string +): Promise { + let result1: Awaited, result2: Awaited; + let error1: Error | undefined; + let error2: Error | undefined; + try { + result1 = await op1(); + } catch (err) { + error1 = err as Error; + } + try { + result2 = await op2(); + } catch (err) { + error2 = err as Error; + } + if (!!error1 !== !!error2) { + error1 && console.log(error1); + error2 && console.log(error2); + } + let message = `${(label && `${label}: `) || ''}equivalent errors`; + deepEqual(!!error1, !!error2, message); + if (!(error1 || error2) && useResults !== undefined) { + return useResults(result1!, result2!); + } +} + function throwError(message?: string): any { throw Error(message); } + +// helper types + +type AnyFunction = (...args: any) => any; + +type Tuple = [] | [T, ...T[]]; + +// infer input types from specs + +type Param1> = In extends { + there: (x: infer In) => any; +} + ? In + : In extends FromSpecUnion + ? T1 + : never; +type Param2> = In extends { + there: (x: any) => infer In; +} + ? In + : In extends FromSpecUnion + ? T2 + : never; + +type Params1>> = { + [k in keyof Ins]: Param1; +}; +type Params2>> = { + [k in keyof Ins]: Param2; +}; + +type Result1> = Out extends ToSpec + ? Out1 + : never; +type Result2> = Out extends ToSpec + ? Out2 + : never;