diff --git a/packages/protocol/contract_layout_layer1.md b/packages/protocol/contract_layout_layer1.md index b0c2c85963..7b0e122e63 100644 --- a/packages/protocol/contract_layout_layer1.md +++ b/packages/protocol/contract_layout_layer1.md @@ -56,3 +56,7 @@ ## ForkRouter +## contracts/layer1/forced-inclusion/TaikoWrapper + +## contracts/layer1/forced-inclusion/ForcedInclusionStore + diff --git a/packages/protocol/contracts/layer1/based/ITaikoInbox.sol b/packages/protocol/contracts/layer1/based/ITaikoInbox.sol index 2d2f17a970..f9faad4b88 100644 --- a/packages/protocol/contracts/layer1/based/ITaikoInbox.sol +++ b/packages/protocol/contracts/layer1/based/ITaikoInbox.sol @@ -281,7 +281,7 @@ interface ITaikoInbox { error MsgValueNotZero(); error NoBlocksToProve(); error NotFirstProposal(); - error NotInboxOperator(); + error NotWhitelistedProposer(); error ParentMetaHashMismatch(); error SameTransition(); error SignalNotSent(); diff --git a/packages/protocol/contracts/layer1/based/TaikoInbox.sol b/packages/protocol/contracts/layer1/based/TaikoInbox.sol index b01b6ec07f..90ed561153 100644 --- a/packages/protocol/contracts/layer1/based/TaikoInbox.sol +++ b/packages/protocol/contracts/layer1/based/TaikoInbox.sol @@ -69,15 +69,15 @@ abstract contract TaikoInbox is EssentialContract, ITaikoInbox, ITaiko { BatchParams memory params = abi.decode(_params, (BatchParams)); { - address operator = resolve(LibStrings.B_INBOX_OPERATOR, true); - if (operator == address(0)) { + address whitelistedProposer = resolve(LibStrings.B_WHITELISTED_PROPOSER, true); + if (whitelistedProposer == address(0)) { require(params.proposer == address(0), CustomProposerNotAllowed()); params.proposer = msg.sender; // blob hashes are only accepted if the caller is trusted. require(params.blobParams.blobHashes.length == 0, InvalidBlobParams()); } else { - require(msg.sender == operator, NotInboxOperator()); + require(msg.sender == whitelistedProposer, NotWhitelistedProposer()); require(params.proposer != address(0), CustomProposerMissing()); } diff --git a/packages/protocol/contracts/layer1/forced-inclusion/ForcedInclusionStore.sol b/packages/protocol/contracts/layer1/forced-inclusion/ForcedInclusionStore.sol new file mode 100644 index 0000000000..0f865b5235 --- /dev/null +++ b/packages/protocol/contracts/layer1/forced-inclusion/ForcedInclusionStore.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "src/shared/common/EssentialContract.sol"; +import "src/shared/libs/LibMath.sol"; +import "src/shared/libs/LibAddress.sol"; +import "src/shared/libs/LibStrings.sol"; +import "src/layer1/based/ITaikoInbox.sol"; +import "./IForcedInclusionStore.sol"; + +/// @title ForcedInclusionStore +/// @dev A contract for storing and managing forced inclusion requests. Forced inclusions allow +/// users to pay a fee +/// to ensure their transactions are included in a block. The contract maintains a FIFO queue +/// of inclusion requests. +/// @custom:security-contact +contract ForcedInclusionStore is EssentialContract, IForcedInclusionStore { + using LibAddress for address; + using LibMath for uint256; + + uint256 private constant SECONDS_PER_BLOCK = 12; + + uint8 public immutable inclusionDelay; + uint64 public immutable feeInGwei; + + mapping(uint256 id => ForcedInclusion inclusion) public queue; // slot 1 + uint64 public head; // slot 2 + uint64 public tail; + uint64 public lastProcessedAtBatchId; + uint64 private __reserved1; + + uint256[48] private __gap; + + constructor( + address _resolver, + uint8 _inclusionDelay, + uint64 _feeInGwei + ) + EssentialContract(_resolver) + { + require(_inclusionDelay != 0 && _inclusionDelay % SECONDS_PER_BLOCK == 0, InvalidParams()); + require(_feeInGwei != 0, InvalidParams()); + + inclusionDelay = _inclusionDelay; + feeInGwei = _feeInGwei; + } + + function init(address _owner) external initializer { + __Essential_init(_owner); + } + + function storeForcedInclusion( + uint8 blobIndex, + uint32 blobByteOffset, + uint32 blobByteSize + ) + external + payable + nonReentrant + { + bytes32 blobHash = _blobHash(blobIndex); + require(blobHash != bytes32(0), BlobNotFound()); + require(msg.value == feeInGwei * 1 gwei, IncorrectFee()); + + ITaikoInbox inbox = ITaikoInbox(resolve(LibStrings.B_TAIKO, false)); + + ForcedInclusion memory inclusion = ForcedInclusion({ + blobHash: blobHash, + feeInGwei: uint64(msg.value / 1 gwei), + createdAtBatchId: inbox.getStats2().numBatches, + blobByteOffset: blobByteOffset, + blobByteSize: blobByteSize + }); + + queue[tail++] = inclusion; + + emit ForcedInclusionStored(inclusion); + } + + function consumeOldestForcedInclusion(address _feeRecipient) + external + nonReentrant + onlyFromNamed(LibStrings.B_TAIKO_WRAPPER) + returns (ForcedInclusion memory inclusion_) + { + // we only need to check the first one, since it will be the oldest. + uint64 _head = head; + ForcedInclusion storage inclusion = queue[_head]; + require(inclusion.createdAtBatchId != 0, NoForcedInclusionFound()); + + ITaikoInbox inbox = ITaikoInbox(resolve(LibStrings.B_TAIKO, false)); + + inclusion_ = inclusion; + delete queue[_head]; + + unchecked { + lastProcessedAtBatchId = inbox.getStats2().numBatches; + head = _head + 1; + } + + emit ForcedInclusionConsumed(inclusion_); + _feeRecipient.sendEtherAndVerify(inclusion_.feeInGwei * 1 gwei); + } + + function getForcedInclusion(uint256 index) external view returns (ForcedInclusion memory) { + return queue[index]; + } + + function getOldestForcedInclusionDeadline() public view returns (uint256) { + unchecked { + ForcedInclusion storage inclusion = queue[head]; + return inclusion.createdAtBatchId == 0 + ? type(uint64).max + : uint256(lastProcessedAtBatchId).max(inclusion.createdAtBatchId) + inclusionDelay; + } + } + + function isOldestForcedInclusionDue() external view returns (bool) { + ITaikoInbox inbox = ITaikoInbox(resolve(LibStrings.B_TAIKO, false)); + return inbox.getStats2().numBatches >= getOldestForcedInclusionDeadline(); + } + + // @dev Override this function for easier testing blobs + function _blobHash(uint8 blobIndex) internal view virtual returns (bytes32) { + return blobhash(blobIndex); + } +} diff --git a/packages/protocol/contracts/layer1/forced-inclusion/IForcedInclusionStore.sol b/packages/protocol/contracts/layer1/forced-inclusion/IForcedInclusionStore.sol new file mode 100644 index 0000000000..dae28021a5 --- /dev/null +++ b/packages/protocol/contracts/layer1/forced-inclusion/IForcedInclusionStore.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title IForcedInclusionStore +/// @custom:security-contact security@taiko.xyz +interface IForcedInclusionStore { + /// @dev Error thrown when a blob is not found. + error BlobNotFound(); + /// @dev Error thrown when the parameters are invalid. + error InvalidParams(); + /// @dev Error thrown when the fee is incorrect. + error IncorrectFee(); + + error NoForcedInclusionFound(); + + /// @dev Event emitted when a forced inclusion is stored. + event ForcedInclusionStored(ForcedInclusion forcedInclusion); + /// @dev Event emitted when a forced inclusion is consumed. + event ForcedInclusionConsumed(ForcedInclusion forcedInclusion); + + struct ForcedInclusion { + bytes32 blobHash; + uint64 feeInGwei; + uint64 createdAtBatchId; + uint32 blobByteOffset; + uint32 blobByteSize; + } + + /// @dev Retrieve a forced inclusion request by its index. + /// @param index The index of the forced inclusion request in the queue. + /// @return The forced inclusion request at the specified index. + function getForcedInclusion(uint256 index) external view returns (ForcedInclusion memory); + + /// @dev Get the deadline for the oldest forced inclusion. + /// @return The deadline for the oldest forced inclusion. + function getOldestForcedInclusionDeadline() external view returns (uint256); + + /// @dev Check if the oldest forced inclusion is due. + /// @return True if the oldest forced inclusion is due, false otherwise. + function isOldestForcedInclusionDue() external view returns (bool); + + /// @dev Consume a forced inclusion request. + /// The inclusion request must be marked as processed and the priority fee must be paid to the + /// caller. + /// @param _feeRecipient The address to receive the priority fee. + /// @return inclusion_ The forced inclusion request. + function consumeOldestForcedInclusion(address _feeRecipient) + external + returns (ForcedInclusion memory); + + /// @dev Store a forced inclusion request. + /// The priority fee must be paid to the contract. + /// @param blobIndex The index of the blob that contains the transaction data. + /// @param blobByteOffset The byte offset in the blob + /// @param blobByteSize The size of the blob in bytes + function storeForcedInclusion( + uint8 blobIndex, + uint32 blobByteOffset, + uint32 blobByteSize + ) + external + payable; +} diff --git a/packages/protocol/contracts/layer1/forced-inclusion/TaikoWrapper.sol b/packages/protocol/contracts/layer1/forced-inclusion/TaikoWrapper.sol new file mode 100644 index 0000000000..6f5b02d401 --- /dev/null +++ b/packages/protocol/contracts/layer1/forced-inclusion/TaikoWrapper.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "src/shared/common/EssentialContract.sol"; +import "src/shared/based/ITaiko.sol"; +import "src/shared/libs/LibMath.sol"; +import "src/shared/libs/LibNetwork.sol"; +import "src/shared/libs/LibStrings.sol"; +import "src/shared/signal/ISignalService.sol"; +import "src/layer1/verifiers/IVerifier.sol"; +import "src/layer1/based/TaikoInbox.sol"; +import "./ForcedInclusionStore.sol"; + +/// @title TaikoWrapper +/// @dev This contract is part of a delayed inbox implementation to enforce the inclusion of +/// transactions. +/// The current design is a simplified and can be improved with the following ideas: +/// 1. **Fee-Based Request Prioritization**: +/// - Proposers can selectively fulfill pending requests based on transaction fees. +/// - Requests not yet due can be processed earlier if fees are attractive, incentivizing timely +/// execution. +/// +/// 2. **Rate Limit Control**: +/// - A rate-limiting mechanism ensures a minimum interval of 12*N seconds between request +/// fulfillments. +/// - Prevents proposers from being overwhelmed during high request volume, ensuring system +/// stability. +/// +/// 3. **Calldata and Blob Support**: +/// - Supports both calldata and blobs in the transaction list. +/// +/// 4. **Gas-Efficient Request Storage**: +/// - Avoids storing full request data in contract storage. +/// - Saves only the request hash and its timestamp. +/// - Leverages Ethereum events to store request details off-chain. +/// - Proposers can reconstruct requests as needed, minimizing on-chain storage and gas +/// consumption. +/// +/// @custom:security-contact security@taiko.xyz + +contract TaikoWrapper is EssentialContract { + using LibMath for uint256; + + /// @dev Event emitted when a forced inclusion is processed. + event ForcedInclusionProcessed(IForcedInclusionStore.ForcedInclusion); + /// @dev Error thrown when the oldest forced inclusion is due. + + error OldestForcedInclusionDue(); + + uint16 public constant MAX_FORCED_TXS_PER_FORCED_INCLUSION = 512; + + uint256[50] private __gap; + + constructor(address _resolver) EssentialContract(_resolver) { } + + function init(address _owner) external initializer { + __Essential_init(_owner); + } + + /// @notice Proposes a batch of blocks with forced inclusion. + /// @param _forcedInclusionParams An optional ABI-encoded BlockParams for the forced inclusion + /// batch. + /// @param _params ABI-encoded BlockParams. + /// @param _txList The transaction list in calldata. If the txList is empty, blob will be used + /// for data availability. + /// @return info_ The info of the proposed batch. + /// @return meta_ The metadata of the proposed batch. + function proposeBatchWithForcedInclusion( + bytes calldata _forcedInclusionParams, + bytes calldata _params, + bytes calldata _txList + ) + external + nonReentrant + returns (ITaikoInbox.BatchInfo memory info_, ITaikoInbox.BatchMetadata memory meta_) + { + ITaikoInbox inbox = ITaikoInbox(resolve(LibStrings.B_TAIKO, false)); + + IForcedInclusionStore store = + IForcedInclusionStore(resolve(LibStrings.B_FORCED_INCLUSION_STORE, false)); + + if (_forcedInclusionParams.length == 0) { + require(!store.isOldestForcedInclusionDue(), OldestForcedInclusionDue()); + } else { + IForcedInclusionStore.ForcedInclusion memory inclusion = + store.consumeOldestForcedInclusion(msg.sender); + + ITaikoInbox.BatchParams memory params = + abi.decode(_forcedInclusionParams, (ITaikoInbox.BatchParams)); + + // Overwrite the batch params to have only 1 block and up to + // MAX_FORCED_TXS_PER_FORCED_INCLUSION transactions + if (params.blocks.length == 0) { + params.blocks = new ITaikoInbox.BlockParams[](1); + } + + if (params.blocks[0].numTransactions < MAX_FORCED_TXS_PER_FORCED_INCLUSION) { + params.blocks[0].numTransactions = MAX_FORCED_TXS_PER_FORCED_INCLUSION; + } + + params.blobParams.blobHashes = new bytes32[](1); + params.blobParams.blobHashes[0] = inclusion.blobHash; + params.blobParams.byteOffset = inclusion.blobByteOffset; + params.blobParams.byteSize = inclusion.blobByteSize; + + inbox.proposeBatch(abi.encode(params), ""); + emit ForcedInclusionProcessed(inclusion); + } + + (info_, meta_) = inbox.proposeBatch(_params, _txList); + } +} diff --git a/packages/protocol/contracts/layer1/preconf/impl/PreconfRouter.sol b/packages/protocol/contracts/layer1/preconf/impl/PreconfRouter.sol index bd0f4fb942..6f80c6f7c1 100644 --- a/packages/protocol/contracts/layer1/preconf/impl/PreconfRouter.sol +++ b/packages/protocol/contracts/layer1/preconf/impl/PreconfRouter.sol @@ -1,11 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; +import "src/shared/common/EssentialContract.sol"; +import "src/shared/libs/LibStrings.sol"; +import "src/layer1/based/TaikoInbox.sol"; +import "src/layer1/forced-inclusion/TaikoWrapper.sol"; import "../iface/IPreconfRouter.sol"; import "../iface/IPreconfWhitelist.sol"; -import "src/layer1/based/ITaikoInbox.sol"; -import "src/shared/libs/LibStrings.sol"; -import "src/shared/common/EssentialContract.sol"; /// @title PreconfRouter /// @custom:security-contact security@taiko.xyz @@ -20,7 +21,7 @@ contract PreconfRouter is EssentialContract, IPreconfRouter { /// @inheritdoc IPreconfRouter function proposePreconfedBlocks( - bytes calldata, + bytes calldata _forcedInclusionParams, bytes calldata _batchParams, bytes calldata _batchTxList ) @@ -32,9 +33,18 @@ contract PreconfRouter is EssentialContract, IPreconfRouter { IPreconfWhitelist(resolve(LibStrings.B_PRECONF_WHITELIST, false)).getOperatorForEpoch(); require(msg.sender == selectedOperator, NotTheOperator()); - // Call the proposeBatch function on the TaikoInbox - address taikoInbox = resolve(LibStrings.B_TAIKO, false); - (, meta_) = ITaikoInbox(taikoInbox).proposeBatch(_batchParams, _batchTxList); + // check if we have a forced inclusion inbox + address wrapper = resolve(LibStrings.B_TAIKO_WRAPPER, true); + if (wrapper == address(0)) { + // Call the proposeBatch function on the TaikoInbox + address taikoInbox = resolve(LibStrings.B_TAIKO, false); + (, meta_) = ITaikoInbox(taikoInbox).proposeBatch(_batchParams, _batchTxList); + } else { + // Call the proposeBatchWithForcedInclusion function on the ForcedInclusionInbox + (, meta_) = TaikoWrapper(wrapper).proposeBatchWithForcedInclusion( + _forcedInclusionParams, _batchParams, _batchTxList + ); + } // Verify that the sender had set itself as the proposer require(meta_.proposer == msg.sender, ProposerIsNotTheSender()); diff --git a/packages/protocol/contracts/shared/libs/LibStrings.sol b/packages/protocol/contracts/shared/libs/LibStrings.sol index 7f119da566..f629f1a42b 100644 --- a/packages/protocol/contracts/shared/libs/LibStrings.sol +++ b/packages/protocol/contracts/shared/libs/LibStrings.sol @@ -15,7 +15,8 @@ library LibStrings { bytes32 internal constant B_ERC1155_VAULT = bytes32("erc1155_vault"); bytes32 internal constant B_ERC20_VAULT = bytes32("erc20_vault"); bytes32 internal constant B_ERC721_VAULT = bytes32("erc721_vault"); - bytes32 internal constant B_INBOX_OPERATOR = bytes32("inbox_operator"); + bytes32 internal constant B_FORCED_INCLUSION_STORE = bytes32("forced_inclusion_store"); + bytes32 internal constant B_WHITELISTED_PROPOSER = bytes32("whitelisted_proposer"); bytes32 internal constant B_PRECONF_ROUTER = bytes32("preconf_router"); bytes32 internal constant B_PRECONF_WHITELIST = bytes32("preconf_whitelist"); bytes32 internal constant B_PRECONF_WHITELIST_OWNER = bytes32("preconf_whitelist_owner"); @@ -26,6 +27,7 @@ library LibStrings { bytes32 internal constant B_SIGNAL_SERVICE = bytes32("signal_service"); bytes32 internal constant B_TAIKO = bytes32("taiko"); bytes32 internal constant B_TAIKO_TOKEN = bytes32("taiko_token"); + bytes32 internal constant B_TAIKO_WRAPPER = bytes32("taiko_wrapper"); bytes32 internal constant B_WITHDRAWER = bytes32("withdrawer"); bytes32 internal constant H_SIGNAL_ROOT = keccak256("SIGNAL_ROOT"); bytes32 internal constant H_STATE_ROOT = keccak256("STATE_ROOT"); diff --git a/packages/protocol/script/gen-layouts.sh b/packages/protocol/script/gen-layouts.sh index 3f05221ce6..9bb1da7f87 100755 --- a/packages/protocol/script/gen-layouts.sh +++ b/packages/protocol/script/gen-layouts.sh @@ -37,6 +37,8 @@ contracts_layer1=( "contracts/layer1/team/TokenUnlock.sol:TokenUnlock" "contracts/layer1/provers/ProverSet.sol:ProverSet" "contracts/layer1/based/ForkRouter.sol:ForkRouter" +"contracts/layer1/forced-inclusion/TaikoWrapper" +"contracts/layer1/forced-inclusion/ForcedInclusionStore" ) # Layer 2 contracts diff --git a/packages/protocol/script/layer1/based/DeployProtocolOnL1.s.sol b/packages/protocol/script/layer1/based/DeployProtocolOnL1.s.sol index d1478e94c8..4d1022e3df 100644 --- a/packages/protocol/script/layer1/based/DeployProtocolOnL1.s.sol +++ b/packages/protocol/script/layer1/based/DeployProtocolOnL1.s.sol @@ -20,6 +20,8 @@ import "src/layer1/devnet/verifiers/DevnetVerifier.sol"; import "src/layer1/mainnet/MainnetInbox.sol"; import "src/layer1/based/TaikoInbox.sol"; import "src/layer1/fork-router/ForkRouter.sol"; +import "src/layer1/forced-inclusion/TaikoWrapper.sol"; +import "src/layer1/forced-inclusion/ForcedInclusionStore.sol"; import "src/layer1/mainnet/multirollup/MainnetBridge.sol"; import "src/layer1/mainnet/multirollup/MainnetERC1155Vault.sol"; import "src/layer1/mainnet/multirollup/MainnetERC20Vault.sol"; @@ -112,7 +114,7 @@ contract DeployProtocolOnL1 is DeployCapability { } if (vm.envBool("DEPLOY_PRECONF_CONTRACTS")) { - deployPreconfContracts(contractOwner, sharedResolver); + deployPreconfContracts(contractOwner, rollupResolver); } if (DefaultResolver(sharedResolver).owner() == msg.sender) { @@ -390,7 +392,7 @@ contract DeployProtocolOnL1 is DeployCapability { address resolver ) private - returns (address whitelist, address router) + returns (address whitelist, address router, address store, address forcedInclusionInbox) { whitelist = deployProxy({ name: "preconf_whitelist", @@ -406,7 +408,35 @@ contract DeployProtocolOnL1 is DeployCapability { registerTo: resolver }); - return (whitelist, router); + store = deployProxy({ + name: "forced_inclusion_store", + impl: address( + new ForcedInclusionStore( + resolver, + uint8(vm.envUint("INCLUSION_WINDOW")), + uint64(vm.envUint("INCLUSION_FEE_IN_GWEI")) + ) + ), + data: abi.encodeCall(ForcedInclusionStore.init, (owner)), + registerTo: resolver + }); + + forcedInclusionInbox = deployProxy({ + name: "taiko_wrapper", + impl: address(new TaikoWrapper(resolver)), + data: abi.encodeCall(TaikoWrapper.init, (owner)), + registerTo: resolver + }); + + // forcedInclusionInbox should be the whitelisted proposer, since + // we call PreconfRouter as the selected operator, which calls + // forcedinclustioninbox.proposeBatchWithForcedInclusion, + // which calls taikoInbox.proposeBatch. + DefaultResolver(resolver).registerAddress( + uint64(block.chainid), LibStrings.B_WHITELISTED_PROPOSER, forcedInclusionInbox + ); + + return (whitelist, router, store, forcedInclusionInbox); } function addressNotNull(address addr, string memory err) private pure { diff --git a/packages/protocol/script/layer1/based/deploy_protocol_on_l1.sh b/packages/protocol/script/layer1/based/deploy_protocol_on_l1.sh index 22b9d4d18f..7ba8fa1980 100755 --- a/packages/protocol/script/layer1/based/deploy_protocol_on_l1.sh +++ b/packages/protocol/script/layer1/based/deploy_protocol_on_l1.sh @@ -18,6 +18,8 @@ L2_GENESIS_HASH=0xee1950562d42f0da28bd4550d88886bc90894c77c9c9eaefef775d4c8223f2 PAUSE_BRIDGE=true \ FOUNDRY_PROFILE="layer1" \ DEPLOY_PRECONF_CONTRACTS=true \ +INCLUSION_WINDOW=24 \ +INCLUSION_FEE=100 \ forge script ./script/layer1/based/DeployProtocolOnL1.s.sol:DeployProtocolOnL1 \ --fork-url http://localhost:8545 \ --broadcast \ diff --git a/packages/protocol/test/layer1/Layer1Test.sol b/packages/protocol/test/layer1/Layer1Test.sol index ff227bbc23..c44b210ae4 100644 --- a/packages/protocol/test/layer1/Layer1Test.sol +++ b/packages/protocol/test/layer1/Layer1Test.sol @@ -2,6 +2,8 @@ pragma solidity ^0.8.24; import "src/layer1/based/TaikoInbox.sol"; +import "src/layer1/forced-inclusion/TaikoWrapper.sol"; +import "src/layer1/forced-inclusion/ForcedInclusionStore.sol"; import "src/layer1/token/TaikoToken.sol"; import "src/layer1/verifiers/SgxVerifier.sol"; import "src/layer1/verifiers/SP1Verifier.sol"; @@ -64,6 +66,33 @@ abstract contract Layer1Test is CommonTest { ); } + function deployForcedInclusionInbox() internal returns (TaikoWrapper) { + return TaikoWrapper( + deploy({ + name: "taiko_wrapper", + impl: address(new TaikoWrapper(address(resolver))), + data: abi.encodeCall(TaikoWrapper.init, (address(0))) + }) + ); + } + + function deployForcedInclusionStore( + uint8 inclusionDelay, + uint64 feeInGwei, + address owner + ) + internal + returns (ForcedInclusionStore) + { + return ForcedInclusionStore( + deploy({ + name: "forced_inclusion_store", + impl: address(new ForcedInclusionStore(address(resolver), inclusionDelay, feeInGwei)), + data: abi.encodeCall(ForcedInclusionStore.init, (owner)) + }) + ); + } + function deployBondToken() internal returns (TaikoToken) { return TaikoToken( deploy({ diff --git a/packages/protocol/test/layer1/based/InboxTest_ProposeAndProve.t.sol b/packages/protocol/test/layer1/based/InboxTest_ProposeAndProve.t.sol index ebda7ff471..5a01baa96a 100644 --- a/packages/protocol/test/layer1/based/InboxTest_ProposeAndProve.t.sol +++ b/packages/protocol/test/layer1/based/InboxTest_ProposeAndProve.t.sol @@ -514,17 +514,17 @@ contract InboxTest_ProposeAndProve is InboxTestBase { inbox.proposeBatch(abi.encode(params), "txList"); vm.startPrank(deployer); - address operator = Bob; - resolver.registerAddress(block.chainid, "inbox_operator", operator); + address whitelistedProposer = Bob; + resolver.registerAddress(block.chainid, "whitelisted_proposer", whitelistedProposer); vm.stopPrank(); vm.startPrank(Alice); - params.proposer = operator; - vm.expectRevert(ITaikoInbox.NotInboxOperator.selector); + params.proposer = whitelistedProposer; + vm.expectRevert(ITaikoInbox.NotWhitelistedProposer.selector); inbox.proposeBatch(abi.encode(params), "txList"); vm.stopPrank(); - vm.startPrank(operator); + vm.startPrank(whitelistedProposer); params.proposer = address(0); vm.expectRevert(ITaikoInbox.CustomProposerMissing.selector); inbox.proposeBatch(abi.encode(params), "txList"); diff --git a/packages/protocol/test/layer1/forced-inclusion/ForcedInclusionStore.t.sol b/packages/protocol/test/layer1/forced-inclusion/ForcedInclusionStore.t.sol new file mode 100644 index 0000000000..62dceb44d2 --- /dev/null +++ b/packages/protocol/test/layer1/forced-inclusion/ForcedInclusionStore.t.sol @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import "../../shared/CommonTest.sol"; +import "src/layer1/forced-inclusion/ForcedInclusionStore.sol"; + +contract ForcedInclusionStoreForTest is ForcedInclusionStore { + constructor( + address _resolver, + uint8 _inclusionDelay, + uint64 _feeInGwei + ) + ForcedInclusionStore(_resolver, _inclusionDelay, _feeInGwei) + { } + + function _blobHash(uint8 blobIndex) internal view virtual override returns (bytes32) { + return bytes32(uint256(blobIndex + 1)); + } +} + +contract MockInbox { + uint64 public numBatches; + + constructor() { + numBatches = 1; + } + + function setNumBatches(uint64 _numBatches) external { + numBatches = _numBatches; + } + + function getStats2() external view returns (ITaikoInbox.Stats2 memory stats2_) { + stats2_.numBatches = numBatches; + } +} + +abstract contract ForcedInclusionStoreTestBase is CommonTest { + address internal storeOwner = Alice; + address internal whitelistedProposer = Alice; + uint8 internal constant inclusionDelay = 12; + uint64 internal constant feeInGwei = 0.001 ether / 1 gwei; + + ForcedInclusionStore internal store; + MockInbox internal mockInbox; + + function setUpOnEthereum() internal virtual override { + register(LibStrings.B_TAIKO_WRAPPER, whitelistedProposer); + + store = ForcedInclusionStore( + deploy({ + name: LibStrings.B_FORCED_INCLUSION_STORE, + impl: address( + new ForcedInclusionStoreForTest(address(resolver), inclusionDelay, feeInGwei) + ), + data: abi.encodeCall(ForcedInclusionStore.init, (storeOwner)) + }) + ); + + mockInbox = new MockInbox(); + register(LibStrings.B_TAIKO, address(mockInbox)); + } +} + +contract ForcedInclusionStoreTest is ForcedInclusionStoreTestBase { + function test_storeForcedInclusion_success() public transactBy(Alice) { + vm.deal(Alice, 1 ether); + + uint64 _feeInGwei = store.feeInGwei(); + + for (uint8 i; i < 5; ++i) { + store.storeForcedInclusion{ value: _feeInGwei * 1 gwei }({ + blobIndex: i, + blobByteOffset: 0, + blobByteSize: 1024 + }); + ( + bytes32 blobHash, + uint64 feeInGwei, + uint64 createdAt, + uint32 blobByteOffset, + uint32 blobByteSize + ) = store.queue(store.tail() - 1); + + assertEq(blobHash, bytes32(uint256(i + 1))); // = blobIndex + 1 + assertEq(createdAt, uint64(block.timestamp)); + assertEq(feeInGwei, _feeInGwei); + assertEq(blobByteOffset, 0); + assertEq(blobByteSize, 1024); + } + } + + function test_storeForcedInclusion_incorrectFee() public transactBy(Alice) { + vm.deal(Alice, 1 ether); + + uint64 feeInGwei = store.feeInGwei(); + vm.expectRevert(IForcedInclusionStore.IncorrectFee.selector); + store.storeForcedInclusion{ value: feeInGwei * 1 gwei - 1 }({ + blobIndex: 0, + blobByteOffset: 0, + blobByteSize: 1024 + }); + + vm.expectRevert(IForcedInclusionStore.IncorrectFee.selector); + store.storeForcedInclusion{ value: feeInGwei * 1 gwei + 1 }({ + blobIndex: 0, + blobByteOffset: 0, + blobByteSize: 1024 + }); + } + + function test_storeConsumeForcedInclusion_success() public { + vm.deal(Alice, 1 ether); + uint64 _feeInGwei = store.feeInGwei(); + + mockInbox.setNumBatches(100); + + vm.prank(Alice); + store.storeForcedInclusion{ value: _feeInGwei * 1 gwei }({ + blobIndex: 0, + blobByteOffset: 0, + blobByteSize: 1024 + }); + + assertEq(store.head(), 0); + assertEq(store.tail(), 1); + + IForcedInclusionStore.ForcedInclusion memory inclusion = store.getForcedInclusion(0); + + vm.prank(whitelistedProposer); + inclusion = store.consumeOldestForcedInclusion(Bob); + + assertEq(inclusion.blobHash, bytes32(uint256(1))); + assertEq(inclusion.blobByteOffset, 0); + assertEq(inclusion.blobByteSize, 1024); + assertEq(inclusion.feeInGwei, _feeInGwei); + assertEq(inclusion.createdAtBatchId, 100); + assertEq(Bob.balance, _feeInGwei * 1 gwei); + } + + function test_storeConsumeForcedInclusion_notOperator() public { + vm.deal(Alice, 1 ether); + uint64 _feeInGwei = store.feeInGwei(); + + mockInbox.setNumBatches(100); + + vm.prank(Alice); + store.storeForcedInclusion{ value: _feeInGwei * 1 gwei }({ + blobIndex: 0, + blobByteOffset: 0, + blobByteSize: 1024 + }); + + assertEq(store.head(), 0); + assertEq(store.tail(), 1); + + vm.warp(block.timestamp + inclusionDelay); + + vm.prank(Carol); + vm.expectRevert(EssentialContract.ACCESS_DENIED.selector); + store.consumeOldestForcedInclusion(Bob); + } + + function test_storeConsumeForcedInclusion_noEligibleInclusion() public { + vm.prank(whitelistedProposer); + vm.expectRevert(IForcedInclusionStore.NoForcedInclusionFound.selector); + store.consumeOldestForcedInclusion(Bob); + } + + function test_storeConsumeForcedInclusion_beforeWindowExpires() public { + vm.deal(Alice, 1 ether); + + mockInbox.setNumBatches(100); + + vm.prank(whitelistedProposer); + store.storeForcedInclusion{ value: store.feeInGwei() * 1 gwei }({ + blobIndex: 0, + blobByteOffset: 0, + blobByteSize: 1024 + }); + + // Verify the stored reqeust is correct + IForcedInclusionStore.ForcedInclusion memory inclusion = store.getForcedInclusion(0); + + assertEq(inclusion.blobHash, bytes32(uint256(1))); + assertEq(inclusion.blobByteOffset, 0); + assertEq(inclusion.blobByteSize, 1024); + assertEq(inclusion.createdAtBatchId, mockInbox.numBatches()); + assertEq(inclusion.feeInGwei, store.feeInGwei()); + + vm.warp(block.timestamp + inclusionDelay - 1); + vm.prank(whitelistedProposer); + + // head request should be consumable + inclusion = store.consumeOldestForcedInclusion(Bob); + assertEq(inclusion.blobHash, bytes32(uint256(1))); + assertEq(inclusion.blobByteOffset, 0); + assertEq(inclusion.blobByteSize, 1024); + assertEq(inclusion.createdAtBatchId, mockInbox.numBatches()); + assertEq(inclusion.feeInGwei, store.feeInGwei()); + + // the head request should have been deleted + inclusion = store.getForcedInclusion(0); + assertEq(inclusion.blobHash, 0); + assertEq(inclusion.blobByteOffset, 0); + assertEq(inclusion.blobByteSize, 0); + assertEq(inclusion.createdAtBatchId, 0); + assertEq(inclusion.feeInGwei, 0); + } +}