// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /// @title HelixAnchor /// @author Binary Helix /// @notice Append-only registry of Merkle roots for the MyHelix wallet. Each root commits to a batch of /// 32-byte hashes (SHA-256 of record ciphertext, consent-receipt hashes, audit-log heads). /// No health data, identifiers or ciphertext is ever written here - only 32-byte roots. /// @dev Merkle construction (must match packages/core): /// leaf = sha256(0x00 || itemHash) /// node = sha256(0x01 || left || right) /// An odd node at the end of a level is promoted unchanged to the next level, so a proof simply has /// no entry for that level. Domain separation (0x00 / 0x01) prevents second-preimage attacks where an /// inner node is presented as a leaf. /// Reference implementation: not audited. Do not deploy to mainnet before an independent audit. contract HelixAnchor { /// @notice Batch metadata stored per anchored root. struct Batch { bytes32 batchId; uint32 leafCount; uint64 timestamp; uint64 blockNumber; } /// @notice Contract owner; manages anchorer roles. Intended to be a multisig / timelock. address public owner; /// @notice Pending owner for two-step ownership transfer. address public pendingOwner; /// @notice Accounts allowed to call {anchor} (batcher services or approved smart accounts). mapping(address => bool) public isAnchorer; /// @notice Merkle root => block timestamp at which it was anchored (0 if never anchored). mapping(bytes32 => uint256) public roots; /// @notice Merkle root => batch metadata. mapping(bytes32 => Batch) public batches; /// @notice batchId => root, so a batch id cannot be reused. mapping(bytes32 => bytes32) public rootOfBatch; /// @notice Emitted once per anchored batch. /// @param root Merkle root of the batch. /// @param batchId Off-chain batch identifier (random 32 bytes; carries no personal information). /// @param leafCount Number of leaves committed by the root. /// @param anchorer Account that submitted the batch. /// @param timestamp Block timestamp. event Anchored(bytes32 indexed root, bytes32 indexed batchId, uint32 leafCount, address indexed anchorer, uint256 timestamp); /// @notice Emitted when an anchorer role is granted or revoked. event AnchorerSet(address indexed account, bool allowed); /// @notice Emitted when ownership transfer is started. event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner); /// @notice Emitted when ownership transfer completes. event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); error NotOwner(); error NotPendingOwner(); error NotAnchorer(); error ZeroAddress(); error EmptyRoot(); error EmptyBatch(); error RootAlreadyAnchored(bytes32 root); error BatchIdAlreadyUsed(bytes32 batchId); error LengthMismatch(); modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; } modifier onlyAnchorer() { if (!isAnchorer[msg.sender]) revert NotAnchorer(); _; } /// @param initialOwner Owner that manages anchorer roles. /// @param initialAnchorer First authorized anchorer (may be the zero address to add later). constructor(address initialOwner, address initialAnchorer) { if (initialOwner == address(0)) revert ZeroAddress(); owner = initialOwner; emit OwnershipTransferred(address(0), initialOwner); if (initialAnchorer != address(0)) { isAnchorer[initialAnchorer] = true; emit AnchorerSet(initialAnchorer, true); } } // ------------------------------------------------------------------------------------------------ // Anchoring // ------------------------------------------------------------------------------------------------ /// @notice Anchor a Merkle root committing to `leafCount` hashes. /// @param root Merkle root (see contract-level docs for the hashing scheme). /// @param leafCount Number of leaves in the batch (> 0). /// @param batchId Random off-chain batch identifier. function anchor(bytes32 root, uint32 leafCount, bytes32 batchId) external onlyAnchorer { if (root == bytes32(0)) revert EmptyRoot(); if (leafCount == 0) revert EmptyBatch(); if (roots[root] != 0) revert RootAlreadyAnchored(root); if (rootOfBatch[batchId] != bytes32(0)) revert BatchIdAlreadyUsed(batchId); roots[root] = block.timestamp; rootOfBatch[batchId] = root; batches[root] = Batch({ batchId: batchId, leafCount: leafCount, timestamp: uint64(block.timestamp), blockNumber: uint64(block.number) }); emit Anchored(root, batchId, leafCount, msg.sender, block.timestamp); } /// @notice True if `root` has been anchored. function isAnchored(bytes32 root) external view returns (bool) { return roots[root] != 0; } // ------------------------------------------------------------------------------------------------ // Verification // ------------------------------------------------------------------------------------------------ /// @notice Recompute a Merkle root from an item hash and its inclusion proof and compare with `root`. /// @dev Pure: does not check that `root` is anchored - use {verifyAnchored} for that. /// @param leafHash The 32-byte item hash, e.g. SHA-256(record ciphertext). The 0x00 leaf prefix is applied here. /// @param proof Sibling hashes from the leaf level upwards (levels where the node was promoted are omitted). /// @param isLeft isLeft[i] is true when proof[i] is the LEFT sibling (wallet proof position 'L'). /// @param root Expected Merkle root. /// @return valid True if the folded proof equals `root`. function verifyProof(bytes32 leafHash, bytes32[] calldata proof, bool[] calldata isLeft, bytes32 root) public pure returns (bool valid) { if (proof.length != isLeft.length) revert LengthMismatch(); bytes32 computed = sha256(abi.encodePacked(bytes1(0x00), leafHash)); for (uint256 i = 0; i < proof.length; ++i) { computed = isLeft[i] ? sha256(abi.encodePacked(bytes1(0x01), proof[i], computed)) : sha256(abi.encodePacked(bytes1(0x01), computed, proof[i])); } return computed == root; } /// @notice Verify an inclusion proof AND that the root is anchored. /// @return valid True if the proof folds to `root`. /// @return anchoredAt Block timestamp of the root (0 if not anchored). function verifyAnchored(bytes32 leafHash, bytes32[] calldata proof, bool[] calldata isLeft, bytes32 root) external view returns (bool valid, uint256 anchoredAt) { valid = verifyProof(leafHash, proof, isLeft, root); anchoredAt = roots[root]; } // ------------------------------------------------------------------------------------------------ // Roles & ownership // ------------------------------------------------------------------------------------------------ /// @notice Grant or revoke the anchorer role. function setAnchorer(address account, bool allowed) external onlyOwner { if (account == address(0)) revert ZeroAddress(); isAnchorer[account] = allowed; emit AnchorerSet(account, allowed); } /// @notice Start a two-step ownership transfer. function transferOwnership(address newOwner) external onlyOwner { if (newOwner == address(0)) revert ZeroAddress(); pendingOwner = newOwner; emit OwnershipTransferStarted(owner, newOwner); } /// @notice Accept a pending ownership transfer. function acceptOwnership() external { if (msg.sender != pendingOwner) revert NotPendingOwner(); emit OwnershipTransferred(owner, msg.sender); owner = msg.sender; pendingOwner = address(0); } }