diff --git a/contracts/internal/registry-chain/contracts/commitment-registry/CommitmentRegistry.sol b/contracts/internal/registry-chain/contracts/commitment-registry/CommitmentRegistry.sol index 99c21aa..c4bb9a6 100644 --- a/contracts/internal/registry-chain/contracts/commitment-registry/CommitmentRegistry.sol +++ b/contracts/internal/registry-chain/contracts/commitment-registry/CommitmentRegistry.sol @@ -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); @@ -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(); diff --git a/contracts/internal/registry-chain/scripts/deploy.ts b/contracts/internal/registry-chain/scripts/deploy.ts index c7cb41b..6aec5ef 100644 --- a/contracts/internal/registry-chain/scripts/deploy.ts +++ b/contracts/internal/registry-chain/scripts/deploy.ts @@ -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) diff --git a/contracts/internal/registry-chain/test/commitmentRegistry/CommitmentRegistry.behavior.ts b/contracts/internal/registry-chain/test/commitmentRegistry/CommitmentRegistry.behavior.ts index 6d0ebce..15f85ef 100644 --- a/contracts/internal/registry-chain/test/commitmentRegistry/CommitmentRegistry.behavior.ts +++ b/contracts/internal/registry-chain/test/commitmentRegistry/CommitmentRegistry.behavior.ts @@ -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 () {