Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ contract CommitmentRegistry is UUPSUpgradeable, Ownable2StepUpgradeable {
keccak256(abi.encode(uint256(keccak256("cofhe.storage.CommitmentRegistry")) - 1)) & ~bytes32(uint256(0xff));

event CommitmentsPosted(bytes32 indexed version, uint256 batchSize);
/// @notice Emitted by `postCommitmentsSafe` when a handle is skipped because
/// it was already committed under this version. `newlyPosted` is the count
/// of handles that actually got written (i.e. `handles.length - skipped`).
event CommitmentsPostedSafe(bytes32 indexed version, uint256 newlyPosted, uint256 skipped);
event VersionStatusChanged(bytes32 indexed version, VersionStatus oldStatus, VersionStatus newStatus);
event PosterAdded(address indexed poster);
event PosterRemoved(address indexed poster);
Expand Down Expand Up @@ -108,6 +112,49 @@ contract CommitmentRegistry is UUPSUpgradeable, Ownable2StepUpgradeable {
emit CommitmentsPosted(version, len);
}

/// @notice Idempotent variant of `postCommitments`. Handles already committed
/// under this version are silently skipped instead of reverting the batch.
/// Useful for callers (e.g. blockchain-poster) where the same handle may
/// arrive in multiple flushes due to deterministic FHE outputs or message
/// redeliveries — the on-chain end state is identical either way, and the
/// caller would rather make progress than roll back the whole batch.
///
/// `ZeroCommitHash` and `LengthMismatch` still revert (those indicate caller
/// bugs, not duplicates). `VersionNotActive`, `EmptyBatch` likewise.
function postCommitmentsSafe(
bytes32 version,
bytes32[] calldata handles,
bytes32[] calldata commitHashes
) external onlyPoster {
uint256 len = handles.length;
if (len == 0) revert EmptyBatch();
if (len != commitHashes.length) revert LengthMismatch();

CommitmentRegistryStorage storage $ = _getStorage();

if ($.versionStatus[version] != VersionStatus.Active) {
revert VersionNotActive(version);
}

mapping(bytes32 => bytes32) storage versionMap = $.commitments[version];

uint256 newlyPosted = 0;
for (uint256 i = 0; i < len; ) {
bytes32 handle = handles[i];
bytes32 commitHash = commitHashes[i];
if (commitHash == bytes32(0)) revert ZeroCommitHash(handle);
if (versionMap[handle] == bytes32(0)) {
versionMap[handle] = commitHash;
$.handlesByVersion[version].push(handle);
unchecked { ++newlyPosted; }
}
// else: handle already committed — silently skip. The desired end
// state (commitment recorded under (version, handle)) is unchanged.
unchecked { ++i; }
}
emit CommitmentsPostedSafe(version, newlyPosted, len - newlyPosted);
}

function addPoster(address poster) external onlyOwner {
if (poster == address(0)) revert InvalidAddress();
CommitmentRegistryStorage storage $ = _getStorage();
Expand Down
2 changes: 1 addition & 1 deletion contracts/internal/registry-chain/scripts/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import hre from "hardhat";
import { deployUUPSProxy } from "../utils/deploy";

// OZ Relayer signer address (deterministic from dev keystore)
const DEFAULT_POSTER_ADDRESS = "0x3f1Eae7D46d88F08fc2F8ed27FCb2AB183EB2d0E";
const DEFAULT_POSTER_ADDRESS = "0x53118C97bD4b7FdDb68244D788Ce7b2946ECd327";
const OZ_RELAYER_ADDRESS = process.env.POSTER_ADDRESS || DEFAULT_POSTER_ADDRESS;

// Commitment version to activate (must match COMMITMENT_VERSION in fhe-engine)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,155 @@ export function shouldBehaveLikeCommitmentRegistry(): void {
});
});

// ── postCommitmentsSafe (idempotent) ────────────────────────────────

