Skip to content

Commit c1c1c3a

Browse files
authored
[FEAT] Add postCommitmentsSafe - idempotent variant (#64)
* [FEAT] Add postCommitmentsSafe — idempotent variant that skips already-posted handles Reverts on duplicates (CommitmentAlreadyExists) break callers like blockchain-poster whose batches can re-post the same handle (deterministic FHE outputs, RabbitMQ redeliveries). The Safe variant silently skips and emits CommitmentsPostedSafe(version, newlyPosted, skipped) so observability isn't lost. ZeroCommitHash, EmptyBatch, LengthMismatch, VersionNotActive, OnlyPosterAllowed still revert — those indicate caller bugs. Tracks PRO-300. * [CHORE] Update DEFAULT_POSTER_ADDRESS to dedicated OZ Relayer wallet Pairs with the genesis allocation + keystore in the parent repo. The previous default 0x3f1Eae… is the test wallet and races with Hardhat-driven txs for the same nonce sequence — see PRO-300 / PR-#676 deployment requirements.
1 parent 0425ca4 commit c1c1c3a

3 files changed

Lines changed: 197 additions & 1 deletion

File tree

contracts/internal/registry-chain/contracts/commitment-registry/CommitmentRegistry.sol

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ contract CommitmentRegistry is UUPSUpgradeable, Ownable2StepUpgradeable {
5151
keccak256(abi.encode(uint256(keccak256("cofhe.storage.CommitmentRegistry")) - 1)) & ~bytes32(uint256(0xff));
5252

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

115+
/// @notice Idempotent variant of `postCommitments`. Handles already committed
116+
/// under this version are silently skipped instead of reverting the batch.
117+
/// Useful for callers (e.g. blockchain-poster) where the same handle may
118+
/// arrive in multiple flushes due to deterministic FHE outputs or message
119+
/// redeliveries — the on-chain end state is identical either way, and the
120+
/// caller would rather make progress than roll back the whole batch.
121+
///
122+
/// `ZeroCommitHash` and `LengthMismatch` still revert (those indicate caller
123+
/// bugs, not duplicates). `VersionNotActive`, `EmptyBatch` likewise.
124+
function postCommitmentsSafe(
125+
bytes32 version,
126+
bytes32[] calldata handles,
127+
bytes32[] calldata commitHashes
128+
) external onlyPoster {
129+
uint256 len = handles.length;
130+
if (len == 0) revert EmptyBatch();
131+
if (len != commitHashes.length) revert LengthMismatch();
132+
133+
CommitmentRegistryStorage storage $ = _getStorage();
134+
135+
if ($.versionStatus[version] != VersionStatus.Active) {
136+
revert VersionNotActive(version);
137+
}
138+
139+
mapping(bytes32 => bytes32) storage versionMap = $.commitments[version];
140+
141+
uint256 newlyPosted = 0;
142+
for (uint256 i = 0; i < len; ) {
143+
bytes32 handle = handles[i];
144+
bytes32 commitHash = commitHashes[i];
145+
if (commitHash == bytes32(0)) revert ZeroCommitHash(handle);
146+
if (versionMap[handle] == bytes32(0)) {
147+
versionMap[handle] = commitHash;
148+
$.handlesByVersion[version].push(handle);
149+
unchecked { ++newlyPosted; }
150+
}
151+
// else: handle already committed — silently skip. The desired end
152+
// state (commitment recorded under (version, handle)) is unchanged.
153+
unchecked { ++i; }
154+
}
155+
emit CommitmentsPostedSafe(version, newlyPosted, len - newlyPosted);
156+
}
157+
111158
function addPoster(address poster) external onlyOwner {
112159
if (poster == address(0)) revert InvalidAddress();
113160
CommitmentRegistryStorage storage $ = _getStorage();

contracts/internal/registry-chain/scripts/deploy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import hre from "hardhat";
22
import { deployUUPSProxy } from "../utils/deploy";
33

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

88
// Commitment version to activate (must match COMMITMENT_VERSION in fhe-engine)

contracts/internal/registry-chain/test/commitmentRegistry/CommitmentRegistry.behavior.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,155 @@ export function shouldBehaveLikeCommitmentRegistry(): void {
457457
});
458458
});
459459

