diff --git a/contracts/contracts/RecoveryWithDelayPlugin.sol b/contracts/contracts/RecoveryWithDelayPlugin.sol new file mode 100644 index 0000000..f7f0df0 --- /dev/null +++ b/contracts/contracts/RecoveryWithDelayPlugin.sol @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.18; +import {ISafe} from "@safe-global/safe-core-protocol/contracts/interfaces/Accounts.sol"; +import {ISafeProtocolPlugin} from "@safe-global/safe-core-protocol/contracts/interfaces/Integrations.sol"; +import {ISafeProtocolManager} from "@safe-global/safe-core-protocol/contracts/interfaces/Manager.sol"; +import {BasePluginWithEventMetadata, PluginMetadata} from "./Base.sol"; +import {SafeTransaction, SafeRootAccess, SafeProtocolAction} from "@safe-global/safe-core-protocol/contracts/DataTypes.sol"; + +/** + * @title RecoveryWithDelayPlugin - A contract compatible with Safe{Core} Protocol that replaces a specified owner for a Safe by a non-owner account. + * @notice This contract should be listed in a Registry and enabled as a Plugin for an account through a Manager to be able to intiate recovery mechanism. + * @dev The recovery process is initiated by a recoverer account. The recoverer account is set during the contract deployment in the constructor and cannot be updated. + * The recoverer account can initiate the recovery process by calling the createAnnouncement function and later when the delay is over, any account can execute + * complete the recovery process by calling the executeFromPlugin function. + * @author Akshay Patel - @akshay-ap + */ +contract RecoveryWithDelayPlugin is BasePluginWithEventMetadata { + // Constants + bytes32 public constant DOMAIN_SEPARATOR_TYPEHASH = keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"); + + bytes32 public constant DELAYED_RECOVERY_TRANSACTION_TYPEHASH = + keccak256( + "DelayedRecoveryTransaction(address recoverer,address manager,address account,address prevOwner,address oldOwner,address newOwner,uint256 nonce)" + ); + + struct Announcement { + uint64 executionTime; // Block time in seconds when the announced transaction can be executed + uint64 validityDuration; // Duration in seconds the announcement is valid after delay is over (0 is valid forever) + bool executed; // Flag if the announced transaction was executed + } + + // Only recoverer can initiate recovery process + address public immutable recoverer; + + // Contract storage + // Transaction Hash -> Announcement + mapping(bytes32 => Announcement) public announcements; + + // Events + event NewRecoveryAnnouncement(address indexed account, bytes32 txHash); + event RecoveryAnnouncementCancelled(address indexed account, bytes32 txHash); + event OwnerReplaced(address indexed account, address oldowner, address newOwner); + + // Errors + error CallerNotValidRecoverer(); + error TransactionAlreadyExecuted(bytes32 txHash); + error TransactionAlreadyScheduled(bytes32 txHash); + error ExecutionTimeShouldBeInFuture(); + error TransactionNotFound(bytes32 txHash); + error TransactionExecutionNotAllowedYet(bytes32 txHash); + error TransactionExecutionValidityExpired(bytes32 txHash); + + constructor( + address _recoverer + ) + BasePluginWithEventMetadata( + PluginMetadata({name: "Recovery Plugin", version: "1.0.0", requiresRootAccess: true, iconUrl: "", appUrl: ""}) + ) + { + recoverer = _recoverer; + } + + modifier onlyRecoverer() { + if (msg.sender != recoverer) { + revert CallerNotValidRecoverer(); + } + _; + } + + /** + * @notice Executes a Safe transaction that swaps owner with a new owner. This allows a Safe account to be recovered + * if the owner's private key is lost. A safe account must set manager as a Module on a safe and enable this + * contract as Plugin on a Safe. + * @param manager Address of the Safe{Core} Protocol Manager. + * @param safe Safe account whose owner has to be recovered + * @param prevOwner Owner that pointed to the owner to be replaced in the linked list + * @param oldOwner Owner address to be replaced. + * @param newOwner New owner address. + * @param nonce A unique identifier used to uniquely identify a recovery transaction. + * @return data Bytes returned from the manager contract. + */ + function executeFromPlugin( + ISafeProtocolManager manager, + ISafe safe, + address prevOwner, + address oldOwner, + address newOwner, + uint256 nonce + ) external returns (bytes memory data) { + bytes32 txHash = getTransactionHash(address(manager), address(safe), prevOwner, oldOwner, newOwner, nonce); + Announcement memory announcement = announcements[txHash]; + + if (announcement.executed) { + revert TransactionAlreadyExecuted(txHash); + } + + if (block.timestamp < uint256(announcement.executionTime)) { + revert TransactionExecutionNotAllowedYet(txHash); + } + + if ( + announcement.validityDuration != 0 && + block.timestamp > uint256(announcement.executionTime) + uint256(announcement.validityDuration) + ) { + revert TransactionExecutionValidityExpired(txHash); + } + + announcements[txHash].executed = true; + + bytes memory txData = abi.encodeWithSignature("swapOwner(address,address,address)", prevOwner, oldOwner, newOwner); + + SafeProtocolAction memory safeProtocolAction = SafeProtocolAction(payable(address(safe)), 0, txData); + SafeRootAccess memory safeTx = SafeRootAccess(safeProtocolAction, 0, ""); + (data) = manager.executeRootAccess(safe, safeTx); + + emit OwnerReplaced(address(safe), oldOwner, newOwner); + } + + /** + * @notice Creates a recovery announcement for a Safe account. Only the recoverer can create a recovery announcement. + * @param manager Address of the manager contract. + * @param account Address of the safe account. + * @param prevOwner Address of the owner previous to the owner to be replaced in the linked list + * @param oldOwner Address of the owner to be replaced. + * @param newOwner Address of the new owner. + * @param nonce A uint256 used to uniquely identify a recovery transaction. + * @param executionTime A uint64 representing the block time in seconds after which the announced transaction can be executed. + */ + function createAnnouncement( + address manager, + address account, + address prevOwner, + address oldOwner, + address newOwner, + uint256 nonce, + uint64 executionTime, + uint64 validityDuration + ) external onlyRecoverer { + bytes32 txHash = getTransactionHash(manager, account, prevOwner, oldOwner, newOwner, nonce); + Announcement memory announcement = announcements[txHash]; + + if (executionTime <= block.timestamp) { + revert ExecutionTimeShouldBeInFuture(); + } + + if (announcement.executionTime != 0) { + revert TransactionAlreadyScheduled(txHash); + } + + announcements[txHash] = Announcement(executionTime, validityDuration, false); + emit NewRecoveryAnnouncement(account, txHash); + } + + /** + * @notice Cancels a recovery announcement for a Safe account. Only the recoverer can execute this function. + * @param manager Address of the manager contract. + * @param account Address of the safe account. + * @param prevOwner Address of the owner previous to the owner to be replaced in the linked list + * @param oldOwner Address of the owner to be replaced. + * @param newOwner Address of the new owner. + * @param nonce A uint256 used to uniquely identify a recovery transaction. + */ + function cancelAnnouncement( + address manager, + address account, + address prevOwner, + address oldOwner, + address newOwner, + uint256 nonce + ) external onlyRecoverer { + _cancelAnnouncement(manager, account, prevOwner, oldOwner, newOwner, nonce); + } + + /** + * @notice Cancels a recovery announcement for a Safe account. This function facilitates cancelling the reccovery process by an account. + * The msg.sender should be an account. + * @param manager Address of the manager contract. + * @param prevOwner Address of the owner previous to the owner to be replaced in the linked list + * @param oldOwner Address of the owner to be replaced. + * @param newOwner Address of the new owner. + * @param nonce A uint256 used to uniquely identify a recovery transaction. + */ + function cancelAnnouncementFromAccount(address manager, address prevOwner, address oldOwner, address newOwner, uint256 nonce) external { + _cancelAnnouncement(manager, msg.sender, prevOwner, oldOwner, newOwner, nonce); + } + + /** + * @notice Cancels a recovery announcement for a Safe account. This is a private function that is called by a recoverer or an account. + * @param manager Address of the manager contract. + * @param account Address of the safe account. + * @param prevOwner Address of the owner previous to the owner to be replaced in the linked list + * @param oldOwner Address of the owner to be replaced. + * @param newOwner Address of the new owner. + * @param nonce A uint256 used to uniquely identify a recovery transaction. + */ + function _cancelAnnouncement( + address manager, + address account, + address prevOwner, + address oldOwner, + address newOwner, + uint256 nonce + ) private { + bytes32 txHash = getTransactionHash(manager, account, prevOwner, oldOwner, newOwner, nonce); + + Announcement memory announcement = announcements[txHash]; + if (announcement.executed) { + revert TransactionAlreadyExecuted(txHash); + } + + if (announcement.executionTime == 0) { + revert TransactionNotFound(txHash); + } + + delete announcements[txHash]; + + emit RecoveryAnnouncementCancelled(account, txHash); + } + + /** + * @notice Returns the transaction hash for a recovery transaction. + * @param manager Address of the manager contract. + * @param account Address of the safe account. + * @param prevOwner Address of the owner previous to the owner to be replaced in the linked list + * @param oldOwner Address of the owner to be replaced. + * @param newOwner Address of the new owner. + * @param nonce A uint256 used to uniquely identify a recovery transaction. + */ + function getTransactionHashData( + address manager, + address account, + address prevOwner, + address oldOwner, + address newOwner, + uint256 nonce + ) public view returns (bytes memory) { + uint256 chainId = block.chainid; + + bytes32 domainSeparator = keccak256(abi.encode(DOMAIN_SEPARATOR_TYPEHASH, chainId, this)); + + bytes32 transactionHash = keccak256( + abi.encode(DELAYED_RECOVERY_TRANSACTION_TYPEHASH, recoverer, manager, account, prevOwner, oldOwner, newOwner, nonce) + ); + + return abi.encodePacked(bytes1(0x19), bytes1(0x01), domainSeparator, transactionHash); + } + + /** + * @notice Returns the transaction hash for a recovery transaction. The hash is generated using keccak256 function. + * @param manager Address of the manager contract. + * @param account Address of the safe account. + * @param prevOwner Address of the owner previous to the owner to be replaced in the linked list + * @param oldOwner Address of the owner to be replaced. + * @param newOwner Address of the new owner. + * @param nonce A uint256 used to uniquely identify a recovery transaction. + */ + function getTransactionHash( + address manager, + address account, + address prevOwner, + address oldOwner, + address newOwner, + uint256 nonce + ) public view returns (bytes32) { + return keccak256(getTransactionHashData(manager, account, prevOwner, oldOwner, newOwner, nonce)); + } +} diff --git a/contracts/hardhat.config.ts b/contracts/hardhat.config.ts index 1ac7cf0..b1546fe 100644 --- a/contracts/hardhat.config.ts +++ b/contracts/hardhat.config.ts @@ -89,7 +89,7 @@ const config: HardhatUserConfig = { deployer: { default: 0 }, - owner: { + recoverer: { default: 1 } } diff --git a/contracts/src/deploy/deploy_plugin.ts b/contracts/src/deploy/deploy_plugin.ts index 38c99e6..e162085 100644 --- a/contracts/src/deploy/deploy_plugin.ts +++ b/contracts/src/deploy/deploy_plugin.ts @@ -5,7 +5,7 @@ import { ZeroAddress } from "ethers"; const deploy: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { const { deployments, getNamedAccounts } = hre; - const { deployer } = await getNamedAccounts(); + const { deployer, recoverer } = await getNamedAccounts(); const { deploy } = deployments; // execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes) @@ -27,6 +27,14 @@ const deploy: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { log: true, deterministicDeployment: true, }); + + await deploy("RecoveryWithDelayPlugin", { + from: deployer, + args: [recoverer], + log: true, + deterministicDeployment: true, + }); + }; deploy.tags = ["plugins"]; diff --git a/contracts/src/utils/contracts.ts b/contracts/src/utils/contracts.ts index 4c91064..f64eb43 100644 --- a/contracts/src/utils/contracts.ts +++ b/contracts/src/utils/contracts.ts @@ -1,5 +1,5 @@ import { Addressable, BaseContract } from "ethers"; -import { BasePlugin, RelayPlugin, TestSafeProtocolRegistryUnrestricted, WhitelistPlugin } from "../../typechain-types"; +import { BasePlugin, RecoveryWithDelayPlugin, RelayPlugin, TestSafeProtocolRegistryUnrestricted, WhitelistPlugin } from "../../typechain-types"; import { HardhatRuntimeEnvironment } from "hardhat/types"; import { getProtocolManagerAddress, getProtocolRegistryAddress } from "./protocol"; @@ -17,3 +17,4 @@ export const getPlugin = (hre: HardhatRuntimeEnvironment, address: string) => ge export const getRelayPlugin = (hre: HardhatRuntimeEnvironment) => getSingleton(hre, "RelayPlugin"); export const getRegistry = async (hre: HardhatRuntimeEnvironment) => getInstance(hre, "TestSafeProtocolRegistryUnrestricted", await getProtocolRegistryAddress(hre)); export const getWhiteListPlugin = async (hre: HardhatRuntimeEnvironment) => getSingleton(hre, "WhitelistPlugin"); +export const getRecoveryWithDelayPlugin= async(hre: HardhatRuntimeEnvironment) => getSingleton(hre, "RecoveryWithDelayPlugin"); diff --git a/contracts/test/RecoveryWithDelayPlugin.spec.ts b/contracts/test/RecoveryWithDelayPlugin.spec.ts new file mode 100644 index 0000000..c401eb3 --- /dev/null +++ b/contracts/test/RecoveryWithDelayPlugin.spec.ts @@ -0,0 +1,381 @@ +import hre, { deployments, ethers } from "hardhat"; +import { expect } from "chai"; +import { getProtocolManagerAddress } from "../src/utils/protocol"; +import { getRecoveryWithDelayPlugin } from "../src/utils/contracts"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { ISafeProtocolManager__factory } from "../typechain-types"; +import { SafeProtocolAction, SafeRootAccess } from "../src/utils/dataTypes"; +import { MaxUint256, ZeroHash } from "ethers"; + +describe("RecoverWithDelayPlugin", () => { + let deployer: SignerWithAddress, + recoverer: SignerWithAddress, + user1: SignerWithAddress, + user2: SignerWithAddress, + user3: SignerWithAddress; + + const validityDuration = 60 * 60 * 24 * 100; // 100 days + + before(async () => { + [deployer, recoverer, user1, user2, user3] = await hre.ethers.getSigners(); + }); + + const setup = deployments.createFixture(async ({ deployments }) => { + await deployments.fixture(); + + const manager = await ethers.getContractAt("MockContract", await getProtocolManagerAddress(hre)); + const account = await (await ethers.getContractFactory("ExecutableMockContract")).deploy(); + const plugin = await getRecoveryWithDelayPlugin(hre); + return { + account, + plugin, + manager, + }; + }); + + it("Should revert due to past execution time", async () => { + const { account, plugin, manager } = await setup(); + await expect( + plugin + .connect(recoverer) + .createAnnouncement(manager.target, account.target, user1.address, user2.address, user3.address, 0n, 0, validityDuration), + ).to.be.revertedWithCustomError(plugin, "ExecutionTimeShouldBeInFuture"); + }); + + it("Should call swap owner an a Safe account", async () => { + const { account, plugin, manager } = await setup(); + + const timestamp = (await ethers.provider.getBlock("latest"))?.timestamp || 0; + expect( + await plugin + .connect(recoverer) + .createAnnouncement( + manager.target, + account.target, + user1.address, + user2.address, + user3.address, + 0n, + timestamp + 10, + validityDuration, + ), + ); + await hre.ethers.provider.send("evm_increaseTime", [60 * 60 * 24 * 10]); // 10 days + await hre.ethers.provider.send("evm_mine"); + + const managerInterface = ISafeProtocolManager__factory.createInterface(); + + expect( + await plugin + .connect(deployer) + .executeFromPlugin(manager.target, account.target, user1.address, user2.address, user3.address, 0n), + ) + .to.emit(plugin, "OwnerReplaced") + .withArgs(account.target, user2.address, user3.address); + + const safeInterface = new hre.ethers.Interface(["function swapOwner(address,address,address)"]); + const data = safeInterface.encodeFunctionData("swapOwner", [user1.address, user2.address, user3.address]); + + const safeProtocolAction: SafeProtocolAction = { + to: account.target, + value: 0n, + data: data, + }; + + const safeRootAccessTx: SafeRootAccess = { action: safeProtocolAction, nonce: 0n, metadataHash: ZeroHash }; + const callData = managerInterface.encodeFunctionData("executeRootAccess", [account.target, safeRootAccessTx]); + expect(await manager.invocationCount()).to.equal(1); + expect(await manager.invocationCountForCalldata(callData)).to.equal(1); + }); + + it("Should revert with TransactionExecutionValidityExpired when execution transaction after validity duration", async () => { + const { account, plugin, manager } = await setup(); + + const timestamp = (await ethers.provider.getBlock("latest"))?.timestamp || 0; + expect( + await plugin + .connect(recoverer) + .createAnnouncement( + manager.target, + account.target, + user1.address, + user2.address, + user3.address, + 0n, + timestamp + 10, + validityDuration, + ), + ); + await hre.ethers.provider.send("evm_increaseTime", [60 * 60 * 24 * 101]); // 101 days + await hre.ethers.provider.send("evm_mine"); + + await expect( + plugin.connect(user1).executeFromPlugin(manager.target, account.target, user1.address, user2.address, user3.address, 0n), + ).to.be.revertedWithCustomError(plugin, "TransactionExecutionValidityExpired"); + }); + + it("Should block swapping owner if execution time is not passed", async () => { + const { account, plugin, manager } = await setup(); + + const timestamp = (await ethers.provider.getBlock("latest"))?.timestamp || 0; + expect( + await plugin + .connect(recoverer) + .createAnnouncement( + manager.target, + account.target, + user1.address, + user2.address, + user3.address, + 0, + timestamp + 10, + validityDuration, + ), + ); + + await expect( + plugin.connect(user1).executeFromPlugin(manager.target, account.target, user1.address, user2.address, user3.address, 0n), + ).to.be.revertedWithCustomError(plugin, "TransactionExecutionNotAllowedYet"); + }); + + it("Should allow execution only once", async () => { + const { account, plugin, manager } = await setup(); + + const timestamp = (await ethers.provider.getBlock("latest"))?.timestamp || 0; + expect( + await plugin + .connect(recoverer) + .createAnnouncement( + manager.target, + account.target, + user1.address, + user2.address, + user3.address, + 0, + timestamp + 10, + validityDuration, + ), + ); + + await hre.ethers.provider.send("evm_increaseTime", [60 * 60 * 24 * 10]); + await hre.ethers.provider.send("evm_mine"); + + expect( + await plugin.connect(user1).executeFromPlugin(manager.target, account.target, user1.address, user2.address, user3.address, 0n), + ) + .to.emit(plugin, "OwnerReplaced") + .withArgs(account.target, user2.address, user3.address); + + await expect( + plugin.connect(user2).executeFromPlugin(manager.target, account.target, user1.address, user2.address, user3.address, 0n), + ).to.be.revertedWithCustomError(plugin, "TransactionAlreadyExecuted"); + }); + + it("Should allow creation of announcement only once", async () => { + const { account, plugin, manager } = await setup(); + + const timestamp = (await ethers.provider.getBlock("latest"))?.timestamp || 0; + + const txHash = await plugin.getTransactionHash(manager.target, account.target, user1.address, user2.address, user3.address, 0); + expect( + await plugin + .connect(recoverer) + .createAnnouncement( + manager.target, + account.target, + user1.address, + user2.address, + user3.address, + 0, + timestamp + 10, + validityDuration, + ), + ) + .to.emit(plugin, "NewRecoveryAnnouncement") + .withArgs(account.target, txHash); + + await expect( + plugin + .connect(recoverer) + .createAnnouncement( + manager.target, + account.target, + user1.address, + user2.address, + user3.address, + 0, + timestamp + 10, + validityDuration, + ), + ).to.be.revertedWithCustomError(plugin, "TransactionAlreadyScheduled"); + }); + + it("Allow only recoverer to create announcement", async () => { + const { account, plugin, manager } = await setup(); + + const timestamp = (await ethers.provider.getBlock("latest"))?.timestamp || 0; + + await expect( + plugin + .connect(user1) + .createAnnouncement( + manager.target, + account.target, + user1.address, + user2.address, + user3.address, + 0, + timestamp + 10, + validityDuration, + ), + ).to.be.revertedWithCustomError(plugin, "CallerNotValidRecoverer"); + }); + + it("Should allow cancellation only once", async () => { + const { account, plugin, manager } = await setup(); + + const timestamp = (await ethers.provider.getBlock("latest"))?.timestamp || 0; + expect( + await plugin + .connect(recoverer) + .createAnnouncement( + manager.target, + account.target, + user1.address, + user2.address, + user3.address, + 0, + timestamp + 10, + validityDuration, + ), + ); + + expect( + await plugin + .connect(recoverer) + .cancelAnnouncement(manager.target, account.target, user1.address, user2.address, user3.address, 0), + ); + + await expect( + plugin.connect(recoverer).cancelAnnouncement(manager.target, account.target, user1.address, user2.address, user3.address, 0), + ).to.be.revertedWithCustomError(plugin, "TransactionNotFound"); + }); + + it("Should allow only recoverer to cancel an announcement", async () => { + const { account, plugin, manager } = await setup(); + + const timestamp = (await ethers.provider.getBlock("latest"))?.timestamp || 0; + expect( + await plugin + .connect(recoverer) + .createAnnouncement( + manager.target, + account.target, + user1.address, + user2.address, + user3.address, + 0, + timestamp + 10, + validityDuration, + ), + ); + + await expect( + plugin.connect(user2).cancelAnnouncement(manager.target, account.target, user1.address, user2.address, user3.address, 0), + ).to.be.revertedWithCustomError(plugin, "CallerNotValidRecoverer"); + }); + + it("Should cancel an announcement", async () => { + const { account, plugin, manager } = await setup(); + + const timestamp = (await ethers.provider.getBlock("latest"))?.timestamp || 0; + expect( + await plugin + .connect(recoverer) + .createAnnouncement( + manager.target, + account.target, + user1.address, + user2.address, + user3.address, + 0, + timestamp + 10, + validityDuration, + ), + ); + + expect( + await plugin + .connect(recoverer) + .cancelAnnouncement(manager.target, account.target, user1.address, user2.address, user3.address, 0), + ).to.emit(plugin, "RecoveryAnnouncementCancelled"); + }); + + it("Should not allow cancellation after execution", async () => { + const { account, plugin, manager } = await setup(); + + const timestamp = (await ethers.provider.getBlock("latest"))?.timestamp || 0; + expect( + await plugin + .connect(recoverer) + .createAnnouncement( + manager.target, + account.target, + user1.address, + user2.address, + user3.address, + 0, + timestamp + 10, + validityDuration, + ), + ); + + await hre.ethers.provider.send("evm_increaseTime", [60 * 60 * 24 * 10]); + await hre.ethers.provider.send("evm_mine"); + + expect( + await plugin.connect(user1).executeFromPlugin(manager.target, account.target, user1.address, user2.address, user3.address, 0n), + ) + .to.emit(plugin, "OwnerReplaced") + .withArgs(account.target, user2.address, user3.address); + + await expect( + plugin.connect(recoverer).cancelAnnouncement(manager.target, account.target, user1.address, user2.address, user3.address, 0n), + ).to.be.revertedWithCustomError(plugin, "TransactionAlreadyExecuted"); + }); + + it("Should cancel an announcement from the account", async () => { + const { account, plugin, manager } = await setup(); + + const timestamp = (await ethers.provider.getBlock("latest"))?.timestamp || 0; + expect( + await plugin + .connect(recoverer) + .createAnnouncement( + manager.target, + account.target, + user1.address, + user2.address, + user3.address, + 0n, + timestamp + 10, + validityDuration, + ), + ); + await hre.ethers.provider.send("evm_increaseTime", [60 * 60 * 24 * 10]); // 10 days + await hre.ethers.provider.send("evm_mine"); + + const data = plugin.interface.encodeFunctionData("cancelAnnouncementFromAccount", [ + manager.target, + user1.address, + user2.address, + user3.address, + 0n, + ]); + await account.executeCallViaMock(plugin.target, 0n, data, MaxUint256); + + expect( + await plugin.connect(user1).executeFromPlugin(manager.target, account.target, user1.address, user2.address, user3.address, 0n), + ).to.be.revertedWithCustomError(plugin, "TransactionExecutionNotAllowedYet"); + }); +});