diff --git a/packages/protocol/contracts/layer1/preconf/iface/IPreconfRouter.sol b/packages/protocol/contracts/layer1/preconf/iface/IPreconfRouter.sol index 3563bf7c699..846d9b214a1 100644 --- a/packages/protocol/contracts/layer1/preconf/iface/IPreconfRouter.sol +++ b/packages/protocol/contracts/layer1/preconf/iface/IPreconfRouter.sol @@ -6,9 +6,24 @@ import "src/layer1/based/ITaikoInbox.sol"; /// @title IPreconfRouter /// @custom:security-contact security@taiko.xyz interface IPreconfRouter { + + struct ForcedTx { + bytes txList; + uint256 timestamp; + bool included; + uint256 stakeAmount; + } + + error ForcedTxListAlreadyIncluded(); + error ForcedTxListAlreadyStored(); + error ForcedTxListHashNotFound(); + error InsufficientStakeAmount(); error NotTheOperator(); error ProposerIsNotTheSender(); + + event ForcedTxStored(bytes indexed txHash, uint256 timestamp); + /// @notice Proposes a batch of blocks that have been preconfed. /// @dev This function only accepts batches from an operator selected to preconf in a particular /// slot or epoch and routes that batch to the TaikoInbox. @@ -19,8 +34,13 @@ interface IPreconfRouter { function proposePreconfedBlocks( bytes calldata _params, bytes calldata _batchParams, - bytes calldata _batchTxList + bytes calldata _batchTxList, + bool force ) external returns (ITaikoInbox.BatchMetadata memory meta_); + + function updateBaseStakeAmount(uint256 _newBaseStakeAmount) external; + + function storeForcedTx(bytes calldata _txList) payable external; } diff --git a/packages/protocol/contracts/layer1/preconf/impl/PreconfRouter.sol b/packages/protocol/contracts/layer1/preconf/impl/PreconfRouter.sol index e018de56010..00a56e5c0e4 100644 --- a/packages/protocol/contracts/layer1/preconf/impl/PreconfRouter.sol +++ b/packages/protocol/contracts/layer1/preconf/impl/PreconfRouter.sol @@ -4,33 +4,95 @@ pragma solidity ^0.8.24; import "../iface/IPreconfRouter.sol"; import "../iface/IPreconfWhitelist.sol"; import "src/layer1/based/ITaikoInbox.sol"; +import "src/shared/libs/LibAddress.sol"; import "src/shared/libs/LibStrings.sol"; import "src/shared/common/EssentialContract.sol"; /// @title PreconfRouter /// @custom:security-contact security@taiko.xyz contract PreconfRouter is EssentialContract, IPreconfRouter { - uint256[50] private __gap; + mapping(bytes32 => ForcedTx) public forcedTxLists; - constructor(address _resolver) EssentialContract(_resolver) { } + uint256 public pendingForcedTxHashes; + + uint256 public inclusionWindow; + + uint256 public baseStakeAmount; + + uint256[46] private __gap; + + constructor(address _resolver, uint256 _inclusionWindow, uint256 _baseStakeAmount) EssentialContract(_resolver) { + inclusionWindow = _inclusionWindow; + baseStakeAmount = _baseStakeAmount; + } function init(address _owner) external initializer { __Essential_init(_owner); } + function updateBaseStakeAmount(uint256 _newBaseStakeAmount) external onlyOwner { + baseStakeAmount = _newBaseStakeAmount; + } + + function getRequiredStakeAmount() public view returns (uint256) { + uint256 multiplier = pendingForcedTxHashes == 0 ? 1 : pendingForcedTxHashes; + return baseStakeAmount * multiplier; + } + + function storeForcedTx(bytes calldata _txList) payable external { + uint256 requiredStake = getRequiredStakeAmount(); + require(msg.value >= requiredStake, InsufficientStakeAmount()); + + bytes32 txListHash = keccak256(_txList); + require(forcedTxLists[txListHash].timestamp == 0, ForcedTxListAlreadyStored()); + + forcedTxLists[txListHash] = ForcedTx({ + txList: _txList, + timestamp: block.timestamp, + included: false, + stakeAmount: msg.value + }); + + pendingForcedTxHashes++; + + emit ForcedTxStored(_txList, block.timestamp); + } + /// @inheritdoc IPreconfRouter function proposePreconfedBlocks( bytes calldata, bytes calldata _batchParams, - bytes calldata _batchTxList + bytes calldata _batchTxList, + bool force ) external returns (ITaikoInbox.BatchMetadata memory meta_) { - // Sender must be the selected operator for the epoch + bytes32 forcedTxListHash = keccak256(_batchTxList); + + // Sender must be the selected operator for the epoch, or able to propose a forced txList + // after the inclusion window has expired address selectedOperator = IPreconfWhitelist(resolve(LibStrings.B_PRECONF_WHITELIST, false)).getOperatorForEpoch(); - require(msg.sender == selectedOperator, NotTheOperator()); + + if(force) { + require(msg.sender == selectedOperator || canProposeFallback(forcedTxListHash), NotTheOperator()); + } else { + require(msg.sender == selectedOperator, NotTheOperator()); + } + + if (force) { + require(forcedTxLists[forcedTxListHash].timestamp != 0, ForcedTxListHashNotFound()); + require(!forcedTxLists[forcedTxListHash].included, ForcedTxListAlreadyIncluded()); + + // Pay out the stake to the proposer + LibAddress.sendEtherAndVerify(msg.sender, forcedTxLists[forcedTxListHash].stakeAmount); + + pendingForcedTxHashes--; + + forcedTxLists[forcedTxListHash].included = true; + } + // Call the proposeBatch function on the TaikoInbox address taikoInbox = resolve(LibStrings.B_TAIKO, false); @@ -39,4 +101,9 @@ contract PreconfRouter is EssentialContract, IPreconfRouter { // Verify that the sender had set itself as the proposer require(meta_.proposer == msg.sender, ProposerIsNotTheSender()); } + + function canProposeFallback(bytes32 _forcedTxHash) public view returns (bool) { + return block.timestamp > forcedTxLists[_forcedTxHash].timestamp + inclusionWindow && + !forcedTxLists[_forcedTxHash].included; + } } diff --git a/packages/protocol/script/layer1/preconf/DeployPreconfContracts.s.sol b/packages/protocol/script/layer1/preconf/DeployPreconfContracts.s.sol index b03a2cc86d3..1d40a2a90bf 100644 --- a/packages/protocol/script/layer1/preconf/DeployPreconfContracts.s.sol +++ b/packages/protocol/script/layer1/preconf/DeployPreconfContracts.s.sol @@ -23,11 +23,10 @@ contract DeployPreconfContracts is BaseScript { address(new PreconfWhitelist(sharedResolver)), abi.encodeCall(PreconfWhitelist.init, (contractOwner)) ); - // Deploy PreconfRouter deploy( LibStrings.B_PRECONF_ROUTER, - address(new PreconfRouter(sharedResolver)), + address(new PreconfRouter(sharedResolver, vm.envUint("INCLUSION_WINDOW"), vm.envUint("BASE_STAKE_AMOUNT"))), abi.encodeCall(PreconfRouter.init, (contractOwner)) ); } diff --git a/packages/protocol/test/layer1/preconf/router/RouterTest.t.sol b/packages/protocol/test/layer1/preconf/router/RouterTest.t.sol index 695388aa9f4..193b48f78e1 100644 --- a/packages/protocol/test/layer1/preconf/router/RouterTest.t.sol +++ b/packages/protocol/test/layer1/preconf/router/RouterTest.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.24; import "./RouterTestBase.sol"; import "../mocks/MockBeaconBlockRoot.sol"; import "src/layer1/based/ITaikoInbox.sol"; +import "src/layer1/preconf/iface/IPreconfRouter.sol"; contract RouterTest is RouterTestBase { function test_proposePreconfedBlocks() external { @@ -54,7 +55,7 @@ contract RouterTest is RouterTestBase { // Prank as Carol (selected operator) and propose blocks vm.prank(Carol); ITaikoInbox.BatchMetadata memory meta = - router.proposePreconfedBlocks("", abi.encode(params), ""); + router.proposePreconfedBlocks("", abi.encode(params), "", false); // Assert the proposer was set correctly in the metadata assertEq(meta.proposer, Carol); @@ -88,7 +89,7 @@ contract RouterTest is RouterTestBase { // Prank as David (not the selected operator) and propose blocks vm.prank(David); vm.expectRevert(IPreconfRouter.NotTheOperator.selector); - router.proposePreconfedBlocks("", "", ""); + router.proposePreconfedBlocks("", "", "", false); } function test_proposePreconfedBlocks_proposerNotSender() external { @@ -139,6 +140,96 @@ contract RouterTest is RouterTestBase { // Prank as Carol (selected operator) and propose blocks vm.prank(Carol); vm.expectRevert(IPreconfRouter.ProposerIsNotTheSender.selector); - router.proposePreconfedBlocks("", abi.encode(params), ""); + router.proposePreconfedBlocks("", abi.encode(params), "", false); } + + function test_canProposeFallback_notExpired() external { + // Store a forced transaction + vm.deal(address(this), 1 ether); + router.storeForcedTx{value: 0.1 ether}(testTxList); + + // Ensure fallback cannot be proposed before the inclusion window expires + assertFalse(router.canProposeFallback(testTxListHash)); + } + + function test_canProposeFallback_expired() external { + // Store a forced transaction + vm.deal(address(this), 1 ether); + router.storeForcedTx{value: 0.1 ether}(testTxList); + + // Warp time beyond the inclusion window + vm.warp(block.timestamp + router.inclusionWindow() + 1); + + // Ensure fallback can now be proposed + assertTrue(router.canProposeFallback(testTxListHash)); + } + + function test_storeForcedTx_success() external { + // Ensure the initial balance is sufficient + vm.deal(address(this), 10 ether); + + // Store forced transaction with the correct stake + uint256 requiredStake = router.baseStakeAmount(); + router.storeForcedTx{value: requiredStake}(testTxList); + + // Validate stored transaction data + (bytes memory storedTxList, uint256 timestamp, bool included, uint256 stakeAmount) = + router.forcedTxLists(testTxListHash); + + assertEq(storedTxList, testTxList); + assertEq(timestamp, block.timestamp); + assertEq(included, false); + assertEq(stakeAmount, requiredStake); + + // Ensure the pendingForcedTxHashes count is incremented + assertEq(router.pendingForcedTxHashes(), 1); + } + + function test_storeForcedTx_insufficientStake() external { + vm.deal(address(this), 10 ether); + + uint256 incorrectStake = router.getRequiredStakeAmount() - 1; + console2.log(router.getRequiredStakeAmount()); + vm.expectRevert(IPreconfRouter.InsufficientStakeAmount.selector); + router.storeForcedTx{value: incorrectStake}(testTxList); + } + + + function test_storeForcedTx_dynamicStakeIncrease() external { + vm.deal(address(this), 10 ether); + + uint256 baseStake = router.baseStakeAmount(); + + for (uint256 i = 0; i < 3; i++) { + uint256 expectedStake = baseStake * (i + 1); + bytes memory newTx = abi.encodePacked(testTxList, i); + bytes32 newTxHash = keccak256(newTx); + + router.storeForcedTx{value: expectedStake}(newTx); + + (, , , uint256 stakeAmount) = router.forcedTxLists(newTxHash); + assertEq(stakeAmount, expectedStake); + } + + assertEq(router.pendingForcedTxHashes(), 3); + } + + function test_storeForcedTx_duplicate() external { + vm.deal(address(this), 10 ether); + + router.storeForcedTx{value: router.getRequiredStakeAmount()}(testTxList); + + uint256 requiredStake = router.getRequiredStakeAmount(); + vm.expectRevert(IPreconfRouter.ForcedTxListAlreadyStored.selector); + router.storeForcedTx{value: requiredStake}(testTxList); + } + + function test_storeForcedTx_pendingTxCount() external { + vm.deal(address(this), 10 ether); + + router.storeForcedTx{value: router.baseStakeAmount()}(testTxList); + + assertEq(router.pendingForcedTxHashes(), 1); + } + } diff --git a/packages/protocol/test/layer1/preconf/router/RouterTestBase.sol b/packages/protocol/test/layer1/preconf/router/RouterTestBase.sol index ca495d472da..2827daf8369 100644 --- a/packages/protocol/test/layer1/preconf/router/RouterTestBase.sol +++ b/packages/protocol/test/layer1/preconf/router/RouterTestBase.sol @@ -11,10 +11,15 @@ abstract contract RouterTestBase is Layer1Test { PreconfWhitelist internal whitelist; address internal routerOwner; address internal whitelistOwner; + bytes internal testTxList = "test transaction list"; + bytes32 internal testTxListHash; + uint256 internal initialTimestamp; function setUpOnEthereum() internal virtual override { routerOwner = Alice; whitelistOwner = Alice; + testTxListHash = keccak256(testTxList); + initialTimestamp = block.timestamp; vm.chainId(1); @@ -37,7 +42,7 @@ abstract contract RouterTestBase is Layer1Test { router = PreconfRouter( deploy({ name: "preconf_router", - impl: address(new PreconfRouter(address(resolver))), + impl: address(new PreconfRouter(address(resolver), 60, 0.01 ether)), data: abi.encodeCall(PreconfRouter.init, (routerOwner)) }) );