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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@

# Build artifacts (generated)
contracts/internal/host-chain/bin/solc-output-compile-all.json

# Registry chain (generated)
contracts/internal/registry-chain/node_modules/
contracts/internal/registry-chain/artifacts/
contracts/internal/registry-chain/cache/
contracts/internal/registry-chain/types/
contracts/internal/registry-chain/.openzeppelin/
contracts/internal/registry-chain/.env
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [Unreleased]

### Added
- **CommitmentRegistry** — UUPS-upgradeable contract for on-chain FHE computation commitments (`handle → commitHash`) grouped by state version. Threshold Network uses these to verify ciphertext integrity before decrypting. Includes version lifecycle state machine, write-once enforcement, batch posting, array-based enumeration with paginated cursor, and Arbitrum gas estimation script.

## v0.1.3 - 2025-03-25

### Changed
Expand Down
39 changes: 39 additions & 0 deletions contracts/internal/registry-chain/contracts/ERC1967Proxy.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.2.0) (proxy/ERC1967/ERC1967Proxy.sol)

pragma solidity ^0.8.22;

import {Proxy} from "@openzeppelin/contracts/proxy/Proxy.sol";
import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol";
/**
* @dev This contract implements an upgradeable proxy. It is upgradeable because calls are delegated to an
* implementation address that can be changed. This address is stored in storage in the location specified by
* https://eips.ethereum.org/EIPS/eip-1967[ERC-1967], so that it doesn't conflict with the storage layout of the
* implementation behind the proxy.
*/
contract ERC1967Proxy is Proxy {
/**
* @dev Initializes the upgradeable proxy with an initial implementation specified by `implementation`.
*
* If `_data` is nonempty, it's used as data in a delegate call to `implementation`. This will typically be an
* encoded function call, and allows initializing the storage of the proxy like a Solidity constructor.
*
* Requirements:
*
* - If `data` is empty, `msg.value` must be zero.
*/
constructor(address implementation, bytes memory data) payable {
ERC1967Utils.upgradeToAndCall(implementation, data);
}

/**
* @dev Returns the current implementation address.
*
* TIP: To get this value clients can read directly from the storage slot shown below (specified by ERC-1967) using
* the https://eth.wiki/json-rpc/API#eth_getstorageat[`eth_getStorageAt`] RPC call.
* `0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc`
*/
function _implementation() internal view virtual override returns (address) {
return ERC1967Utils.getImplementation();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
// SPDX-License-Identifier: BSD-3-Clause-Clear
pragma solidity >=0.8.25 <0.9.0;

import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol";

contract CommitmentRegistry is UUPSUpgradeable, Ownable2StepUpgradeable {

enum VersionStatus { Unset, Active, Deprecated, Revoked }

/// @notice Returned when a non-poster address attempts to post commitments.
error OnlyPosterAllowed(address caller);

/// @notice Returned when attempting to add a poster that is already registered.
error PosterAlreadyExists(address poster);

/// @notice Returned when attempting to remove a poster that is not registered.
error PosterNotFound(address poster);

/// @notice Returned when attempting to post commitments under a non-active version.
error VersionNotActive(bytes32 version);

/// @notice Returned when a commitment for the given handle already exists under this version.
error CommitmentAlreadyExists(bytes32 version, bytes32 handle);

/// @notice Returned when a zero address is provided where a non-zero address is required.
error InvalidAddress();

/// @notice Returned when the handles and commitHashes arrays have different lengths.
error LengthMismatch();

/// @notice Returned when an empty batch is submitted.
error EmptyBatch();

/// @notice Returned when a zero commitHash is provided for a handle.
error ZeroCommitHash(bytes32 handle);

/// @notice Returned when an invalid version status transition is attempted.
error InvalidVersionTransition(bytes32 version, VersionStatus current, VersionStatus target);

/// @custom:storage-location erc7201:cofhe.storage.CommitmentRegistry
struct CommitmentRegistryStorage {
mapping(bytes32 version => mapping(bytes32 handle => bytes32 commitHash)) commitments;
mapping(bytes32 version => bytes32[]) handlesByVersion;
mapping(bytes32 version => VersionStatus) versionStatus;
mapping(address => bool) posters;
}

/// @dev keccak256(abi.encode(uint256(keccak256("cofhe.storage.CommitmentRegistry")) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant STORAGE_SLOT =
keccak256(abi.encode(uint256(keccak256("cofhe.storage.CommitmentRegistry")) - 1)) & ~bytes32(uint256(0xff));

event CommitmentsPosted(bytes32 indexed version, uint256 batchSize);
event VersionStatusChanged(bytes32 indexed version, VersionStatus oldStatus, VersionStatus newStatus);
event PosterAdded(address indexed poster);
event PosterRemoved(address indexed poster);

modifier onlyPoster() {
CommitmentRegistryStorage storage $ = _getStorage();
if (!$.posters[msg.sender]) {
revert OnlyPosterAllowed(msg.sender);
}
_;
}

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}

function initialize(address initialOwner, address initialPoster) public initializer {
if (initialOwner == address(0) || initialPoster == address(0)) {
revert InvalidAddress();
}
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
CommitmentRegistryStorage storage $ = _getStorage();
$.posters[initialPoster] = true;
emit PosterAdded(initialPoster);
}

function postCommitments(
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];

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)) revert CommitmentAlreadyExists(version, handle);
versionMap[handle] = commitHash;
$.handlesByVersion[version].push(handle);
unchecked { ++i; }
}
emit CommitmentsPosted(version, len);
}

