o1js
Version:
TypeScript framework for zk-SNARKs and zkApps
190 lines (178 loc) • 8.49 kB
text/typescript
/**
* This module defines the `RuntimeTable` class, which represents a provable table whose entries
* can be defined at runtime within the SNARK circuit. It allows inserting key-value pairs and
* checking for their existence.
*/
import { assert } from '../../util/assert.js';
import { Field } from "../field.js";
import { Gates } from "../gates.js";
export {
RuntimeTable,
};
/**
* # RuntimeTable
*
* A **provable lookup table** whose entries are defined at runtime (during circuit construction).
* It constrains that certain `(index, value)` pairs *exist* in a table identified by `id`, using
* efficient **lookup gates** under the hood. Each inner lookup gate can batch up to **3 pairs**.
*
* ## When to use
* - **small/medium, runtime-chosen set** of `(index, value)` pairs and want to prove
* **membership** of queried pairs in that set.
* - **ergonomic batching**: repeated `lookup()` calls automatically group into 3-tuples
* so it creates pay fewer gates when possible (instead of writing repetitive `Gates.lookup(...)`
* calls and manually handling batching of lookup entries).
* - **expressiveness**: all runtime tables will be condensed into one long table under the hood,
* so it is highly recommended to use distinct `id`s for unrelated tables to achieve better
* separation of concerns and avoid accidental collisions, at no extra cost.
*
* ## When *not* to use
* - **static and global tables**: Prefer built-ins for fixed-tables that already exist in the system.
* (a.k.a. standard 4-bit XOR or 12-bit length range-check tables).
* - **hiding properties**: lookup tables **constrain membership**, but don’t provide secrecy
* of the values by themselves. If data privacy is needed, consider using the **witness** to hold
* the values and protect from exposure to the verifier.
* - **huge tables**: runtime lookups are efficient for a limited amount of entries, but their
* size is limited by the underlying circuit size (i.e. 2^16). Applications needing more storage
* should consider an optimized custom solution.
* - **mutable data**: runtime tables are write-once only, so once inserted entries in table are
* remain fixed. To represent changing data, consider using DynamicArrays.
* - **unknown bounded size**: runtime lookup tables require all possible `indices` to be preallocated
* at construction time. If the set of possible indices is not known in advance, consider using
* DynamicArrays instead.
*
* ## Invariants & constraints
* - `id !== 0 && id !== 1`. (Reserved for XOR and range-check tables.)
* - `indices` are **unique**. Duplicates are rejected.
* - `indices` must be **known** at construction time.
* - `lookup()` **batches** each 3 calls (for the same table) into **one** gate automatically.
* - `check()` call is required for soundness to flush 1–2 pending pairs before the end of the circuit.
*
* ## Complexity
* - Gate cost for membership checks is ~`ceil(#pairs / 3)` lookup gates per table id,
* plus one lookup gate per `insert()` triplet.
*
* ## Example
* ```ts
* // Define a runtime table with id=5 and allowed indices {10n, 20n, 30n}
* const rt = new RuntimeTable(5, [10n, 20n, 30n]);
*
* // Populate some pairs (you can insert in chunks of up to 3)
* rt.insert([
* [10n, Field.from(123)],
* [20n, Field.from(456)],
* [30n, Field.from(789)],
* ]);
*
* // Constrain that these pairs exist in the table
* rt.lookup(10n, Field.from(123));
* rt.lookup(20n, Field.from(456));
* // These two calls will be grouped; add a third, or call check() to flush
* rt.check(); // flush pending lookups (important!)
* ```
*
* ## Gotchas
* - **Don’t forget `check()`**: If you finish a proof block with 1–2 pending `lookup()` calls,
* call `check()` to emit the final lookup gate. Otherwise those constraints won’t land.
* - **Index validation**: `insert()` and `lookup()` throw if the index isn’t whitelisted in `indices`.
* - **ID collisions**: Pick distinct `id`s for unrelated runtime tables.
* - **flag settings**: zkApps with runtime tables must be compiled with the `withRuntimeTables` flag.
*
* @remarks
* Construction registers the table configuration via `Gates.addRuntimeTableConfig(id, indices)`.
* Subsequent `insert()`/`lookup()` use that configuration to emit lookup gates. Please refrain from
* using that function directly, as it will be deprecated in the future.
*
* @see Gates.lookup
* @see Gates.addRuntimeTableConfig
* @see Gadgets.inTable
* @see DynamicArray for a mutable alternative to store runtime data.
* @public
*/
class RuntimeTable {
/**
* Unique identifier for the runtime table.
* Must be different than 0 and 1, as those values are reserved
* for the XOR and range-check tables, respectively.
*/
readonly id: number;
/**
* Indices that define the structure of the runtime table.
* They can be consecutive or not, but they must be unique.
*/
readonly indices: Set<bigint>;
/**
* Pending pairs to be checked on the runtime table.
*/
pairs: Array<[bigint, Field]> = [];
constructor(id: number, indices: bigint[]) {
// check that id is not 0 or 1, as those are reserved values
assert(id !== 0 && id !== 1, "Runtime table id must be different than 0 and 1");
// check that all the indices are unique
let uniqueIndices = new Set(indices);
assert(uniqueIndices.size === indices.length, "Runtime table indices must be unique");
// initialize the runtime table
this.id = id;
this.indices = uniqueIndices;
Gates.addRuntimeTableConfig(id, indices);
}
/**
* Inserts key-value pairs into the runtime table.
* Under the hood, this method uses the `Gates.lookup` function to perform
* lookups to the table with identifier `this.id`. One single lookup gate
* can store up to 3 different pairs of index and value.
*
* It throws when trying to insert a pair with an index that is not part of
* the runtime table.
*
* @param pairs Array of pairs [index, value] to insert into the runtime table.
*/
insert(pairs: [bigint, Field][]) {
for (let i = 0; i < pairs.length; i += 3) {
const chunk = pairs.slice(i, i + 3);
const [idx0, value0] = chunk[0];
const [idx1, value1] = chunk[1] || [idx0, value0];
const [idx2, value2] = chunk[2] || [idx0, value0];
assert(this.indices.has(idx0) && this.indices.has(idx1) && this.indices.has(idx2),
`Indices must be part of the runtime table with id ${this.id}`);
Gates.lookup(Field.from(this.id), Field.from(idx0), value0, Field.from(idx1), value1, Field.from(idx2), value2);
}
}
/**
* In-circuit checks if a key-value pair exists in the runtime table. Note
* that the same index can be queried several times as long as the value
* remains the same.
*
* Every three calls to this method for the same identifier will be grouped
* into a single lookup gate for efficiency.
*
* @param idx The index of the key to check.
* @param value The value to check.
*/
lookup(idx: bigint, value: Field) {
if (this.pairs.length == 2) {
let [idx0, value0] = this.pairs[0];
let [idx1, value1] = this.pairs[1];
Gates.lookup(Field.from(this.id), Field.from(idx0), value0, Field.from(idx1), value1, Field.from(idx), value);
this.pairs = [];
} else {
this.pairs.push([idx, value]);
}
}
/**
* Finalizes any pending checks by creating a Lookup when necessary.
* This function must be called after all `lookup()` calls of the table
* to ensure that all pending checks are looked up in the circuit.
*/
check() {
// If there are any pending checks, perform one lookup with them.
// Because the lookup gate takes 3 pairs, we add redundancy if needed.
if (this.pairs.length > 0) {
let [idx0, value0] = this.pairs[0];
let [idx1, value1] = this.pairs.length > 1 ? this.pairs[1] : [idx0, value0];
let [idx2, value2] = this.pairs.length > 2 ? this.pairs[2] : [idx0, value0];
Gates.lookup(Field.from(this.id), Field.from(idx0), value0, Field.from(idx1), value1, Field.from(idx2), value2);
this.pairs = [];
}
}
}