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
61 changes: 61 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Tests

on:
pull_request:
branches:
- master
push:
branches:
- master

jobs:
test-host-chain:
name: Host Chain Contract Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: contracts/internal/host-chain

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 9

- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV

- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('contracts/internal/host-chain/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-

- name: Install dependencies
run: pnpm install

- name: Compile contracts
run: pnpm compile
env:
# Dummy keys for hardhat config (not used - tests run on Hardhat network)
KEY: "0x0000000000000000000000000000000000000000000000000000000000000001"
KEY2: "0x0000000000000000000000000000000000000000000000000000000000000002"
AGGREGATOR_KEY: "0x0000000000000000000000000000000000000000000000000000000000000003"

- name: Run tests
run: pnpm test
env:
KEY: "0x0000000000000000000000000000000000000000000000000000000000000001"
KEY2: "0x0000000000000000000000000000000000000000000000000000000000000002"
AGGREGATOR_KEY: "0x0000000000000000000000000000000000000000000000000000000000000003"
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## [Unreleased]

### Added
- `isPubliclyAllowed(uint256 ctHash)` view function on `TaskManager` to query whether a ciphertext handle has been publicly allowed (via `allowGlobal` / `allowPublic`). Delegates to `acl.globalAllowed()`.
- `FHE.isPubliclyAllowed()` typed overloads for all encrypted types (`ebool`, `euint8`, ..., `eaddress`) so contracts can query public-allow status directly via the FHE library.
- `publishDecryptResult()` and `publishDecryptResultBatch()` on TaskManager for publishing signed decrypt results on-chain
- `verifyDecryptResult()` (reverts on invalid) and `verifyDecryptResultSafe()` (returns false) for signature verification without publishing
- `decryptResultSigner` state variable and `setDecryptResultSigner()` admin function
- Typed overloads in `FHE.sol` for all encrypted types (`ebool`, `euint8`, ..., `eaddress`)
- `onlyIfEnabled` modifier on publish functions
- `LengthMismatch` custom error replacing require string in batch publish

## v0.1.0

### Breaking Changes
Expand Down
308 changes: 308 additions & 0 deletions contracts/FHE.sol

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions contracts/ICofhe.sol
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,20 @@ interface ITaskManager {

function allow(uint256 ctHash, address account) external;
function isAllowed(uint256 ctHash, address account) external returns (bool);
function isPubliclyAllowed(uint256 ctHash) external view returns (bool);
function allowGlobal(uint256 ctHash) external;
function allowTransient(uint256 ctHash, address account) external;
function getDecryptResultSafe(uint256 ctHash) external view returns (uint256, bool);
function getDecryptResult(uint256 ctHash) external view returns (uint256);

function publishDecryptResult(uint256 ctHash, uint256 result, bytes calldata signature) external;
function publishDecryptResultBatch(uint256[] calldata ctHashes, uint256[] calldata results, bytes[] calldata signatures) external;
function verifyDecryptResult(uint256 ctHash, uint256 result, bytes calldata signature) external view returns (bool);
function verifyDecryptResultSafe(
uint256 ctHash,
uint256 result,
bytes calldata signature
) external view returns (bool);
}

library Utils {
Expand Down
4 changes: 3 additions & 1 deletion contracts/internal/host-chain/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ KEY="0xb03608c1c1f1461ed55c1c2315ab27fe82e2fe4c289ff753dea99078848d27dd"
KEY2="0xcb5790da63720727af975f42c79f69918580209889225fa7128c92402a6d3a65"
AGGREGATOR_KEY="dbfae500d71337029492a6f7f6c82e014467d1a847b684a9bca8403fbc0d6e45"
# verifier signer address to be set only on deployment
VERIFIER_ADDRESS="0x0000000000000000000000000000000000000000"
VERIFIER_ADDRESS="0x0000000000000000000000000000000000000000"
# decrypt result signer address (dispatcher's signing key address)
DECRYPT_RESULT_SIGNER="0x0000000000000000000000000000000000000000"
142 changes: 142 additions & 0 deletions contracts/internal/host-chain/contracts/TaskManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ error InvalidSecurityZone(int32 zone, int32 min, int32 max);
error InvalidSignature();
error InvalidSigner(address signer, address expectedSigner);
error UnsupportedType(uint256 t);
error LengthMismatch();

// Access control errors
error InvalidAddress();
Expand All @@ -48,6 +49,13 @@ library TMCommon {
The format: keccak256(operands_list, op)[0:29] || is_trivial (1 bit) & ct_type (7 bit) || securityZone
*/

// Constants for decrypt result hash computation (message format: result || enc_type || chain_id || ct_hash)
uint256 internal constant SHIFT_ENC_TYPE = 224; // Shift for 4-byte enc_type (256 - 32 = 224)
uint256 internal constant SHIFT_CHAIN_ID = 192; // Shift for 8-byte chain_id (256 - 64 = 192)
uint256 internal constant OFFSET_ENC_TYPE = 0x20; // Byte offset for enc_type in message
uint256 internal constant OFFSET_CHAIN_ID = 0x24; // Byte offset for chain_id in message
uint256 internal constant OFFSET_CT_HASH = 0x2c; // Byte offset for ctHash in message
uint256 internal constant MESSAGE_LENGTH = 0x4c; // Total message length: 76 bytes

function uint256ToBytes32(uint256 value) internal pure returns (bytes memory) {
bytes memory result = new bytes(32);
Expand Down Expand Up @@ -161,6 +169,7 @@ contract TaskManager is ITaskManager, Initializable, UUPSUpgradeable, Ownable2St
__UUPSUpgradeable_init();
initialized = true;
verifierSigner = address(1);
decryptResultSigner = address(1);
isEnabled = true;
}

Expand Down Expand Up @@ -193,6 +202,8 @@ contract TaskManager is ITaskManager, Initializable, UUPSUpgradeable, Ownable2St
event TaskCreated(uint256 ctHash, string operation, uint256 input1, uint256 input2, uint256 input3);
event ProtocolNotification(uint256 ctHash, string operation, string errorMessage);
event DecryptionResult(uint256 ctHash, uint256 result, address indexed requestor);
event DecryptResultSignerChanged(address indexed oldSigner, address indexed newSigner);
event VerifierSignerChanged(address indexed oldSigner, address indexed newSigner);

struct Task {
address creator;
Expand Down Expand Up @@ -225,6 +236,10 @@ contract TaskManager is ITaskManager, Initializable, UUPSUpgradeable, Ownable2St
// If disabled, all operations will revert
bool public isEnabled;

// Signer address for decrypt result verification (threshold network's signing key)
// When set to address(0), signature verification is skipped (debug mode)
address public decryptResultSigner;


modifier onlyAggregator() {
if (!aggregators[msg.sender]) {
Expand Down Expand Up @@ -549,6 +564,119 @@ contract TaskManager is ITaskManager, Initializable, UUPSUpgradeable, Ownable2St
}
}

/// @notice Publish a signed decrypt result to the chain
/// @dev Anyone with a valid signature from the decrypt network can call this
/// @param ctHash The ciphertext hash
/// @param result The decrypted plaintext value
/// @param signature The ECDSA signature from the decrypt network
function publishDecryptResult(
uint256 ctHash,
uint256 result,
bytes calldata signature
) external onlyIfEnabled {
_verifyDecryptResult(ctHash, result, signature, true);
plaintextsStorage.storeResult(ctHash, result);
emit DecryptionResult(ctHash, result, msg.sender);
}

/// @notice Publish multiple decrypt results in one transaction
/// @dev Amortizes base tx cost across multiple operations
function publishDecryptResultBatch(
uint256[] calldata ctHashes,
uint256[] calldata results,
bytes[] calldata signatures
) external onlyIfEnabled {
uint256 length = ctHashes.length;
if (results.length != length || signatures.length != length) revert LengthMismatch();

for (uint256 i = 0; i < length; i++) {
_verifyDecryptResult(ctHashes[i], results[i], signatures[i], true);
plaintextsStorage.storeResult(ctHashes[i], results[i]);
emit DecryptionResult(ctHashes[i], results[i], msg.sender);
}
}

/// @notice Verify a decrypt result signature without publishing
/// @dev Returns true if signature is valid, reverts otherwise
/// @return True if signature is valid
function verifyDecryptResult(
uint256 ctHash,
uint256 result,
bytes calldata signature
) external view returns (bool) {
return _verifyDecryptResult(ctHash, result, signature, true);
}

/// @notice Verify a decrypt result signature without publishing (non-reverting)
/// @dev Returns false if signature is invalid instead of reverting
/// @return True if signature is valid, false otherwise
function verifyDecryptResultSafe(
uint256 ctHash,
uint256 result,
bytes calldata signature
) external view returns (bool) {
return _verifyDecryptResult(ctHash, result, signature, false);
}

/// @dev Verify decrypt result signature
/// @dev Skips verification if decryptResultSigner is address(0) (debug mode)
/// @param shouldRevert If true, reverts on invalid signature; if false, returns false
function _verifyDecryptResult(
uint256 ctHash,
uint256 result,
bytes calldata signature,
bool shouldRevert
) private view returns (bool) {
if (decryptResultSigner == address(0)) {
return true;
}

bytes32 messageHash = _computeDecryptResultHash(ctHash, result);
(address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(messageHash, signature);

if (err != ECDSA.RecoverError.NoError || recovered == address(0)) {
if (shouldRevert) revert InvalidSignature();
return false;
}
if (recovered != decryptResultSigner) {
if (shouldRevert) revert InvalidSigner(recovered, decryptResultSigner);
return false;
}
return true;
}

/// @dev Compute message hash using assembly for gas efficiency
/// @notice Format: result (32) || enc_type (4) || chain_id (8) || ct_hash (32) = 76 bytes
function _computeDecryptResultHash(
uint256 ctHash,
uint256 result
) private view returns (bytes32 messageHash) {
uint8 encryptionType = TMCommon.getUintTypeFromHash(ctHash);
uint64 chainId = uint64(block.chainid);

// Load constants for assembly
uint256 shiftEncType = TMCommon.SHIFT_ENC_TYPE;
uint256 shiftChainId = TMCommon.SHIFT_CHAIN_ID;
uint256 offsetEncType = TMCommon.OFFSET_ENC_TYPE;
uint256 offsetChainId = TMCommon.OFFSET_CHAIN_ID;
uint256 offsetCtHash = TMCommon.OFFSET_CT_HASH;
uint256 msgLength = TMCommon.MESSAGE_LENGTH;

// Assembly for gas-efficient message construction
// Overlapping 32-byte mstores are safe here: each subsequent mstore overwrites
// only the tail bytes of the previous one, and the final mstore (ctHash) lands
// exactly at the end of the 76-byte message, so all fields end up correctly placed.
assembly {
let ptr := mload(0x40)
mstore(ptr, result) // bytes 0-31: result
mstore(add(ptr, offsetEncType), shl(shiftEncType, encryptionType)) // bytes 32-35: enc_type
mstore(add(ptr, offsetChainId), shl(shiftChainId, chainId)) // bytes 36-43: chain_id
mstore(add(ptr, offsetCtHash), ctHash) // bytes 44-75: ctHash
messageHash := keccak256(ptr, msgLength) // hash 76 bytes
mstore(0x40, add(ptr, msgLength)) // advance free memory pointer
}
}

function handleError(uint256 ctHash, string memory operation, string memory errorMessage) external onlyAggregator {
emit ProtocolNotification(ctHash, operation, errorMessage);
}
Expand Down Expand Up @@ -609,6 +737,10 @@ contract TaskManager is ITaskManager, Initializable, UUPSUpgradeable, Ownable2St
return acl.isAllowed(ctHash, account);
}

function isPubliclyAllowed(uint256 ctHash) external view returns (bool) {
return acl.globalAllowed(ctHash);
}

function extractSigner(EncryptedInput memory input, address sender) private view returns (address) {
bytes memory combined = abi.encodePacked(
input.ctHash,
Expand All @@ -629,7 +761,17 @@ contract TaskManager is ITaskManager, Initializable, UUPSUpgradeable, Ownable2St
}

function setVerifierSigner(address signer) external onlyOwner {
address oldSigner = verifierSigner;
verifierSigner = signer;
emit VerifierSignerChanged(oldSigner, signer);
}

/// @notice Set the authorized signer for decrypt results
/// @param signer The new signer address (address(0) disables verification)
function setDecryptResultSigner(address signer) external onlyOwner {
address oldSigner = decryptResultSigner;
decryptResultSigner = signer;
emit DecryptResultSignerChanged(oldSigner, signer);
}

function setSecurityZoneMax(int32 securityZone) external onlyOwner {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ contract OnChain {
}

function cantEncryptWithFakeSecurityZone() public returns (euint32) {
return FHE.asEuint32(16, 100);
return FHE.asEuint32(16, 200); // 200 is outside valid range (-128 to 127)
}

function cantCastWithFakeType() public returns (uint256) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: MIT

pragma solidity >=0.8.13 <0.9.0;

import {FHE, euint8} from "@fhenixprotocol/cofhe-contracts/FHE.sol";

contract PubliclyAllowedTest {
euint8 public lastHandle;

function createAndAllowGlobal(uint8 value) public returns (euint8) {
euint8 encrypted = FHE.asEuint8(value);
FHE.allowGlobal(encrypted);
lastHandle = encrypted;
return encrypted;
}

function createWithoutGlobal(uint8 value) public returns (euint8) {
euint8 encrypted = FHE.asEuint8(value);
lastHandle = encrypted;
return encrypted;
}
}
32 changes: 30 additions & 2 deletions contracts/internal/host-chain/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,39 @@ async function TaskManagerSetup(TMProxyContract: any, aggregatorSigners: any[])
process.env.VERIFIER_ADDRESS,
);
await tx.wait();
console.log(chalk.green("Successfully set verifier signer address"));
console.log(chalk.green(`Successfully set verifier signer address: ${process.env.VERIFIER_ADDRESS}`));
} catch (e) {
console.error(chalk.red(`Failed setVerifierSigner transaction: ${e}`));
return e;
}

// Set the decrypt result signer (dispatcher's signing key)
try {
const connectedImplementation = TMProxyContract.connect(aggregatorSigners[0]);
if (process.env.DECRYPT_RESULT_SIGNER === "0x0000000000000000000000000000000000000000") {
const networkName = hre?.network?.name;
const networkConfig = hre?.network?.config as any;
const networkUrl = networkConfig?.url;
if (
networkUrl &&
!networkUrl.includes("localhost") &&
!networkUrl.includes("127.0.0.1") &&
!networkName?.startsWith("localfhenix")
) {
console.error(chalk.red("refusing to set DECRYPT_RESULT_SIGNER to 0 on a non-local network!"));
return;
}
}

const tx = await connectedImplementation.setDecryptResultSigner(
process.env.DECRYPT_RESULT_SIGNER,
);
await tx.wait();
console.log(chalk.green(`Successfully set decrypt result signer address: ${process.env.DECRYPT_RESULT_SIGNER}`));
} catch (e) {
console.error(chalk.red(`Failed setDecryptResultSigner transaction: ${e}`));
return e;
}
console.log("\n");
}

Expand Down Expand Up @@ -301,7 +329,7 @@ function getAggregatorWallets(ethers: any) {
const func: DeployFunction = async function () {
console.log(chalk.bold.blue("-----------------------Network-----------------------------"));
console.log(chalk.green("Network name:", hre.network.name));
console.log(chalk.green("Network:", hre.network.config));
console.log(chalk.green("Network:", JSON.stringify(hre.network.config, (_, v) => typeof v === 'bigint' ? v.toString() : v)));
console.log("\n");

// Note: we need to use an unused account for deployment via ignition, or it will complain
Expand Down
Loading