function addPoster(address poster) external onlyOwner {
if (poster == address(0)) revert InvalidAddress();
CommitmentRegistryStorage storage $ = _getStorage();
if ($.posters[poster]) revert PosterAlreadyExists(poster);
$.posters[poster] = true;
emit PosterAdded(poster);
}

function removePoster(address poster) external onlyOwner {
if (poster == address(0)) revert InvalidAddress();
CommitmentRegistryStorage storage $ = _getStorage();
if (!$.posters[poster]) revert PosterNotFound(poster);
$.posters[poster] = false;
emit PosterRemoved(poster);
}

function setVersionStatus(bytes32 version, VersionStatus newStatus) external onlyOwner {
CommitmentRegistryStorage storage $ = _getStorage();
VersionStatus current = $.versionStatus[version];

// Allowed transitions:
// Unset -> Active
// Active -> Deprecated
// Active -> Revoked
// Deprecated -> Revoked
bool allowed = (current == VersionStatus.Unset && newStatus == VersionStatus.Active) ||
(current == VersionStatus.Active && newStatus == VersionStatus.Deprecated) ||
(current == VersionStatus.Active && newStatus == VersionStatus.Revoked) ||
(current == VersionStatus.Deprecated && newStatus == VersionStatus.Revoked);

if (!allowed) {
revert InvalidVersionTransition(version, current, newStatus);
}

$.versionStatus[version] = newStatus;
emit VersionStatusChanged(version, current, newStatus);
}

function getCommitment(bytes32 version, bytes32 handle) external view returns (bytes32) {
return _getStorage().commitments[version][handle];
}

function getVersionStatus(bytes32 version) external view returns (VersionStatus) {
return _getStorage().versionStatus[version];
}

function getSize(bytes32 version) external view returns (uint256) {
return _getStorage().handlesByVersion[version].length;
}

function getHandleByIndex(bytes32 version, uint256 index) external view returns (bytes32) {
return _getStorage().handlesByVersion[version][index];
}

function getHandles(bytes32 version, uint256 offset, uint256 limit) external view returns (bytes32[] memory) {
CommitmentRegistryStorage storage $ = _getStorage();
bytes32[] storage allHandles = $.handlesByVersion[version];
uint256 total = allHandles.length;
if (offset >= total) return new bytes32[](0);
uint256 end = offset + limit;
if (end > total) end = total;
uint256 len = end - offset;
bytes32[] memory result = new bytes32[](len);
for (uint256 i = 0; i < len; ) {
result[i] = allHandles[offset + i];
unchecked { ++i; }
}
return result;
}

function isPoster(address account) external view returns (bool) {
return _getStorage().posters[account];
}

