micro-eth-signer
Version:
Minimal library for Ethereum transactions, addresses and smart contracts
609 lines (593 loc) • 25.4 kB
text/typescript
// prettier-ignore
import {
type PolyFn, type Polynomial,
bitReversalPermutation, FFT, log2, poly, reverseBits, rootsOfUnity,
} from '@noble/curves/abstract/fft';
import { bytesToNumberBE, numberToBytesBE } from '@noble/curves/abstract/utils';
import { bls12_381 as bls } from '@noble/curves/bls12-381';
import { sha256 } from '@noble/hashes/sha2';
import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
import { add0x, hexToNumber, strip0x } from './utils.ts';
/*
KZG for [EIP-4844](https://eips.ethereum.org/EIPS/eip-4844).
Docs:
- https://github.com/ethereum/c-kzg-4844
- https://github.com/ethereum/consensus-specs/blob/dev/specs/deneb/polynomial-commitments.md
TODO(high-level):
- data converted into blob by prepending 0x00 prefix on each chunk and ends with 0x80 terminator
- Unsure how generic is this
- There are up to 6 blob per tx
- Terminator only added to the last blob
- sidecar: {blob, commitment, proof}
- Calculate versionedHash from commitment, which is included inside of tx
- if 'sidecars' inside of tx enabled:
- envelope turns into 'wrapper'
- rlp([tx, blobs, commitments, proofs])
- this means there are two eip4844 txs: with sidecars and without
*/
const { Fr, Fp12 } = bls.fields;
const G1 = bls.G1.ProjectivePoint;
const G2 = bls.G2.ProjectivePoint;
type G1Point = typeof bls.G1.ProjectivePoint.BASE;
type G2Point = typeof bls.G2.ProjectivePoint.BASE;
type Scalar = string | bigint;
type Blob = string | string[] | bigint[];
const BLOB_REGEX = /.{1,64}/g; // TODO: is this valid?
function parseScalar(s: Scalar): bigint {
if (typeof s === 'string') {
s = strip0x(s);
if (s.length !== 2 * Fr.BYTES) throw new Error('parseScalar: wrong format');
s = BigInt(`0x${s}`);
}
if (!Fr.isValid(s)) throw new Error('parseScalar: invalid field element');
return s;
}
function formatScalar(n: bigint) {
return add0x(bytesToHex(numberToBytesBE(n, Fr.BYTES)));
}
function pairingVerify(a1: G1Point, a2: G2Point, b1: G1Point, b2: G2Point) {
// Filter-out points at infinity, because pairingBatch will throw an error
const pairs = [
{ g1: a1.negate(), g2: a2 },
{ g1: b1, g2: b2 },
].filter(({ g1, g2 }) => !G1.ZERO.equals(g1) && !G2.ZERO.equals(g2));
const f = bls.pairingBatch(pairs, true);
return Fp12.eql(f, Fp12.ONE);
}
function chunks<T>(arr: T[], len: number): T[][] {
if (len <= 0) throw new Error('chunks: chunkSize must be > 0');
const res: T[][] = [];
for (let i = 0; i < arr.length; i += len) res.push(arr.slice(i, i + len));
return res;
}
function chunkBytes(u8a: Uint8Array, len: number): Uint8Array[] {
if (len <= 0) throw new Error('chunkBytes: chunk size must be > 0');
const res: Uint8Array[] = [];
for (let i = 0; i < u8a.length; i += len) res.push(u8a.subarray(i, i + len));
return res;
}
function strideExtend(src: bigint[], stride: number, outLen: number): bigint[] {
const dst = new Array<bigint>(outLen).fill(Fr.ZERO);
for (let i = 0, pos = 0; i < src.length && pos < outLen; i++, pos += stride) dst[pos] = src[i];
return dst;
}
// Official JSON format
export type SetupData = {
g1_lagrange: string[];
g2_monomial: string[];
g1_monomial?: string[]; // Optional, for PeerDAS only!
fk20?: string[];
};
// PEERDAS Constants
const FE_PER_EXT_BLOB = 8192;
const FE_PER_BLOB = 4096;
const FE_PER_CELL = 64;
const CELLS_PER_BLOB = FE_PER_BLOB / FE_PER_CELL; // 64
const CELLS_PER_EXT_BLOB = FE_PER_EXT_BLOB / FE_PER_CELL; // 128
const BYTES_PER_CELL = 2048;
const CIRCULANT_DOMAIN_SIZE = CELLS_PER_BLOB * 2; // 128
const FK20_STRIDE = FE_PER_EXT_BLOB / CIRCULANT_DOMAIN_SIZE;
// RBL = Reverse Bits Limited table
const CELL_INDICES_RBL: Readonly<number[]> = bitReversalPermutation(
Array.from({ length: 128 }, (_, j) => j)
);
const Cell = {
encode(fields: bigint[]): string {
if (fields.length !== FE_PER_CELL)
throw new Error(`Cell.encode: Expected ${FE_PER_CELL} field elements`);
return add0x(bytesToHex(concatBytes(...fields.map(Fr.toBytes))));
},
decode(hex: string): bigint[] {
const bytes = hexToBytes(strip0x(hex));
if (bytes.length !== BYTES_PER_CELL)
throw new Error(`Cell.decode: Expected ${BYTES_PER_CELL} bytes after decoding hex`);
const fields = chunkBytes(bytes, Fr.BYTES).map(Fr.fromBytes);
for (const f of fields) if (!Fr.isValid(f)) throw new Error('invalid fr');
return fields;
},
};
/**
* KZG from [EIP-4844](https://eips.ethereum.org/EIPS/eip-4844).
* @example
* const kzg = new KZG(trustedSetupData);
*/
export class KZG {
private readonly POLY_NUM: number;
private readonly G1LB: G1Point[]; // lagrange brp
private readonly G2M: G2Point[];
private readonly G1M?: G1Point[];
private readonly ROOTS_OF_UNITY_BRP: bigint[];
private readonly ROOTS_CACHE: ReturnType<typeof rootsOfUnity>;
private readonly fftFr: ReturnType<typeof FFT<bigint>>;
private readonly fftG1: ReturnType<typeof FFT<G1Point>>;
private readonly polyFr: PolyFn<bigint[], bigint>;
// Should they be configurable?
private readonly FIAT_SHAMIR_PROTOCOL_DOMAIN = utf8ToBytes('FSBLOBVERIFY_V1_');
private readonly RANDOM_CHALLENGE_KZG_BATCH_DOMAIN = utf8ToBytes('RCKZGBATCH___V1_');
private readonly POLY_NUM_BYTES: Uint8Array;
// PeerDAS
private fk20Columns?: G1Point[][];
constructor(setup: SetupData & { encoding?: 'fast_v1' }) {
if (setup == null || typeof setup !== 'object') throw new Error('expected valid setup data');
if (!Array.isArray(setup.g1_lagrange) || !Array.isArray(setup.g2_monomial))
throw new Error('expected valid setup data');
// The slowest part
let fastSetup = false;
if ('encoding' in setup) {
fastSetup = setup.encoding === 'fast_v1';
if (!fastSetup) throw new Error('unknown encoding ' + setup.encoding);
}
const G1L = setup.g1_lagrange.map(fastSetup ? this.parseG1Unchecked : this.parseG1);
this.POLY_NUM = G1L.length;
this.G2M = setup.g2_monomial.map(fastSetup ? this.parseG2Unchecked : this.parseG2);
this.G1LB = bitReversalPermutation(G1L);
this.ROOTS_CACHE = rootsOfUnity(Fr, 7n);
this.ROOTS_OF_UNITY_BRP = this.ROOTS_CACHE.brp(log2(this.POLY_NUM));
this.fftFr = FFT(this.ROOTS_CACHE, Fr);
this.fftG1 = FFT<G1Point>(rootsOfUnity(Fr, 7n), {
add: (a, b) => a.add(b),
sub: (a, b) => a.subtract(b),
mul: (a, scalar) => a.multiplyUnsafe(scalar),
inv: Fr.inv,
});
this.polyFr = poly(Fr, this.ROOTS_CACHE);
this.POLY_NUM_BYTES = numberToBytesBE(this.POLY_NUM, 8);
if (setup.g1_monomial) {
this.G1M = setup.g1_monomial.map(fastSetup ? this.parseG1Unchecked : this.parseG1);
}
if (setup.fk20) {
this.fk20Columns = chunks(
setup.fk20.map(fastSetup ? this.parseG1Unchecked : this.parseG1),
FE_PER_CELL
);
}
}
// Internal
private parseG1(p: string | G1Point) {
if (typeof p === 'string') p = G1.fromHex(strip0x(p));
return p;
}
private parseG1Unchecked(p: string) {
if (typeof p !== 'string') throw new Error('string expected');
const [x, y] = p.split(' ').map(hexToNumber);
return G1.fromAffine({ x, y });
}
private parseG2(p: string) {
return G2.fromHex(strip0x(p));
}
private parseG2Unchecked(p: string) {
const xy = strip0x(p)
.split(' ')
.map((c) => c.split(',').map((c) => BigInt('0x' + c))) as unknown as [bigint, bigint][];
const x = bls.fields.Fp2.fromBigTuple(xy[0]);
const y = bls.fields.Fp2.fromBigTuple(xy[1]);
return G2.fromAffine({ x, y });
}
private parseBlob(blob: Blob) {
if (typeof blob === 'string') {
blob = strip0x(blob);
if (blob.length !== this.POLY_NUM * Fr.BYTES * 2) throw new Error('Wrong blob length');
const m = blob.match(BLOB_REGEX);
if (!m) throw new Error('Wrong blob');
blob = m;
}
return blob.map(parseScalar);
}
private invSafe(inverses: bigint[]) {
inverses = Fr.invertBatch(inverses);
for (const i of inverses) if (i === undefined) throw new Error('invSafe: division by zero');
return inverses;
}
private G1msm(points: G1Point[], scalars: bigint[]) {
// Filters zero scalars, non-const time, but improves computeProof up to x93 for empty blobs
const _points = [];
const _scalars = [];
for (let i = 0; i < scalars.length; i++) {
const s = scalars[i];
if (Fr.is0(s)) continue;
_points.push(points[i]);
_scalars.push(s);
}
return G1.msm(_points, _scalars);
}
private computeChallenge(blob: bigint[], commitment: G1Point): bigint {
const h = sha256
.create()
.update(this.FIAT_SHAMIR_PROTOCOL_DOMAIN)
.update(numberToBytesBE(0, 8))
.update(this.POLY_NUM_BYTES);
for (const b of blob) h.update(numberToBytesBE(b, Fr.BYTES));
h.update(commitment.toRawBytes(true));
const res = Fr.create(bytesToNumberBE(h.digest()));
h.destroy();
return res;
}
private evalPoly(poly: bigint[], x: bigint) {
return this.polyFr.lagrange.eval(poly, x, true);
}
// Basic
computeProof(blob: Blob, z: bigint | string): [string, string] {
z = parseScalar(z);
blob = this.parseBlob(blob);
const y = this.evalPoly(blob, z);
const batch = [];
let rootOfUnityPos: undefined | number;
const poly = new Array(this.POLY_NUM).fill(Fr.ZERO);
for (let i = 0; i < this.POLY_NUM; i++) {
if (Fr.eql(z, this.ROOTS_OF_UNITY_BRP[i])) {
rootOfUnityPos = i;
batch.push(Fr.ONE);
continue;
}
poly[i] = Fr.sub(blob[i], y);
batch.push(Fr.sub(this.ROOTS_OF_UNITY_BRP[i], z));
}
const inverses = this.invSafe(batch);
for (let i = 0; i < this.POLY_NUM; i++) poly[i] = Fr.mul(poly[i], inverses[i]);
if (rootOfUnityPos !== undefined) {
poly[rootOfUnityPos] = Fr.ZERO;
for (let i = 0; i < this.POLY_NUM; i++) {
if (i === rootOfUnityPos) continue;
batch[i] = Fr.mul(Fr.sub(z, this.ROOTS_OF_UNITY_BRP[i]), z);
}
const inverses = this.invSafe(batch);
for (let i = 0; i < this.POLY_NUM; i++) {
if (i === rootOfUnityPos) continue;
poly[rootOfUnityPos] = Fr.add(
poly[rootOfUnityPos],
Fr.mul(Fr.mul(Fr.sub(blob[i], y), this.ROOTS_OF_UNITY_BRP[i]), inverses[i])
);
}
}
const proof = add0x(this.G1msm(this.G1LB, poly).toHex(true));
return [proof, formatScalar(y)];
}
verifyProof(commitment: string, z: Scalar, y: Scalar, proof: string): boolean {
try {
z = parseScalar(z);
y = parseScalar(y);
const g2x = Fr.is0(z) ? G2.ZERO : G2.BASE.multiply(z);
const g1y = Fr.is0(y) ? G1.ZERO : G1.BASE.multiply(y);
const XminusZ = this.G2M[1].subtract(g2x);
const PminusY = this.parseG1(commitment).subtract(g1y);
return pairingVerify(PminusY, G2.BASE, this.parseG1(proof), XminusZ);
} catch (e) {
return false;
}
}
private getRPowers(r: bigint, n: number) {
const rPowers = [];
if (n !== 0) {
rPowers.push(Fr.ONE);
for (let i = 1; i < n; i++) rPowers[i] = Fr.mul(rPowers[i - 1], r);
}
return rPowers;
}
// There are no test vectors for this
private verifyProofBatch(commitments: G1Point[], zs: bigint[], ys: bigint[], proofs: string[]) {
const n = commitments.length;
const p: G1Point[] = proofs.map((i) => this.parseG1(i));
const h = sha256
.create()
.update(this.RANDOM_CHALLENGE_KZG_BATCH_DOMAIN)
.update(this.POLY_NUM_BYTES)
.update(numberToBytesBE(n, 8));
for (let i = 0; i < n; i++) {
h.update(commitments[i].toRawBytes(true));
h.update(Fr.toBytes(zs[i]));
h.update(Fr.toBytes(ys[i]));
h.update(p[i].toRawBytes(true));
}
const r = Fr.create(bytesToNumberBE(h.digest()));
h.destroy();
const rPowers = this.getRPowers(r, n);
const proofPowers = this.G1msm(p, rPowers);
const CminusY = commitments.map((c, i) =>
c.subtract(Fr.is0(ys[i]) ? G1.ZERO : G1.BASE.multiply(ys[i]))
);
const RtimesZ = rPowers.map((p, i) => Fr.mul(p, zs[i]));
const rhs = this.G1msm(p.concat(CminusY), RtimesZ.concat(rPowers));
return pairingVerify(proofPowers, this.G2M[1], rhs, G2.BASE);
}
// Blobs
blobToKzgCommitment(blob: Blob): string {
return add0x(this.G1msm(this.G1LB, this.parseBlob(blob)).toHex(true));
}
computeBlobProof(blob: Blob, commitment: string): string {
blob = this.parseBlob(blob);
const challenge = this.computeChallenge(blob, this.parseG1(commitment));
const [proof, _] = this.computeProof(blob, challenge);
return proof;
}
verifyBlobProof(blob: Blob, commitment: string, proof: string): boolean {
try {
blob = this.parseBlob(blob);
const c = this.parseG1(commitment);
const challenge = this.computeChallenge(blob, c);
const y = this.evalPoly(blob, challenge);
return this.verifyProof(commitment, challenge, y, proof);
} catch (e) {
return false;
}
}
verifyBlobProofBatch(blobs: string[], commitments: string[], proofs: string[]): boolean {
if (!Array.isArray(blobs) || !Array.isArray(commitments) || !Array.isArray(proofs))
throw new Error('invalid arguments');
if (blobs.length !== commitments.length || blobs.length !== proofs.length) return false;
if (blobs.length === 1) return this.verifyBlobProof(blobs[0], commitments[0], proofs[0]);
try {
const b = blobs.map((i) => this.parseBlob(i));
const c = commitments.map(this.parseG1);
const challenges = b.map((b, i) => this.computeChallenge(b, c[i]));
const ys = b.map((_, i) => this.evalPoly(b[i], challenges[i]));
return this.verifyProofBatch(c, challenges, ys, proofs);
} catch (e) {
return false;
}
}
// PeerDAS (https://eips.ethereum.org/EIPS/eip-7594)
private Fk20Precomputes = (): G1Point[][] => {
if (!this.G1M) throw new Error('PeerDAS requires full kzg setup (with G1 monomial)');
if (this.fk20Columns) return this.fk20Columns;
// This is very slow and takes 38s on first run!
const columns: G1Point[][] = Array.from(
{ length: CIRCULANT_DOMAIN_SIZE },
() => new Array(FE_PER_CELL)
);
const G1Mrev_chunks = chunks(Array.from(this.G1M).reverse(), FE_PER_CELL);
const xExt = new Array<G1Point>(CIRCULANT_DOMAIN_SIZE).fill(G1.ZERO);
for (let offset = 0; offset < FE_PER_CELL; offset++) {
for (let i = 0; i < CELLS_PER_BLOB - 1; i++) xExt[i] = G1Mrev_chunks[i + 1][offset];
// FFT call is 600ms here, while in Rust it's 45ms. Issue is mostly with Point#multiply:
// Rust
// RUST mul=47951 add=2 sub=3 (microseconds)
// JS: mul=597947 add=2613 sub=2653
// It could be optimized by copying optimizations from C:
// - GLV endomorphism
// - Windowed booth encode multiplication (wnaf-like stuff)
const res = this.fftG1.direct(xExt);
for (let row = 0; row < CIRCULANT_DOMAIN_SIZE; row++) columns[row][offset] = res[row];
}
this.fk20Columns = columns;
return columns;
};
private Fk20Proof = (poly: Polynomial<bigint>): string[] => {
const precomputes = this.Fk20Precomputes(); // 128x64
if (poly.length !== FE_PER_BLOB) throw new Error('Fk20Proof: wrong poly');
const coeffs: bigint[][] = Array.from({ length: CIRCULANT_DOMAIN_SIZE }, () =>
new Array(FE_PER_CELL).fill(Fr.ZERO)
);
for (let i = 0; i < FE_PER_CELL; i++) {
const toeplitz = new Array<bigint>(CIRCULANT_DOMAIN_SIZE).fill(Fr.ZERO);
toeplitz[0] = poly[FE_PER_BLOB - 1 - i];
for (let j = 0; j < CELLS_PER_EXT_BLOB - CELLS_PER_BLOB - 2; j++) {
toeplitz[CELLS_PER_BLOB + 2 + j] = poly[CELLS_PER_EXT_BLOB - i - 1 + j * FE_PER_CELL];
}
const res = this.fftFr.direct(toeplitz);
for (let j = 0; j < CIRCULANT_DOMAIN_SIZE; j++) coeffs[j][i] = res[j];
}
const hExtFFT = [];
for (let i = 0; i < CIRCULANT_DOMAIN_SIZE; i++)
hExtFFT.push(this.G1msm(precomputes[i], coeffs[i]));
const h = this.fftG1.inverse(hExtFFT);
for (let i = CELLS_PER_BLOB; i < CIRCULANT_DOMAIN_SIZE; i++) h[i] = G1.ZERO;
return this.fftG1.direct(h, false, true).map((p) => add0x(p.toHex(true)));
};
private getCells(blob: string) {
if (!this.G1M) throw new Error('PeerDAS requires full kzg setup (with G1 monomial)');
// Convert compact poly into extended
const blobParsed = this.parseBlob(blob);
const polyMShort = this.fftFr.inverse(blobParsed, true);
const extendedEvalBRP = this.fftFr.direct(
strideExtend(polyMShort, 1, FE_PER_EXT_BLOB),
false,
true
);
const cells = chunks(extendedEvalBRP, FE_PER_CELL).map(Cell.encode);
return { cells, polyMShort };
}
computeCells(blob: string): string[] {
return this.getCells(blob).cells;
}
computeCellsAndProofs(blob: string): [string[], string[]] {
const { cells, polyMShort } = this.getCells(blob);
const proofs = this.Fk20Proof(polyMShort);
return [cells, proofs];
}
private recoverCell(indices: number[], recoveredCellsNulls: (bigint | null)[]) {
const PEERDAS_RECOVERY_SHIFT = 7n;
const PEERDAS_RECOVERY_SHIFT_INV = Fr.inv(7n);
const cellsBRP: (bigint | null)[] = new Array(FE_PER_EXT_BLOB);
for (let i = 0; i < FE_PER_EXT_BLOB; i++)
cellsBRP[reverseBits(i, log2(FE_PER_EXT_BLOB))] = recoveredCellsNulls[i];
const cellsBRPFull = bitReversalPermutation(recoveredCellsNulls);
const missingIndicesBRP: number[] = [];
const indicesSet = new Set(indices);
for (let i = 0; i < CELLS_PER_EXT_BLOB; i++)
if (!indicesSet.has(i)) missingIndicesBRP.push(reverseBits(i, log2(CELLS_PER_EXT_BLOB)));
if (missingIndicesBRP.length === 0 || missingIndicesBRP.length >= CELLS_PER_EXT_BLOB)
throw new Error('Invalid number of missing cells for vanishing polynomial');
const roots = [];
const extRoots = this.ROOTS_CACHE.roots(log2(FE_PER_EXT_BLOB));
for (let i = 0; i < missingIndicesBRP.length; i++)
roots.push(extRoots[missingIndicesBRP[i] * FK20_STRIDE]);
const shortVanishing = this.polyFr.vanishing(roots);
const vanishing = strideExtend(shortVanishing, FE_PER_CELL, FE_PER_EXT_BLOB);
const vanishingEval = this.fftFr.direct(vanishing);
const extendedEvalZero = [];
for (let i = 0; i < FE_PER_EXT_BLOB; i++) {
const v = cellsBRPFull[i];
extendedEvalZero.push(v === null ? Fr.ZERO : Fr.mul(v, vanishingEval[i]));
}
const extendedCoset = this.fftFr.direct(
this.polyFr.shift(this.fftFr.inverse(extendedEvalZero), PEERDAS_RECOVERY_SHIFT)
);
const vanishingShifted = this.polyFr.shift(vanishing, PEERDAS_RECOVERY_SHIFT);
const vanishingCoset = this.fftFr.direct(vanishingShifted);
const reconstructedCoset = [];
for (let i = 0; i < FE_PER_EXT_BLOB; i++)
reconstructedCoset.push(Fr.div(extendedCoset[i], vanishingCoset[i]));
return this.fftFr.direct(
this.polyFr.shift(this.fftFr.inverse(reconstructedCoset), PEERDAS_RECOVERY_SHIFT_INV),
false,
true
);
}
recoverCellsAndProofs(indices: number[], cells: string[]): [string[], string[]] {
if (cells.length !== indices.length)
throw new Error('Indices and cells array lengths mismatch');
if (indices.length > CELLS_PER_EXT_BLOB)
throw new Error(`Too many cells provided (${indices.length} > ${CELLS_PER_EXT_BLOB})`);
if (indices.length < CELLS_PER_BLOB)
throw new Error(
`Not enough cells provided (${indices.length} < ${CELLS_PER_BLOB}) for recovery`
);
const uniqueIndices = new Set<number>();
for (const idx of indices) {
if (idx >= CELLS_PER_EXT_BLOB || idx < 0) throw new Error(`Invalid cell index found: ${idx}`);
if (uniqueIndices.has(idx)) throw new Error(`Duplicate cell index found: ${idx}`);
uniqueIndices.add(idx);
}
const recoveredCellsNulls: (bigint | null)[] = new Array(FE_PER_EXT_BLOB).fill(null);
for (let i = 0; i < indices.length; i++) {
const idx = indices[i];
const fields = Cell.decode(cells[i]);
for (let j = 0; j < FE_PER_CELL; j++) recoveredCellsNulls[idx * FE_PER_CELL + j] = fields[j];
}
let recoveredCells: bigint[];
if (indices.length === CELLS_PER_EXT_BLOB) {
// TODO: this should not happen. Need to think about better construction that will enforce this
// perhaps uniqueIndices.size() == indices.length?
if (recoveredCellsNulls.some((f) => f === null))
throw new Error('Internal error: Null found even when all cells provided');
recoveredCells = recoveredCellsNulls as bigint[];
} else {
recoveredCells = this.recoverCell(indices, recoveredCellsNulls);
}
const allCells = chunks(recoveredCells, FE_PER_CELL).map(Cell.encode);
const proofs = this.Fk20Proof(this.fftFr.inverse(recoveredCells, true).slice(0, FE_PER_BLOB));
return [allCells, proofs];
}
verifyCellKzgProofBatch(
commitments: string[],
indices: number[],
cells: string[],
proofs: string[]
): boolean {
if (!this.G1M) throw new Error('PeerDAS requires full kzg setup (with G1 monomial)');
if (
commitments.length !== cells.length ||
indices.length !== cells.length ||
proofs.length !== cells.length
) {
throw new Error('verifyCellKzgProofBatch: input array lengths mismatch');
}
if (cells.length === 0) return true;
for (const idx of indices) {
if (idx >= CELLS_PER_EXT_BLOB)
throw new Error('verifyCellKzgProofBatch: invalid cell index: ' + idx);
}
// Deduplicate commitments (0ms)
const uniqueMap = new Map<string, number>();
const uniqueCommitments: string[] = [];
const commitmentIndicesMap = [];
for (let i = 0; i < commitments.length; i++) {
const commitHex = commitments[i];
if (uniqueMap.has(commitHex)) commitmentIndicesMap.push(uniqueMap.get(commitHex)!);
else {
const newIndex = uniqueCommitments.length;
uniqueMap.set(commitHex, newIndex);
uniqueCommitments.push(commitHex);
commitmentIndicesMap.push(newIndex);
}
}
// Compute challenge r (5ms)
const h = sha256.create();
h.update(utf8ToBytes('RCKZGCBATCH__V1_'));
h.update(numberToBytesBE(FE_PER_CELL, 8)); // uint64
h.update(numberToBytesBE(uniqueCommitments.length, 8)); // uint64
h.update(numberToBytesBE(cells.length, 8)); // uint64
for (const c of uniqueCommitments) h.update(hexToBytes(strip0x(c)));
for (const idx of commitmentIndicesMap) h.update(numberToBytesBE(idx, 8)); // uint64
for (const idx of indices) h.update(numberToBytesBE(idx, 8)); // uint64
for (const c of cells) h.update(hexToBytes(strip0x(c)));
for (const p of proofs) h.update(hexToBytes(strip0x(p)));
const r = Fr.create(bytesToNumberBE(h.digest()));
// Proofs lincomb (175ms)
const rPowers = this.getRPowers(r, cells.length); //
const proofsG1 = proofs.map((hex) => this.parseG1(hex)); // 120ms
const proofLincomb = this.G1msm(proofsG1, rPowers); // 51ms
// Weighted sum of commitments (4ms)
const uniqueCommitmentsG1 = uniqueCommitments.map(this.parseG1);
const weights: bigint[] = new Array(uniqueCommitments.length).fill(Fr.ZERO);
for (let i = 0; i < commitmentIndicesMap.length; i++) {
const idx = commitmentIndicesMap[i];
weights[idx] = Fr.add(weights[idx], rPowers[i]);
}
const CAgg = this.G1msm(uniqueCommitmentsG1, weights);
// Compute commitment to aggregated interpolation polynomial (47 ms)
const columns = Array.from({ length: CELLS_PER_EXT_BLOB }, () =>
new Array(FE_PER_CELL).fill(Fr.ZERO)
);
const usedRows = new Set<number>();
for (let k = 0; k < cells.length; k++) {
const row = indices[k];
usedRows.add(row);
const weight = rPowers[k];
const cell = Cell.decode(cells[k]);
for (let j = 0; j < FE_PER_CELL; j++)
columns[row][j] = Fr.add(columns[row][j], Fr.mul(cell[j], weight));
}
const ROOTS_EXT = this.ROOTS_CACHE.roots(log2(FE_PER_EXT_BLOB));
const aggInterp = new Array(FE_PER_CELL).fill(Fr.ZERO);
for (const i of usedRows) {
const idx = (FE_PER_EXT_BLOB - CELL_INDICES_RBL[i]) % FE_PER_EXT_BLOB;
const cosetR = ROOTS_EXT[idx];
const shifted = this.polyFr.shift(this.fftFr.inverse(columns[i], true), cosetR);
for (let k = 0; k < FE_PER_CELL; k++) aggInterp[k] = Fr.add(aggInterp[k], shifted[k]);
}
const IAgg = this.G1msm(this.G1M.slice(0, FE_PER_CELL), aggInterp);
// Weighted sum of proofs (0ms)
const weightedR = [];
for (let k = 0; k < proofsG1.length; k++) {
const idx = indices[k];
if (idx >= CELLS_PER_EXT_BLOB) throw new Error(`Invalid cell index ${idx}`);
const hkPow = (CELL_INDICES_RBL[idx] * FE_PER_CELL) % FE_PER_EXT_BLOB;
if (hkPow >= ROOTS_EXT.length) throw new Error(`hkPow out of bounds`);
weightedR.push(Fr.mul(rPowers[k], ROOTS_EXT[hkPow]));
}
const PiAgg = this.G1msm(proofsG1, weightedR);
return pairingVerify(
CAgg.add(IAgg.negate()).add(PiAgg), // CAgg - IAgg + PiAgg
G2.BASE,
proofLincomb,
this.G2M[FE_PER_CELL]
);
}
// High-level method
// commitmentToVersionedHash(commitment: Uint8Array) {
// const VERSION = 1; // Currently only 1 version is supported
// // commitment is G1 point in hex?
// return concatBytes(new Uint8Array([VERSION]), sha256(commitment));
// }
}