diff --git a/src/libs/LibValueTicket.sol b/src/libs/LibValueTicket.sol new file mode 100644 index 0000000..1cca56d --- /dev/null +++ b/src/libs/LibValueTicket.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {LibSignal} from "../libs/LibSignal.sol"; +import {SlotDerivation} from "@openzeppelin/contracts/utils/SlotDerivation.sol"; +import {StorageSlot} from "@openzeppelin/contracts/utils/StorageSlot.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +/// @dev Library to create and verify value tickets per chain (source and destination), nonce, sender, receiver. +/// +/// Tickets can be used to bridge ETH or standard tokens (e.g. ERC20, ERC271, ERC1155) with the only condition of having +/// the same contract deployed on both chains and a trusted source of a state root. +library LibValueTicket { + using SafeCast for uint256; + using StorageSlot for bytes32; + using LibSignal for *; + using SlotDerivation for *; + + struct ValueTicket { + uint64 chainId; + uint64 nonce; + address from; + address to; + uint256 value; + } + + /// @dev The ticket is not valid (i.e. couldn't be verified) + error InvalidTicket(); + + /// @dev Unique ticket identifier. + function id(ValueTicket memory ticket) internal pure returns (bytes32 _id) { + return keccak256(abi.encode(ticket)); + } + + /// @dev Verifies that a ticket created with `nonce` by the receiver (`from`) is valid for the receiver (`to`) + /// to claim `value` on this chain. It does so by performing an storage proof of `address(this)` on the source chain + /// using `accountProof` and validating it against the network state `root` using `proof`. + /// The `root` MUST be trusted. + function verifyTicket(ValueTicket memory ticket, bytes32 root, bytes[] memory accountProof, bytes[] memory proof) + internal + view + returns (bool verified, bytes32 _id) + { + _id = id(ticket); + (verified,) = address(this).verifySignal(root, ticket.chainId, _id, accountProof, proof); + return (verified, _id); + } + + /// @dev Creates a ticket with `msg.value` ETH for the receiver (`to`) to claim on the `destinationChainId`. + function createTicket(uint64 destinationChainId, address from, address to, uint256 value) + internal + returns (ValueTicket memory ticket) + { + ticket = ValueTicket(destinationChainId, _useNonce(from).toUint64(), from, to, value); + bytes32 _id = id(ticket); + _id.signal(); + return ticket; + } + + /// @dev Reverts if a ticket was not created by `from` with `nonce` on `chainId`. See `verifyTicket`. + function checkTicket(ValueTicket memory ticket, bytes32 root, bytes[] memory accountProof, bytes[] memory proof) + internal + view + returns (bytes32) + { + (bool valid, bytes32 _id) = verifyTicket(ticket, root, accountProof, proof); + require(valid, InvalidTicket()); + return _id; + } + + /// @dev Consumes a nonce and returns the current value and increments nonce. + function _useNonce(address account) private returns (uint256) { + // For each account, the nonce has an initial value of 0, can only be incremented by one, and cannot be + // decremented or reset. This guarantees that the nonce never overflows. + + unchecked { + // It is important to do x++ and not ++x here. + // slot: keccak256(abi.encode(uint256(keccak256("LibValueTicket.nonces")) - 1)) & ~bytes32(uint256(0xff)) + return 0x23c95d7a21dec6ba744555d361d2572ad62017f33fd3da51a4ffa8cde254e900.deriveMapping(account) + .getUint256Slot().value++; + } + } +} diff --git a/src/protocol/CommitmentSyncer.sol b/src/protocol/CommitmentSyncer.sol index bc41dad..69bffc6 100644 --- a/src/protocol/CommitmentSyncer.sol +++ b/src/protocol/CommitmentSyncer.sol @@ -39,7 +39,7 @@ abstract contract CommitmentSyncer is ICommitmentSyncer { } /// @inheritdoc ICommitmentSyncer - function id(uint64 chainId, uint64 height, bytes32 commitment) public pure virtual returns (bytes32 value) { + function commitmentId(uint64 chainId, uint64 height, bytes32 commitment) public pure virtual returns (bytes32 id) { return keccak256(abi.encodePacked(chainId, height, commitment)); } @@ -48,10 +48,10 @@ abstract contract CommitmentSyncer is ICommitmentSyncer { public view virtual - returns (bool valid, bytes32 value) + returns (bool valid, bytes32 id) { - value = id(chainId, height, commitment); - return (LibTrieProof.verifyState(value.deriveSlot(), value, root, proof), value); + id = commitmentId(chainId, height, commitment); + return (LibTrieProof.verifyState(id.deriveSlot(), id, root, proof), id); } /// @inheritdoc ICommitmentSyncer @@ -75,17 +75,18 @@ abstract contract CommitmentSyncer is ICommitmentSyncer { virtual onlyCheckpointTracker { - _checkCommitment(chainId, height, commitment, root, proof); - _syncCommitment(chainId, height, commitment); + _syncCommitment(_checkCommitment(chainId, height, commitment, root, proof), chainId, height, commitment); } - /// @dev Internal version of `syncCommitment` without access control and without verifying the commitment. + /// @dev Internal version of `syncCommitment` without access control. /// Emits `CommitmentSynced` if the provided `height` is larger than `latestHeight` for `chainId`. - function _syncCommitment(uint64 chainId, uint64 height, bytes32 commitment) internal virtual { + function _syncCommitment(bytes32 id, uint64 chainId, uint64 height, bytes32 commitment) internal virtual { if (latestHeight(chainId) < height) { _latestHeight[chainId] = height; _commitment[chainId][height] = commitment; - id(chainId, height, commitment).signal(); + // Invariant: + // assert(id == commitmentId(chainId, height, commitment)); + id.signal(); emit CommitmentSynced(chainId, height, commitment); } } @@ -94,10 +95,10 @@ abstract contract CommitmentSyncer is ICommitmentSyncer { function _checkCommitment(uint64 chainId, uint64 height, bytes32 commitment, bytes32 root, bytes[] calldata proof) internal virtual - returns (bytes32 value) + returns (bytes32 id) { bool valid; - (valid, value) = verifyCommitment(chainId, height, commitment, root, proof); + (valid, id) = verifyCommitment(chainId, height, commitment, root, proof); require(valid, InvalidCommitment()); } diff --git a/src/protocol/ETHBridge.sol b/src/protocol/ETHBridge.sol new file mode 100644 index 0000000..8a9d635 --- /dev/null +++ b/src/protocol/ETHBridge.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {LibTrieProof} from "../libs/LibTrieProof.sol"; + +import {LibValueTicket} from "../libs/LibValueTicket.sol"; +import {IETHBridge} from "./IETHBridge.sol"; + +/// @dev Bridge implementation to send native ETH to other chains using storage proofs. +/// +/// IMPORTANT: No recovery mechanism is implemented in case an account creates a ticket that can't be claimed. Consider +/// implementing one on top of this bridge for more specific use cases. +contract ETHBridge is IETHBridge { + using LibValueTicket for LibValueTicket.ValueTicket; + + mapping(bytes32 id => bool) _claimed; + + /// @inheritdoc IETHBridge + function claimed(bytes32 id) public view virtual returns (bool) { + return _claimed[id]; + } + + /// @inheritdoc IETHBridge + function ticketId(LibValueTicket.ValueTicket memory ticket) public view virtual returns (bytes32 id) { + return ticket.id(); + } + + /// @inheritdoc IETHBridge + function verifyTicket( + LibValueTicket.ValueTicket memory ticket, + bytes32 root, + bytes[] memory accountProof, + bytes[] memory proof + ) public view virtual returns (bool verified, bytes32 id) { + return ticket.verifyTicket(root, accountProof, proof); + } + + /// @inheritdoc IETHBridge + function createTicket(uint64 chainId, address to) external payable virtual { + emit ETHTicket(LibValueTicket.createTicket(chainId, msg.sender, to, msg.value)); + } + + /// @inheritdoc IETHBridge + function claimTicket( + LibValueTicket.ValueTicket memory ticket, + bytes32 root, + bytes[] memory accountProof, + bytes[] memory proof + ) external virtual { + bytes32 id_ = ticket.checkTicket(root, accountProof, proof); + require(!claimed(id_), AlreadyClaimed()); + _claimed[id_] = true; + _sendETH(ticket.to, ticket.value); + emit ETHTicketClaimed(ticket); + } + + /// @dev Function to transfer ETH to the receiver but ignoring the returndata. + function _sendETH(address to, uint256 value) private returns (bool success) { + assembly ("memory-safe") { + success := call(gas(), to, value, 0, 0, 0, 0) + } + require(success, FailedClaim()); + } +} diff --git a/src/protocol/ICommitmentSyncer.sol b/src/protocol/ICommitmentSyncer.sol index 3d16184..fc68655 100644 --- a/src/protocol/ICommitmentSyncer.sol +++ b/src/protocol/ICommitmentSyncer.sol @@ -15,14 +15,14 @@ interface ICommitmentSyncer { error InvalidCommitment(); /// @dev Commitment identifier. - function id(uint64 chainId, uint64 height, bytes32 commitment) external pure returns (bytes32 value); + function commitmentId(uint64 chainId, uint64 height, bytes32 commitment) external pure returns (bytes32 id); /// @dev Verifies that a `commitment` is valid for the provided `chainId`, `height` and `root`. /// The `root` MUST be trusted. function verifyCommitment(uint64 chainId, uint64 height, bytes32 commitment, bytes32 root, bytes[] calldata proof) external view - returns (bool valid, bytes32 value); + returns (bool valid, bytes32 id); /// @dev Get commitment at a particular `height` for `chainId`. function commitmentAt(uint64 chainId, uint64 height) external view returns (bytes32 commitment); diff --git a/src/protocol/IETHBridge.sol b/src/protocol/IETHBridge.sol new file mode 100644 index 0000000..2860053 --- /dev/null +++ b/src/protocol/IETHBridge.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {LibValueTicket} from "../libs/LibValueTicket.sol"; + +/// @dev Bridges native value (i.e. ETH) by creating and verifying tickets using the `LibValueTicket`. +/// +/// These can be created by sending value to the `createTicket` function. Later, the receiver can claim the ticket on +/// the destination chain by using a storage proof. +/// +/// ETH bridge MUST be deployed at the same address on both chains. +interface IETHBridge { + /// @dev Sender (`from`) sent `value` with `nonce` to the receiver (`to`). Claimable on `chainId`. + event ETHTicket(LibValueTicket.ValueTicket ticket); + + /// @dev A ticket was claimed. + event ETHTicketClaimed(LibValueTicket.ValueTicket ticket); + + /// @dev Failed to call the receiver with value. + error FailedClaim(); + + /// @dev Ticket was already claimed. + error AlreadyClaimed(); + + /// @dev Whether the ticket identified by `id` has been claimed. + function claimed(bytes32 id) external view returns (bool); + + /// @dev Ticket identifier. + function ticketId(LibValueTicket.ValueTicket memory ticket) external view returns (bytes32 id); + + /// @dev Verifies if a ticket defined created on the `chainId` with `nonce` by the receiver (`from`) is valid + /// for the receiver `to` to claim `value` on this chain by performing an storage proof of this bridge address using + /// `accountProof` and validating it against the network state root using `proof`. + function verifyTicket( + LibValueTicket.ValueTicket memory ticket, + bytes32 root, + bytes[] memory accountProof, + bytes[] memory proof + ) external view returns (bool verified, bytes32 id); + + /// @dev Creates a ticket with `msg.value` ETH for the receiver (`to`) to claim on the `chainId`. + function createTicket(uint64 chainId, address to) external payable; + + /// @dev Claims a ticket created on `chainId` by the sender (`from`) with `nonce`. The `value` ETH claimed is + /// sent to the receiver (`to`) after verifying the proofs. See `verifyTicket`. + function claimTicket( + LibValueTicket.ValueTicket memory ticket, + bytes32 root, + bytes[] memory accountProof, + bytes[] memory proof + ) external; +} diff --git a/src/protocol/ISignalService.sol b/src/protocol/ISignalService.sol new file mode 100644 index 0000000..f542946 --- /dev/null +++ b/src/protocol/ISignalService.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/// @title ISignalService +/// @notice Interface for the SignalService contract. +interface ISignalService { + error SignalNotReceived(); + error CallerNotAuthorised(); + + event SignalsReceived(bytes32[] signalSlots); + + function sendSignal(bytes32 value) external returns (bytes32); + + function receiveSignals(bytes32[] calldata signalSlots) external; + + function verifySignal( + address account, + bytes32 root, + uint64 chainId, + bytes32 value, + bytes[] memory accountProof, + bytes[] memory storageProof + ) external view; + + function isSignalSent(bytes32 signal) external view returns (bool); +} diff --git a/src/protocol/SignalService.sol b/src/protocol/SignalService.sol new file mode 100644 index 0000000..99f0088 --- /dev/null +++ b/src/protocol/SignalService.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {LibSignal} from "../libs/LibSignal.sol"; +import {ISignalService} from "./ISignalService.sol"; +import {StorageSlot} from "@openzeppelin/contracts/utils/StorageSlot.sol"; + +/// @title SignalService +/// @notice A minimal implementation of a signal service using LibSignal. +contract SignalService is ISignalService { + using LibSignal for bytes32; + using StorageSlot for bytes32; + + address internal _rollup; + + mapping(bytes32 signal => bool isReceived) internal _receivedSignals; + + constructor(address rollup_) { + _rollup = rollup_; + } + + modifier onlyRollup() { + require(msg.sender == _rollup, CallerNotAuthorised()); + _; + } + + /// @dev Only required to be called on L1 + function sendSignal(bytes32 value) external returns (bytes32) { + return value.signal(); + } + + /// @dev Only required to be called on L2 + function receiveSignals(bytes32[] calldata signalSlots) external onlyRollup { + for (uint256 i; i < signalSlots.length; ++i) { + _receivedSignals[signalSlots[i]] = true; + } + emit SignalsReceived(signalSlots); + } + + /// @dev Only required to be called on L2 + function verifySignal( + address account, + bytes32 root, + uint64 chainId, + bytes32 value, + bytes[] memory accountProof, + bytes[] memory storageProof + ) external view { + if (accountProof.length == 0) { + require(_receivedSignals[LibSignal.deriveSlot(chainId, account, value)], SignalNotReceived()); + return; + } + + (bool valid,) = LibSignal.verifySignal(account, root, chainId, value, accountProof, storageProof); + require(valid, SignalNotReceived()); + } + + /// @dev Only required to be called on L1 + function isSignalSent(bytes32 signal) external view returns (bool) { + // This will return `false` when the signal itself is 0 + return signal.getBytes32Slot().value != 0; + } +} diff --git a/src/protocol/taiko_alethia/TaikoAnchor.sol b/src/protocol/taiko_alethia/TaikoAnchor.sol index d23aefc..9b3b7eb 100644 --- a/src/protocol/taiko_alethia/TaikoAnchor.sol +++ b/src/protocol/taiko_alethia/TaikoAnchor.sol @@ -1,11 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; +import {ISignalService} from "../ISignalService.sol"; + contract TaikoAnchor { event Anchor(uint256 publicationId, uint256 anchorBlockId, bytes32 anchorBlockHash, bytes32 parentGasUsed); uint256 public immutable fixedBaseFee; address public immutable permittedSender; // 0x0000777735367b36bC9B61C50022d9D0700dB4Ec + ISignalService public immutable signalService; uint256 public lastAnchorBlockId; uint256 public lastPublicationId; @@ -19,10 +22,11 @@ contract TaikoAnchor { } // This constructor is only used in test as the contract will be pre-deployed in the L2 genesis - constructor(uint256 _fixedBaseFee, address _permittedSender) { + constructor(uint256 _fixedBaseFee, address _permittedSender, address _signalService) { require(_fixedBaseFee > 0, "fixedBaseFee must be greater than 0"); fixedBaseFee = _fixedBaseFee; permittedSender = _permittedSender; + signalService = ISignalService(_signalService); uint256 parentId = block.number - 1; blockHashes[parentId] = blockhash(parentId); @@ -38,10 +42,14 @@ contract TaikoAnchor { /// @param _anchorBlockId The latest L1 block known to the L2 blocks in this publication /// @param _anchorBlockHash The block hash of the L1 anchor block /// @param _parentGasUsed The gas used in the parent block - function anchor(uint256 _publicationId, uint256 _anchorBlockId, bytes32 _anchorBlockHash, bytes32 _parentGasUsed) - external - onlyFromPermittedSender - { + /// @param _signalSlots fast signals manually added by the proposer + function anchor( + uint256 _publicationId, + uint256 _anchorBlockId, + bytes32 _anchorBlockHash, + bytes32 _parentGasUsed, + bytes32[] calldata _signalSlots + ) external onlyFromPermittedSender { // Make sure this function can only succeed once per publication require(_publicationId > lastPublicationId, "publicationId too small"); lastPublicationId = _publicationId; @@ -65,6 +73,8 @@ contract TaikoAnchor { require(circularBlocksHash == currentHash, "circular hash mismatch"); circularBlocksHash = newHash; + signalService.receiveSignals(_signalSlots); + _verifyBaseFee(_parentGasUsed); emit Anchor(_publicationId, _anchorBlockId, _anchorBlockHash, _parentGasUsed); diff --git a/src/protocol/taiko_alethia/TaikoInbox.sol b/src/protocol/taiko_alethia/TaikoInbox.sol index e840068..712bd65 100644 --- a/src/protocol/taiko_alethia/TaikoInbox.sol +++ b/src/protocol/taiko_alethia/TaikoInbox.sol @@ -5,6 +5,8 @@ import {IBlobRefRegistry} from "../../blobs/IBlobRefRegistry.sol"; import {IPublicationFeed} from "../IPublicationFeed.sol"; +import {ISignalService} from "../ISignalService.sol"; + import {IDelayedInclusionStore} from "./IDelayedInclusionStore.sol"; import {ILookahead} from "./ILookahead.sol"; @@ -13,12 +15,14 @@ contract TaikoInbox { uint256 anchorBlockId; bytes32 anchorBlockHash; bool isDelayedInclusion; + bytes32[] signalSlots; } IPublicationFeed public immutable publicationFeed; ILookahead public immutable lookahead; IBlobRefRegistry public immutable blobRefRegistry; IDelayedInclusionStore public immutable delayedInclusionStore; + ISignalService public immutable signalService; uint256 public immutable maxAnchorBlockIdOffset; @@ -34,16 +38,18 @@ contract TaikoInbox { address _lookahead, address _blobRefRegistry, address _delayedInclusionStore, + address _signalService, uint256 _maxAnchorBlockIdOffset ) { publicationFeed = IPublicationFeed(_publicationFeed); lookahead = ILookahead(_lookahead); blobRefRegistry = IBlobRefRegistry(_blobRefRegistry); delayedInclusionStore = IDelayedInclusionStore(_delayedInclusionStore); + signalService = ISignalService(_signalService); maxAnchorBlockIdOffset = _maxAnchorBlockIdOffset; } - function publish(uint256 nBlobs, uint64 anchorBlockId) external { + function publish(uint256 nBlobs, uint64 anchorBlockId, bytes32[] calldata signalSlots) external { if (address(lookahead) != address(0)) { require(lookahead.isCurrentPreconfer(msg.sender), "not current preconfer"); } @@ -53,10 +59,13 @@ contract TaikoInbox { // Build the attribute for the anchor transaction inputs require(anchorBlockId >= block.number - maxAnchorBlockIdOffset, "anchorBlockId too old"); + _verifySignalSlots(signalSlots); + Metadata memory metadata = Metadata({ anchorBlockId: anchorBlockId, anchorBlockHash: blockhash(anchorBlockId), - isDelayedInclusion: false + isDelayedInclusion: false, + signalSlots: signalSlots }); require(metadata.anchorBlockHash != 0, "blockhash not found"); @@ -89,4 +98,10 @@ contract TaikoInbox { blobIndices[i] = i; } } + + function _verifySignalSlots(bytes32[] calldata signalSlots) private view { + for (uint256 i; i < signalSlots.length; ++i) { + require(signalService.isSignalSent(signalSlots[i]), "signal not sent"); + } + } }