function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}

function _getStorage() private pure returns (CommitmentRegistryStorage storage $) {
bytes32 slot = STORAGE_SLOT;
assembly {
$.slot := slot
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# CommitmentRegistry

UUPS-upgradeable contract for storing on-chain FHE computation commitments. Deployed on Arbitrum One.

## Purpose

After CoFHE computes an FHE operation, it posts a commitment (`handle → hash(ciphertext)`) on-chain. The Threshold Network (TN) uses these commitments to verify ciphertext integrity before decrypting:

1. TN receives a decrypt request for handle `X`
2. TN calls `getCommitment(version, X)` → gets the committed `commitHash`
3. TN fetches the actual ciphertext from the DB
4. TN checks `keccak256(ciphertext) == commitHash` → proceeds with decrypt

## Data Model

```
mapping(bytes32 version => mapping(bytes32 handle => bytes32 commitHash)) // O(1) lookup
mapping(bytes32 version => bytes32[]) // enumerable handle list per version
```

- **version**: Opaque `bytes32` from the FHE engine — `keccak256(publicKey[securityZone], library_id, library_version, params)`. Scoped per security zone.
- **handle**: The ciphertext identifier.
- **commitHash**: `keccak256` of the actual computed ciphertext bytes.

Each commitment is stored in both the mapping (for lookup) and the array (for enumeration/migration).

## Version Lifecycle

```
Unset → Active → Deprecated → Revoked
→ Revoked
```

- **Active**: Accepts new commitments, TN trusts them
- **Deprecated**: No new writes, existing commitments still valid
- **Revoked**: No new writes, existing commitments should not be trusted

No resurrection — once Deprecated or Revoked, cannot go back to Active.

## API

### Write (poster only)

```solidity
postCommitments(bytes32 version, bytes32[] handles, bytes32[] commitHashes)
```

Posts a batch of commitments. Reverts if:
- Version is not Active
- Any handle already has a commitment (write-once)
- Any commitHash is zero
- Arrays have different lengths or are empty

### Admin (owner only)

```solidity
setPoster(address newPoster) // Change the authorized poster
setVersionStatus(bytes32, VersionStatus) // Manage version lifecycle
```

### Views

```solidity
getCommitment(bytes32 version, bytes32 handle) → bytes32 commitHash
getVersionStatus(bytes32 version) → VersionStatus
getSize(bytes32 version) → uint256 // Number of commitments under a version
getHandleByIndex(bytes32 version, uint256 index) → bytes32 // Enumerate by index
getHandles(bytes32 version, uint256 offset, uint256 limit) → bytes32[] // Paginated cursor
getPoster() → address
```

## Gas Costs

Measured on Hardhat (L2 execution only, includes mapping + array storage):

| Batch Size | Total Gas | Per Commitment |
|---|---|---|
| 1 | 102,222 | 102,222 |
| 10 | 517,726 | 51,773 |
| 25 | 1,210,188 | 48,408 |
| 50 | 2,364,306 | 47,286 |
| 100 | 4,672,513 | 46,725 |

Per-commitment cost converges to ~47K gas at scale. The fixed overhead per batch is ~55K gas (tx base + access control + version check + event).

Estimated Arbitrum One cost at 0.03 gwei effective gas price, ETH ~$2,140:
- Single post: ~$0.007/CT
- Batch of 10: ~$0.003/CT
- Batch of 50: ~$0.003/CT

Monthly projections (100K CTs/day): $9K-20K/mo depending on batch efficiency.

## Testing

```bash
cd contracts/internal/registry-chain

# Install dependencies
pnpm install

# Run tests
pnpm test

# Run with gas report
pnpm test:gas

# Estimate gas on Arbitrum Sepolia (needs KEY in .env)
npx hardhat run scripts/estimateGasArbitrum.ts --network arbitrumSepolia
```

## Upgradeability

Uses UUPS proxy pattern with ERC-7201 namespaced storage. Future upgrades can add:
- Merkle root storage for cheaper batch posting (~10-20x cost reduction)
- Additional access control roles
- Additional access control roles
Loading
Loading