Follow-up from #1516 (refactor: new proof aggregation).
The proof public inputs in the new aggregation flow need to be bound to the appropriate onchain data to guarantee that proofs are verified against the correct on-chain state.
Context
publishCommittee (CiphernodeRegistryOwnable.sol:192) accepts a caller-supplied address[] nodes and only validates its length against the stored committee:
require(nodes.length == c.topNodes.length, "Node count mismatch");
It never checks that nodes[i] == c.topNodes[i]. The on-chain committee members are already stored as c.topNodes from finalizeCommittee, but they are never compared to the submitted array. A malicious aggregator could pass the correct proof with an entirely different set of addresses in nodes and the contract would accept it.
The second binding gap is deeper: the aggregated proof's internal party_ids public inputs are field element integers (0..H-1) representing positional committee slots — Ethereum addresses appear nowhere in the ZK circuits. There is no cryptographic link between what the proof internally calls "party 0, party 1, …" and the on-chain addresses in c.topNodes. The contract emits:
emit CommitteePublished(e3Id, nodes, publicKey, pkCommitment, proof);
Since nodes is unvalidated, the emitted event — which downstream tooling and slashing attribution rely on — can log whichever addresses the aggregator chose to pass.
Proposed fix
Step 1 — Contract side (immediate, no circuit change needed)
Enforce identity between the submitted nodes and the on-chain topNodes in publishCommittee:
require(nodes.length == c.topNodes.length, "Node count mismatch");
for (uint256 i = 0; i < nodes.length; i++) {
require(nodes[i] == c.topNodes[i], "Node address mismatch");
}
Or simplify by dropping the nodes parameter entirely and reading from c.topNodes directly, since the data is already on-chain:
function publishCommittee(
uint256 e3Id,
bytes calldata publicKey,
bytes32 pkCommitment,
bytes calldata proof
) external {
// ...
emit CommitteePublished(e3Id, c.topNodes, publicKey, pkCommitment, proof);
}
This closes the immediate slashing-attribution vector with a one-line change.
Step 2 — Circuit binding (stronger, closes the address↔party_id gap)
To cryptographically bind the proof's internal party_ids to on-chain committee addresses, include a commitment to the committee address list as a public input to dkg_aggregator. The contract would then verify:
bytes32 committeeHash = keccak256(abi.encodePacked(c.topNodes));
require(bytes32(publicInputs[COMMITTEE_HASH_IDX]) == committeeHash, "Committee mismatch");
This requires the dkg_aggregator circuit to accept and expose a commitment to the sorted node address list — a larger change, but the only way to make the proof itself attest to which real-world parties participated.
Step 1 is a low-risk immediate fix. Step 2 is the complete solution.
Follow-up from #1516 (refactor: new proof aggregation).
The proof public inputs in the new aggregation flow need to be bound to the appropriate onchain data to guarantee that proofs are verified against the correct on-chain state.
Context
publishCommittee(CiphernodeRegistryOwnable.sol:192) accepts a caller-suppliedaddress[] nodesand only validates its length against the stored committee:It never checks that
nodes[i] == c.topNodes[i]. The on-chain committee members are already stored asc.topNodesfromfinalizeCommittee, but they are never compared to the submitted array. A malicious aggregator could pass the correct proof with an entirely different set of addresses innodesand the contract would accept it.The second binding gap is deeper: the aggregated proof's internal
party_idspublic inputs are field element integers (0..H-1) representing positional committee slots — Ethereum addresses appear nowhere in the ZK circuits. There is no cryptographic link between what the proof internally calls "party 0, party 1, …" and the on-chain addresses inc.topNodes. The contract emits:Since
nodesis unvalidated, the emitted event — which downstream tooling and slashing attribution rely on — can log whichever addresses the aggregator chose to pass.Proposed fix
Step 1 — Contract side (immediate, no circuit change needed)
Enforce identity between the submitted
nodesand the on-chaintopNodesinpublishCommittee:Or simplify by dropping the
nodesparameter entirely and reading fromc.topNodesdirectly, since the data is already on-chain:This closes the immediate slashing-attribution vector with a one-line change.
Step 2 — Circuit binding (stronger, closes the address↔party_id gap)
To cryptographically bind the proof's internal
party_idsto on-chain committee addresses, include a commitment to the committee address list as a public input todkg_aggregator. The contract would then verify:This requires the
dkg_aggregatorcircuit to accept and expose a commitment to the sorted node address list — a larger change, but the only way to make the proof itself attest to which real-world parties participated.Step 1 is a low-risk immediate fix. Step 2 is the complete solution.