UNPKG

mina-attestations

Version:
700 lines 29.6 kB
import { Bool, Field, Option, Provable, UInt32, Gadgets, Poseidon, MerkleList, } from 'o1js'; import { assert, assertHasProperty, chunk, fill, pad, zip } from "../util.js"; import { ProvableType, } from "../o1js-missing.js"; import { assertInRange16, assertLessThan16, lessThan16, pack, } from "./gadgets.js"; import { ProvableFactory } from "../provable-factory.js"; import { deserializeProvable, deserializeProvableType, serializeProvable, serializeProvableType, } from "../serialize-provable.js"; import { TypeBuilder } from "../provable-type-builder.js"; import { StaticArray } from "./static-array.js"; import { bitSize, packToField } from "./dynamic-hash.js"; import { BaseType } from "./dynamic-base-types.js"; import { NestedProvable } from "../nested.js"; import { z } from 'zod'; import { SerializedTypeSchema, SerializedValueSchema } from "../validation.js"; export { DynamicArray }; export { DynamicArrayBase, provable as provableDynamicArray, }; function DynamicArray(type, { maxLength, }) { // assert maxLength bounds assert(maxLength >= 0, 'maxLength must be >= 0'); assert(maxLength < 2 ** 16, 'maxLength must be < 2^16'); class DynamicArray_ extends DynamicArrayBase { get innerType() { return type; } static get maxLength() { return maxLength; } static get provable() { return provableArray; } static from(input) { return provableArray.fromValue(input); } } const provableArray = provable(ProvableType.get(type), DynamicArray_).build(); return DynamicArray_; } BaseType.DynamicArray = DynamicArray; class DynamicArrayBase { /** * The internal array, which includes the actual values, padded up to `maxLength` with unconstrained values. */ array; /** * Length of the array. Guaranteed to be in [0, maxLength]. */ length; // props to override get innerType() { throw Error('Inner type must be defined in a subclass.'); } static get maxLength() { throw Error('Max length must be defined in a subclass.'); } // derived prop get maxLength() { return this.constructor.maxLength; } constructor(array, length) { let maxLength = this.maxLength; assert(array.length === maxLength, 'input has to match maxLength'); this.array = array; this.length = length; } /** * Asserts that 0 <= i < this.length, using a cached check that's not duplicated when doing it on the same variable multiple times. * * Cost: 1.5 */ assertIndexInRange(i) { if (!this._indicesInRange.has(i.value)) { assertLessThan16(i, this.length); this._indicesInRange.add(i.value); } } /** * Gets value at index i, and proves that the index is in the array. * * Cost: TN + 1.5 */ get(i) { this.assertIndexInRange(i); return this.getOrUnconstrained(i.value); } /** * Gets a value at index i, as an option that is None if the index is not in the array. * * Note: The correct type for `i` is actually UInt16 which doesn't exist. The method is not complete (but sound) for i >= 2^16. * * Cost: TN + 2.5 */ getOption(i) { let type = this.innerType; let isContained = lessThan16(i.value, this.length); let value = this.getOrUnconstrained(i.value); const OptionT = Option(type); return OptionT.fromValue({ isSome: isContained, value }); } /** * Gets a value at index i, ASSUMING that the index is in the array. * * If the index is in fact not in the array, the return value is completely unconstrained. * * **Warning**: Only use this if you already know/proved by other means that the index is within bounds. * * Cost: T*N where T = size of the type */ getOrUnconstrained(i) { let type = ProvableType.get(this.innerType); let NULL = ProvableType.synthesize(type); let ai = Provable.witness(type, () => this.array[Number(i)] ?? NULL); let aiFields = type.toFields(ai); // assert a is correct on every field column with arrayGet() let fields = this.array.map((t) => type.toFields(t)); for (let j = 0; j < type.sizeInFields(); j++) { let column = fields.map((x) => x[j]); Gadgets.arrayGet(column, i).assertEquals(aiFields[j]); } return ai; } /** * Sets a value at index i and proves that the index is in the array. * * Cost: 1.5(T + 1)N + 1.5 */ set(i, value) { this.assertIndexInRange(i); this.setOrDoNothing(i.value, value); } /** * Sets a value at index i, or does nothing if the index is not in the array * * Cost: 1.5(T + 1)N */ setOrDoNothing(i, value) { zip(this.array, this._indexMask(i)).forEach(([t, equalsIJ], j) => { this.array[j] = Provable.if(equalsIJ, this.innerType, value, t); }); } /** * Map every element of the array to a new value. * * **Warning**: The callback will be passed unconstrained dummy values. */ map(type, f) { let Array = DynamicArray(type, { maxLength: this.maxLength }); let provable = ProvableType.get(type); let array = this.array.map((x, i) => provable.fromValue(f(x, i))); let newArray = new Array(array, this.length); // new array has same length/maxLength, so it can use the same cached masks newArray._indexMasks = this._indexMasks; newArray._indicesInRange = this._indicesInRange; newArray.__dummyMask = this.__dummyMask; return newArray; } /** * Iterate over all elements of the array. * * The callback will be passed an element and a boolean `isDummy` indicating whether the value is part of the actual array. */ forEach(f) { zip(this.array, this._dummyMask()).forEach(([t, isDummy], i) => { f(t, isDummy, i); }); } /** * Iterate over all elements of the array, in reverse order. * * The callback will be passed an element and a boolean `isDummy` indicating whether the value is part of the actual array. * * Note: the indices are also passed in reverse order, i.e. we always have `t = this.array[i]`. */ forEachReverse(f) { zip(this.array, this._dummyMask()) .toReversed() .forEach(([t, isDummy], i) => { f(t, isDummy, this.maxLength - 1 - i); }); } /** * Reduce the array to a single value. * * The callback will be passed the current state, an element, and a boolean `isDummy` indicating whether the value is part of the actual array. */ reduce(stateType, state, f) { let type = NestedProvable.get(stateType); this.forEach((t, isDummy) => { let newState = f(state, t, isDummy); state = Provable.if(isDummy, type, state, newState); }); return state; } /** * Split the array at index i, i.e. returns `[slice(0, i), slice(i)]`. * * If i is 0, the first array will be empty. * If i it >= the length, the second array will be empty. * * Note: this method uses very few constraints, it's only rearranging the array contents * and recomputing the two lengths. */ splitAt(i) { assert(i >= 0 && i < 1 << 16, 'index must be in [0, 2^16)'); let maxLength1 = Math.min(i, this.maxLength); let maxLength2 = Math.max(this.maxLength - i, 0); let Array1 = DynamicArray(this.innerType, { maxLength: maxLength1 }); let Array2 = DynamicArray(this.innerType, { maxLength: maxLength2 }); let array1 = this.array.slice(0, maxLength1); let array2 = this.array.slice(maxLength1); let ltLength = lessThan16(Field(i), this.length); let length1 = Provable.if(ltLength, Field(i), this.length); let length2 = Provable.if(ltLength, this.length.sub(Field(i)), Field(0)); return [new Array1(array1, length1), new Array2(array2, length2)]; } /** * Equivalent to `Array.slice(start)`. Supports variable start index. */ slice(start) { if (typeof start === 'number') return this.splitAt(start)[1]; let Array = DynamicArray(this.innerType, { maxLength: this.maxLength }); let length = this.length.sub(start.value).seal(); Gadgets.rangeCheck16(length); // note: these values are constrained if the index is in the new range // i < length - start => start + i < length let array = this.array.map((_, i) => this.getOrUnconstrained(start.value.add(i))); return new Array(array, length); } /** * Returns a new array with the elements reversed. */ reverse() { let Array = DynamicArray(this.innerType, { maxLength: this.maxLength }); // first, reverse the full array let array = this.array.toReversed(); // `array` is not yet what we need, since it has all the padding at the beginning // so, slice off the padding let maxLength = Field(this.maxLength); return new Array(array, maxLength).slice(UInt32.Unsafe.fromField(maxLength.sub(this.length).seal())); } /** * Dynamic array hash that only depends on the actual values (not the padding). * * Avoids hash collisions by encoding the number of actual elements at the beginning of the hash input. */ hash() { let type = ProvableType.get(this.innerType); // pack all elements into a single field element let fields = this.array.map((x) => packToField(x, type)); let NULL = packToField(ProvableType.synthesize(type), type); // assert that all padding elements are 0. this allows us to pack values into blocks zip(fields, this._dummyMask()).forEach(([x, isPadding]) => { Provable.assertEqualIf(isPadding, Field, x, NULL); }); // create blocks of 2 field elements each // TODO abstract this into a `chunk()` method that returns a DynamicArray<StaticArray<T>> let elementSize = bitSize(type); if (elementSize === 0) elementSize = 1; // edge case for empty types like `Undefined` let elementsPerHalfBlock = Math.floor(254 / elementSize); if (elementsPerHalfBlock === 0) elementsPerHalfBlock = 1; // larger types are compressed let elementsPerBlock = 2 * elementsPerHalfBlock; // we pack the length at the beginning of the first block // for efficiency (to avoid unpacking the length), we first put zeros at the beginning // and later just add the length to the first block let elementsPerUint32 = Math.max(Math.floor(32 / elementSize), 1); let array = fill(elementsPerUint32, Field(0)).concat(fields); let maxBlocks = Math.ceil((elementsPerUint32 + this.maxLength) / elementsPerBlock); let padded = pad(array, maxBlocks * elementsPerBlock, NULL); let chunked = chunk(padded, elementsPerBlock); let blocks = chunked.map((block) => { let firstHalf = block.slice(0, elementsPerHalfBlock); let secondHalf = block.slice(elementsPerHalfBlock); return [pack(firstHalf, elementSize), pack(secondHalf, elementSize)]; }); // add length to the first block let firstBlock = blocks[0]; firstBlock[0] = firstBlock[0].add(this.length).seal(); let Fieldx2 = StaticArray(Field, 2); let Blocks = DynamicArray(Fieldx2, { maxLength: maxBlocks }); // nBlocks = ceil(length / elementsPerBlock) = floor((length + elementsPerBlock - 1) / elementsPerBlock) let nBlocks = UInt32.Unsafe.fromField(this.length.add(elementsPerUint32 + elementsPerBlock - 1)).div(elementsPerBlock).value; let dynBlocks = new Blocks(blocks.map(Fieldx2.from), nBlocks); // now hash the 2-field elements blocks, one permutation at a time // note: there's a padding element included at the end in the case of uneven number of blocks // however, this doesn't cause hash collisions because we encoded the length at the beginning let state = Poseidon.initialState(); dynBlocks.forEach((block, isPadding) => { let newState = Poseidon.update(state, block.array); state[0] = Provable.if(isPadding, state[0], newState[0]); state[1] = Provable.if(isPadding, state[1], newState[1]); state[2] = Provable.if(isPadding, state[2], newState[2]); }); return state[0]; } /** * Convert the array to a MerkleList. */ merkelize(listHash) { let type = this.innerType; listHash ??= (h, t) => Poseidon.hash([h, packToField(t, type)]); const List = MerkleList.create(type, listHash); let list = List.empty(); this.forEach((t, isDummy) => { list.pushIf(isDummy.not(), t); }); return list; } /** * Returns a dynamic number of full chunks and a final, smaller chunk. * * If the array is evenly divided into chunks, the final chunk has length 0. * * Note: This method uses very few constraints, it's mostly rearranging the array contents * doing a small amount of math on the lengths, and a single `get()` operation on the chunked array. */ chunk(chunkSize) { let type = ProvableType.get(this.innerType); let maxChunks = Math.floor(this.maxLength / chunkSize); let maxChunksCeil = Math.ceil(this.maxLength / chunkSize); let Chunk = StaticArray(type, chunkSize); let DynamicChunk = DynamicArray(type, { maxLength: chunkSize }); let Chunks = DynamicArray(Chunk, { maxLength: maxChunks }); let NULL = ProvableType.synthesize(type); let padded = pad(this.array, maxChunksCeil * chunkSize, NULL); let completeChunks = padded.slice(0, maxChunks * chunkSize); let chunked = chunk(completeChunks, chunkSize).map(Chunk.from); // nChunks = floor(length / chunkSize) let length = UInt32.Unsafe.fromField(this.length); let { quotient: nChunks, rest: lastChunkLength } = length.divMod(chunkSize); let chunks = new Chunks(chunked, nChunks.value); // last chunk is the chunk at `nChunks`, of the fully padded array let lastChunkPadded = StaticArray(Chunk, maxChunksCeil) .from(chunk(padded, chunkSize)) // this `get()` can only be out of bounds if maxChunksCeil = ceil(maxLength / chunkSize) <= nChunks = floor(length / chunkSize) // which implies length == maxLength == maxChunksCeil * chunkSize // which implies lastChunkLength == 0; in which case we don't care about the actual values in this chunk .getOrUnconstrained(nChunks.value); let lastChunk = new DynamicChunk(lastChunkPadded.array, lastChunkLength.value); return [chunks, lastChunk]; } /** * Assert that the array is exactly equal, in its representation in field elements, to another array. * * Warning: Also checks equality of the padding and maxLength, which don't contribute to the "meaningful" part of the array. * Therefore, this method is mainly intended for testing. */ assertEqualsStrict(other) { assert(this.maxLength === other.maxLength, 'max length mismatch'); this.length.assertEquals(other.length, 'length mismatch'); zip(this.array, other.array).forEach(([a, b]) => { Provable.assertEqual(this.innerType, a, b); }); } /** * Assert that this array is equal to another. * * Note: This only requires the length and the actual elements to be equal, not the padding or the maxLength. * To check for exact equality, use `assertEqualsStrict()`. */ assertEquals(other) { this.length.assertEquals(other.length, 'length mismatch'); let otherArray = Array.isArray(other) ? other : other.array; let type = ProvableType.get(this.innerType); let NULL = ProvableType.synthesize(type); this.forEach((t, isDummy, i) => { let s = type.fromValue(otherArray[i] ?? NULL); Provable.assertEqualIf(isDummy.not(), type, t, s); }); } /** * Concatenate two arrays. * * The resulting (max)length is the sum of the two individual (max)lengths. * * **Warning**: This method takes effort proportional to (M + N)*N where M, N are the two maxlengths. * It's only recommended to use if at least one of the arrays is small. */ concat(other) { // witness combined array let CombinedArray = DynamicArray(this.innerType, { maxLength: this.maxLength + other.maxLength, }); let combinedArray = Provable.witness(CombinedArray, () => this.array .slice(0, Number(this.length)) .concat(other.array.slice(0, Number(other.length)))); // length has to be the sum of the lengths this.length.add(other.length).assertEquals(combinedArray.length); // combined array has to contain the first array, starting from the beginning this.forEach((t, isDummy, i) => { let s = combinedArray.array[i]; Provable.assertEqualIf(isDummy.not(), this.innerType, t, s); }); // combined array has to contain the second array, starting from the end of the first array other.forEach((t, isDummy, i) => { let j = this.length.add(i); // this is guaranteed to be within bounds, if isDummy is false let s = combinedArray.getOrUnconstrained(j); Provable.assertEqualIf(isDummy.not(), other.innerType, t, s); }); // we don't care what else is in the combined array! return combinedArray; } /** * Concatenate two arrays. * * Alternative to `concat()` that takes effort proportional to (M + N)*M where M, N are the two maxlengths, * and also has a better constant. * * Note: This is better than `concat()` if the arrays are about equal or the first array is smaller. * It's worse is the first array is much larger than the second. */ concatTransposed(other) { // construct 2D array of all possible combinations depending on a's length // [b0, b1, b2, ... ], // [a0, b0, b1, ... ], // [a0, a1, b0, ... ], etc let a = this.array; let b = other.array; let NULL = ProvableType.synthesize(this.innerType); let Column = StaticArray(this.innerType, this.maxLength + 1); let maxLength = this.maxLength + other.maxLength; let array2D = Array.from({ length: this.maxLength + 1 }, (_, i) => pad(a.slice(0, i).concat(b), maxLength, NULL)); let arrayTransposed = Array.from({ length: maxLength }, (_, j) => Column.from(array2D.map((row) => row[j]))); let array = arrayTransposed.map((a) => a.getOrUnconstrained(this.length)); let length = this.length.add(other.length).seal(); let CombinedArray = DynamicArray(this.innerType, { maxLength }); return new CombinedArray(array, length); } /** * Concatenate two arrays. * * Alternative to `concat()` that proves correctness of the concatenated array * by Poseidon-hashing it element by element. In contrast to `concat()`, the effort is linear in N + M, * but with a larger constant. * * The resulting (max)length is the sum of the two individual (max)lengths. */ concatByHashing(other) { // witness combined array let type = ProvableType.get(this.innerType); let CombinedArray = DynamicArray(type, { maxLength: this.maxLength + other.maxLength, }); let combinedArray = Provable.witness(CombinedArray, () => this.array .slice(0, Number(this.length)) .concat(other.array.slice(0, Number(other.length)))); // hash the combined array, element by element let hash = Field(0); combinedArray.forEach((t, isDummy) => { let newHash = Poseidon.hash([hash, packToField(t, type)]); hash = Provable.if(isDummy, hash, newHash); }); // hash the first array and then the second array, element by element let hash1 = Field(0); this.forEach((t, isDummy) => { let newHash = Poseidon.hash([hash1, packToField(t, type)]); hash1 = Provable.if(isDummy, hash1, newHash); }); other.forEach((t, isDummy) => { let newHash = Poseidon.hash([hash1, packToField(t, type)]); hash1 = Provable.if(isDummy, hash1, newHash); }); // the two hashes must be equal hash.assertEquals(hash1); // lengths must be equal as well (this was implicitly proved by the hash as well) combinedArray.length.assertEquals(this.length.add(other.length)); return combinedArray; } /** * Push a value, without changing the maxLength. * * Proves that the new length is still within the maxLength, fails otherwise. * * To grow the maxLength along with the actual length, you can use: * * ```ts * array = array.growMaxLengthBy(1); * array.push(value); * ``` * * Cost: 1.5(T + 1)N + 2 */ push(value) { let oldLength = this.length; this.length = oldLength.add(1).seal(); assertInRange16(this.length, this.maxLength); this.setOrDoNothing(oldLength, value); } /** * Return a version of the same array with a larger maxLength. * * **Warning**: Does not modify the array, but returns a new one. * * **Note**: this doesn't cost constraints, but currently doesn't preserve any cached constraints. */ growMaxLengthTo(maxLength) { assert(maxLength >= this.maxLength, 'new maxLength must be greater or equal'); let NewArray = DynamicArray(this.innerType, { maxLength }); let NULL = ProvableType.synthesize(this.innerType); let array = pad(this.array, maxLength, NULL); let length = this.length; return new NewArray(array, length); } /** * Return a version of the same array with a larger maxLength. * * **Warning**: Does not modify the array, but returns a new one. * * **Note**: this doesn't cost constraints, but currently doesn't preserve any cached constraints. */ growMaxLengthBy(maxLength) { return this.growMaxLengthTo(this.maxLength + maxLength); } /** * Mutate this array such that all elements beyond the actual length are set to an empty value. */ normalize() { let NULL = ProvableType.synthesize(this.innerType); this.forEach((t, isPadding, i) => { this.array[i] = Provable.if(isPadding, this.innerType, NULL, t); }); } /** * Assert that the array is normalized, i.e. all padding elements are empty. * * Note: For completeness, it is probably better to use `normalize()` which uses the same amount * of constraints and comes with the same guarantee. */ assertNormalized() { let NULL = ProvableType.synthesize(this.innerType); this.forEach((t, isPadding) => { // TODO this needs a message argument! Provable.assertEqualIf(isPadding, this.innerType, t, NULL); }); } // cached variables to not duplicate constraints if we do something like array.get(i), array.set(i, ..) on the same index _indexMasks = new Map(); _indicesInRange = new Set(); __dummyMask; /** * Compute i.equals(j) for all indices j in the static-size array. * * Costs: 1.5N * * TODO: equals() could be optimized to just 1 double generic because j is constant, o1js doesn't do that */ _indexMask(i) { let mask = this._indexMasks.get(i); mask ??= this.array.map((_, j) => i.equals(j)); this._indexMasks.set(i, mask); return mask; } /** * Tells us which elements are dummies = not actually in the array. * * 0 0 0 1 1 1 1 * ^ * length */ _dummyMask() { if (this.__dummyMask !== undefined) return this.__dummyMask; let isLength = this._indexMask(this.length); let wasLength = Bool(false); let mask = isLength.map((isLength) => { wasLength = wasLength.or(isLength); return wasLength; }); this.__dummyMask = mask; return mask; } /** * Returns true if the index is a dummy index, * i.e. not actually in the array. */ isDummyIndex(i) { return this._dummyMask()[i]; } toValue() { assertHasProperty(this.constructor, 'provable', 'Need subclass'); return this.constructor.provable.toValue(this); } /** * Assert that this array contains the given subarray, and returns the index where it starts. */ assertContains(subarray, message) { let type = this.innerType; assert(subarray.maxLength <= this.maxLength, 'subarray must be smaller'); // idea: witness an index i and show that the subarray is contained at i let i = Provable.witness(Field, () => { let length = Number(this.length); let sublength = Number(subarray.length); if (sublength === 0) return 0n; for (let i = 0; i < length; i++) { // check if subarray is contained at i let isContained = true; for (let j = 0; j < sublength; j++) { if (i + j >= length) return -1n; isContained &&= Provable.equal(type, this.array[i + j], subarray.array[j]).toBoolean(); } if (isContained) return BigInt(i); } return -1n; }); // explicit constraint for !== -1, just to get a nice error message // TODO: would be better to have error message in `Gadgets.rangeCheck16()` i.assertNotEquals(-1, message ?? 'Array does not contain subarray'); // i + subarray.length - 1 < this.length Gadgets.rangeCheck16(i); this.assertIndexInRange(UInt32.Unsafe.fromField(i.add(subarray.length).sub(1))); // assert that subarray is contained at i // cost: M*(N*T + O(1)) let j = 0; if (subarray instanceof DynamicArrayBase) { subarray.forEach((si, isDummy) => { let ai = this.getOrUnconstrained(i.add(j)); Provable.assertEqualIf(isDummy.not(), type, si, ai); j++; }); } else { subarray.forEach((si) => { let ai = this.getOrUnconstrained(i.add(j)); Provable.assertEqual(type, si, ai); j++; }); } return i; } } /** * Base class of all DynamicArray subclasses */ DynamicArray.Base = DynamicArrayBase; function provable(type, Class) { let maxLength = Class.maxLength; let NULL = ProvableType.synthesize(type); return (TypeBuilder.shape({ array: Provable.Array(type, maxLength), length: Field, }) .forConstructor((t) => new Class(t.array, t.length)) // check has to validate length in addition to the other checks .withAdditionalCheck(({ length }) => { assertInRange16(length, maxLength); }) // convert to/from plain array that has the _actual_ length .mapValue({ there({ array, length }) { return array.slice(0, Number(length)); }, backAndDistinguish(array) { // gracefully handle different maxLength if (array instanceof DynamicArrayBase) { if (array.maxLength === maxLength) return array; array = array.toValue(); } // fully convert back so that we can pad with NULL let converted = array.map((x) => type.fromValue(x)); let padded = pad(converted, maxLength, NULL); return new Class(padded, Field(array.length)); }, }) // custom hash input .hashInput((array) => { return { fields: [array.hash()] }; })); } // serialize/deserialize ProvableFactory.register('DynamicArray', DynamicArray, { typeSchema: z.object({ maxLength: z.number(), innerType: SerializedTypeSchema, }), valueSchema: z.array(SerializedValueSchema), typeToJSON(constructor) { return { maxLength: constructor.maxLength, innerType: serializeProvableType(constructor.prototype.innerType), }; }, typeFromJSON(json) { let innerType = deserializeProvableType(json.innerType); return DynamicArray(innerType, { maxLength: json.maxLength, }); }, valueToJSON(_, { array, length }) { return array.slice(0, Number(length)).map((v) => serializeProvable(v)); }, valueFromJSON(type, value) { let array = value.map((v) => deserializeProvable(v)); return type.from(array); }, }); //# sourceMappingURL=dynamic-array.js.map