460+
// ── postCommitmentsSafe (idempotent) ────────────────────────────────
461+
462+
describe("Post Commitments Safe", function () {
463+
beforeEach(async function () {
464+
await this.registry.setVersionStatus(VERSION_1, VersionStatus.Active);
465+
});
466+
467+
it("should write all handles and emit (newlyPosted=N, skipped=0) on first call", async function () {
468+
const handles = Array.from({ length: 3 }, () => randomBytes32());
469+
const commitHashes = Array.from({ length: 3 }, () => randomBytes32());
470+
const registryAsPoster = this.registry.connect(this.poster);
471+
472+
await expect(registryAsPoster.postCommitmentsSafe(VERSION_1, handles, commitHashes))
473+
.to.emit(this.registry, "CommitmentsPostedSafe")
474+
.withArgs(VERSION_1, 3, 0);
475+
476+
for (let i = 0; i < 3; i++) {
477+
expect(await this.registry.getCommitment(VERSION_1, handles[i])).to.equal(commitHashes[i]);
478+
}
479+
expect(await this.registry.getSize(VERSION_1)).to.equal(3);
480+
});
481+
482+
it("should silently skip already-committed handles", async function () {
483+
const handle = randomBytes32();
484+
const original = randomBytes32();
485+
const registryAsPoster = this.registry.connect(this.poster);
486+
487+
await registryAsPoster.postCommitmentsSafe(VERSION_1, [handle], [original]);
488+
489+
// Re-post same handle with a different commit hash — should NOT revert
490+
// and should NOT overwrite the original.
491+
await expect(
492+
registryAsPoster.postCommitmentsSafe(VERSION_1, [handle], [randomBytes32()])
493+
)
494+
.to.emit(this.registry, "CommitmentsPostedSafe")
495+
.withArgs(VERSION_1, 0, 1);
496+
497+
expect(await this.registry.getCommitment(VERSION_1, handle)).to.equal(original);
498+
expect(await this.registry.getSize(VERSION_1)).to.equal(1);
499+
});
500+
501+
it("should write only new handles in a mixed batch", async function () {
502+
const existingHandle = randomBytes32();
503+
const newHandle1 = randomBytes32();
504+
const newHandle2 = randomBytes32();
505+
const existingCommit = randomBytes32();
506+
const registryAsPoster = this.registry.connect(this.poster);
507+
508+
await registryAsPoster.postCommitmentsSafe(VERSION_1, [existingHandle], [existingCommit]);
509+
510+
const newCommit1 = randomBytes32();
511+
const newCommit2 = randomBytes32();
512+
await expect(
513+
registryAsPoster.postCommitmentsSafe(
514+
VERSION_1,
515+
[existingHandle, newHandle1, newHandle2],
516+
[randomBytes32(), newCommit1, newCommit2]
517+
)
518+
)
519+
.to.emit(this.registry, "CommitmentsPostedSafe")
520+
.withArgs(VERSION_1, 2, 1);
521+
522+
// existing one preserved
523+
expect(await this.registry.getCommitment(VERSION_1, existingHandle)).to.equal(existingCommit);
524+
// new ones written
525+
expect(await this.registry.getCommitment(VERSION_1, newHandle1)).to.equal(newCommit1);
526+
expect(await this.registry.getCommitment(VERSION_1, newHandle2)).to.equal(newCommit2);
527+
expect(await this.registry.getSize(VERSION_1)).to.equal(3);
528+
});
529+
530+
it("should dedup duplicate handles within the same batch", async function () {
531+
const handle = randomBytes32();
532+
const commitHash = randomBytes32();
533+
const registryAsPoster = this.registry.connect(this.poster);
534+
535+
// Same handle three times in one call — should write once, skip twice.
536+
await expect(
537+
registryAsPoster.postCommitmentsSafe(
538+
VERSION_1,
539+
[handle, handle, handle],
540+
[commitHash, randomBytes32(), randomBytes32()]
541+
)
542+
)
543+
.to.emit(this.registry, "CommitmentsPostedSafe")
544+
.withArgs(VERSION_1, 1, 2);
545+
546+
expect(await this.registry.getCommitment(VERSION_1, handle)).to.equal(commitHash);
547+
expect(await this.registry.getSize(VERSION_1)).to.equal(1);
548+
});
549+
550+
it("should still revert on zero commitHash", async function () {
551+
const handle = randomBytes32();
552+
const registryAsPoster = this.registry.connect(this.poster);
553+
554+
await expect(
555+
registryAsPoster.postCommitmentsSafe(VERSION_1, [handle], [ethers.ZeroHash])
556+
)
557+
.to.be.revertedWithCustomError(this.registry, "ZeroCommitHash")
558+
.withArgs(handle);
559+
});
560+
561+
it("should still revert on empty batch", async function () {
562+
const registryAsPoster = this.registry.connect(this.poster);
563+
await expect(
564+
registryAsPoster.postCommitmentsSafe(VERSION_1, [], [])
565+
).to.be.revertedWithCustomError(this.registry, "EmptyBatch");
566+
});
567+
568+
it("should still revert on length mismatch", async function () {
569+
const registryAsPoster = this.registry.connect(this.poster);
570+
await expect(
571+
registryAsPoster.postCommitmentsSafe(
572+
VERSION_1,
573+
[randomBytes32()],
574+
[randomBytes32(), randomBytes32()]
575+
)
576+
).to.be.revertedWithCustomError(this.registry, "LengthMismatch");
577+
});
578+
579+
it("should revert when version is not active", async function () {
580+
const registryAsPoster = this.registry.connect(this.poster);
581+
await expect(
582+
registryAsPoster.postCommitmentsSafe(VERSION_2, [randomBytes32()], [randomBytes32()])
583+
)
584+
.to.be.revertedWithCustomError(this.registry, "VersionNotActive")
585+
.withArgs(VERSION_2);
586+
});
587+
588+
it("should revert when caller is not a poster", async function () {
589+
const registryAsOther = this.registry.connect(this.otherAccount);
590+
await expect(
591+
registryAsOther.postCommitmentsSafe(VERSION_1, [randomBytes32()], [randomBytes32()])
592+
)
593+
.to.be.revertedWithCustomError(this.registry, "OnlyPosterAllowed")
594+
.withArgs(this.otherAccount.address);
595+
});
596+
597+
it("should not double-count handles in handlesByVersion when re-posted", async function () {
598+
const handle = randomBytes32();
599+
const registryAsPoster = this.registry.connect(this.poster);
600+
601+
await registryAsPoster.postCommitmentsSafe(VERSION_1, [handle], [randomBytes32()]);
602+
await registryAsPoster.postCommitmentsSafe(VERSION_1, [handle], [randomBytes32()]);
603+
await registryAsPoster.postCommitmentsSafe(VERSION_1, [handle], [randomBytes32()]);
604+
605+
expect(await this.registry.getSize(VERSION_1)).to.equal(1);
606+
});
607+
});
608+
460609
// ── Access Control ─────────────────────────────────────────────────
461610

462611
describe("Access Control", function () {

0 commit comments

Comments
 (0)