describe("Post Commitments Safe", function () {
beforeEach(async function () {
await this.registry.setVersionStatus(VERSION_1, VersionStatus.Active);
});

it("should write all handles and emit (newlyPosted=N, skipped=0) on first call", async function () {
const handles = Array.from({ length: 3 }, () => randomBytes32());
const commitHashes = Array.from({ length: 3 }, () => randomBytes32());
const registryAsPoster = this.registry.connect(this.poster);

await expect(registryAsPoster.postCommitmentsSafe(VERSION_1, handles, commitHashes))
.to.emit(this.registry, "CommitmentsPostedSafe")
.withArgs(VERSION_1, 3, 0);

for (let i = 0; i < 3; i++) {
expect(await this.registry.getCommitment(VERSION_1, handles[i])).to.equal(commitHashes[i]);
}
expect(await this.registry.getSize(VERSION_1)).to.equal(3);
});

it("should silently skip already-committed handles", async function () {
const handle = randomBytes32();
const original = randomBytes32();
const registryAsPoster = this.registry.connect(this.poster);

await registryAsPoster.postCommitmentsSafe(VERSION_1, [handle], [original]);

// Re-post same handle with a different commit hash — should NOT revert
// and should NOT overwrite the original.
await expect(
registryAsPoster.postCommitmentsSafe(VERSION_1, [handle], [randomBytes32()])
)
.to.emit(this.registry, "CommitmentsPostedSafe")
.withArgs(VERSION_1, 0, 1);

expect(await this.registry.getCommitment(VERSION_1, handle)).to.equal(original);
expect(await this.registry.getSize(VERSION_1)).to.equal(1);
});

it("should write only new handles in a mixed batch", async function () {
const existingHandle = randomBytes32();
const newHandle1 = randomBytes32();
const newHandle2 = randomBytes32();
const existingCommit = randomBytes32();
const registryAsPoster = this.registry.connect(this.poster);

await registryAsPoster.postCommitmentsSafe(VERSION_1, [existingHandle], [existingCommit]);

const newCommit1 = randomBytes32();
const newCommit2 = randomBytes32();
await expect(
registryAsPoster.postCommitmentsSafe(
VERSION_1,
[existingHandle, newHandle1, newHandle2],
[randomBytes32(), newCommit1, newCommit2]
)
)
.to.emit(this.registry, "CommitmentsPostedSafe")
.withArgs(VERSION_1, 2, 1);

// existing one preserved
expect(await this.registry.getCommitment(VERSION_1, existingHandle)).to.equal(existingCommit);
// new ones written
expect(await this.registry.getCommitment(VERSION_1, newHandle1)).to.equal(newCommit1);
expect(await this.registry.getCommitment(VERSION_1, newHandle2)).to.equal(newCommit2);
expect(await this.registry.getSize(VERSION_1)).to.equal(3);
});

it("should dedup duplicate handles within the same batch", async function () {
const handle = randomBytes32();
const commitHash = randomBytes32();
const registryAsPoster = this.registry.connect(this.poster);

// Same handle three times in one call — should write once, skip twice.
await expect(
registryAsPoster.postCommitmentsSafe(
VERSION_1,
[handle, handle, handle],
[commitHash, randomBytes32(), randomBytes32()]
)
)
.to.emit(this.registry, "CommitmentsPostedSafe")
.withArgs(VERSION_1, 1, 2);

expect(await this.registry.getCommitment(VERSION_1, handle)).to.equal(commitHash);
expect(await this.registry.getSize(VERSION_1)).to.equal(1);
});

it("should still revert on zero commitHash", async function () {
const handle = randomBytes32();
const registryAsPoster = this.registry.connect(this.poster);

await expect(
registryAsPoster.postCommitmentsSafe(VERSION_1, [handle], [ethers.ZeroHash])
)
.to.be.revertedWithCustomError(this.registry, "ZeroCommitHash")
.withArgs(handle);
});

it("should still revert on empty batch", async function () {
const registryAsPoster = this.registry.connect(this.poster);
await expect(
registryAsPoster.postCommitmentsSafe(VERSION_1, [], [])
).to.be.revertedWithCustomError(this.registry, "EmptyBatch");
});

it("should still revert on length mismatch", async function () {
const registryAsPoster = this.registry.connect(this.poster);
await expect(
registryAsPoster.postCommitmentsSafe(
VERSION_1,
[randomBytes32()],
[randomBytes32(), randomBytes32()]
)
).to.be.revertedWithCustomError(this.registry, "LengthMismatch");
});

it("should revert when version is not active", async function () {
const registryAsPoster = this.registry.connect(this.poster);
await expect(
registryAsPoster.postCommitmentsSafe(VERSION_2, [randomBytes32()], [randomBytes32()])
)
.to.be.revertedWithCustomError(this.registry, "VersionNotActive")
.withArgs(VERSION_2);
});

it("should revert when caller is not a poster", async function () {
const registryAsOther = this.registry.connect(this.otherAccount);
await expect(
registryAsOther.postCommitmentsSafe(VERSION_1, [randomBytes32()], [randomBytes32()])
)
.to.be.revertedWithCustomError(this.registry, "OnlyPosterAllowed")
.withArgs(this.otherAccount.address);
});

it("should not double-count handles in handlesByVersion when re-posted", async function () {
const handle = randomBytes32();
const registryAsPoster = this.registry.connect(this.poster);

await registryAsPoster.postCommitmentsSafe(VERSION_1, [handle], [randomBytes32()]);
await registryAsPoster.postCommitmentsSafe(VERSION_1, [handle], [randomBytes32()]);
await registryAsPoster.postCommitmentsSafe(VERSION_1, [handle], [randomBytes32()]);

expect(await this.registry.getSize(VERSION_1)).to.equal(1);
});
});

// ── Access Control ─────────────────────────────────────────────────

describe("Access Control", function () {
Expand Down
Loading