diff --git a/.gas-snapshot b/.gas-snapshot new file mode 100644 index 00000000..535cbce1 --- /dev/null +++ b/.gas-snapshot @@ -0,0 +1,63 @@ +AddToAllowlist_Unit_Concrete_Test:test_AddToAllowlist() (gas: 178178) +AddToAllowlist_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 12955) +AddToAllowlist_Unit_Concrete_Test:test_RevertWhen_InvalidZeroCodeModule() (gas: 13123) +CancelInvoice_Integration_Concret_Test:test_CancelInvoice_PaymentMethodLinearStream_StatusOngoing() (gas: 326597) +CancelInvoice_Integration_Concret_Test:test_CancelInvoice_PaymentMethodLinearStream_StatusPending() (gas: 30566) +CancelInvoice_Integration_Concret_Test:test_CancelInvoice_PaymentMethodTranchedStream_StatusOngoing() (gas: 445110) +CancelInvoice_Integration_Concret_Test:test_CancelInvoice_PaymentMethodTranchedStream_StatusPending() (gas: 30566) +CancelInvoice_Integration_Concret_Test:test_CancelInvoice_PaymentMethodTransfer() (gas: 30497) +CancelInvoice_Integration_Concret_Test:test_RevertWhen_InvoiceIsCanceled() (gas: 27177) +CancelInvoice_Integration_Concret_Test:test_RevertWhen_InvoiceIsPaid() (gas: 49663) +CancelInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodLinearStream_StatusOngoing_SenderNoInitialtStreamSender() (gas: 285668) +CancelInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodLinearStream_StatusPending_SenderNotInvoiceRecipient() (gas: 20666) +CancelInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodTranchedStream_StatusOngoing_SenderNoInitialtStreamSender() (gas: 402986) +CancelInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodTranchedStream_StatusPending_SenderNotInvoiceRecipient() (gas: 20642) +CancelInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodTransfer_SenderNotInvoiceRecipient() (gas: 20575) +CreateContainer_Unit_Concrete_Test:test_CreateContainer_DockIdNonZero() (gas: 2079188) +CreateContainer_Unit_Concrete_Test:test_CreateContainer_DockIdZero() (gas: 1066370) +CreateContainer_Unit_Concrete_Test:test_RevertWhen_CallerNotDockOwner() (gas: 1084452) +CreateInvoice_Integration_Concret_Test:test_CreateInvoice_LinearStream() (gas: 251262) +CreateInvoice_Integration_Concret_Test:test_CreateInvoice_PaymentMethodOneOffTransfer() (gas: 251466) +CreateInvoice_Integration_Concret_Test:test_CreateInvoice_RecurringTransfer() (gas: 252617) +CreateInvoice_Integration_Concret_Test:test_CreateInvoice_Tranched() (gas: 252890) +CreateInvoice_Integration_Concret_Test:test_RevertWhen_CallerNotContract() (gas: 89343) +CreateInvoice_Integration_Concret_Test:test_RevertWhen_EndTimeInThePast() (gas: 102376) +CreateInvoice_Integration_Concret_Test:test_RevertWhen_NonCompliantContainer() (gas: 92598) +CreateInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodLinearStream_PaymentAssetNativeToken() (gas: 102494) +CreateInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodRecurringTransfer_PaymentIntervalTooShortForSelectedRecurrence() (gas: 103184) +CreateInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodTranchedStream_PaymentAssetNativeToken() (gas: 103664) +CreateInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodTranchedStream_PaymentIntervalTooShortForSelectedRecurrence() (gas: 103228) +CreateInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodTranchedStream_RecurrenceSetToOneOff() (gas: 101872) +CreateInvoice_Integration_Concret_Test:test_RevertWhen_StartTimeGreaterThanEndTime() (gas: 101700) +CreateInvoice_Integration_Concret_Test:test_RevertWhen_ZeroPaymentAmount() (gas: 81059) +DisableModule_Unit_Concrete_Test:test_DisableModule() (gas: 177591) +DisableModule_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 16425) +EnableModule_Unit_Concrete_Test:test_EnableModule() (gas: 33937) +EnableModule_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 16422) +EnableModule_Unit_Concrete_Test:test_RevertWhen_ModuleNotAllowlisted() (gas: 24667) +Execute_Unit_Concrete_Test:test_Execute() (gas: 84136) +Execute_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 18992) +Execute_Unit_Concrete_Test:test_RevertWhen_ModuleNotEnabled() (gas: 19065) +PayInvoice_Integration_Concret_Test:test_PayInvoice_PaymentMethodLinearStream() (gas: 309543) +PayInvoice_Integration_Concret_Test:test_PayInvoice_PaymentMethodTranchedStream() (gas: 434948) +PayInvoice_Integration_Concret_Test:test_PayInvoice_PaymentMethodTransfer_ERC20Token_Recurring() (gas: 87177) +PayInvoice_Integration_Concret_Test:test_PayInvoice_PaymentMethodTransfer_NativeToken_OneOff() (gas: 63518) +PayInvoice_Integration_Concret_Test:test_RevertWhen_InvoiceAlreadyPaid() (gas: 62518) +PayInvoice_Integration_Concret_Test:test_RevertWhen_InvoiceCanceled() (gas: 29675) +PayInvoice_Integration_Concret_Test:test_RevertWhen_InvoiceNull() (gas: 17874) +PayInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodTransfer_NativeTokenTransferFails() (gas: 166006) +PayInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodTransfer_PaymentAmountLessThanInvoiceValue() (gas: 31961) +Receive_Unit_Concrete_Test:test_Receive() (gas: 20732) +RemoveFromAllowlist_Unit_Concrete_Test:test_AddToAllowlist() (gas: 22211) +RemoveFromAllowlist_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 12982) +TransferContainerOwnership_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 1083749) +TransferContainerOwnership_Unit_Concrete_Test:test_RevertWhen_InvalidOwnerZeroAddress() (gas: 1081714) +TransferContainerOwnership_Unit_Concrete_Test:test_transferContainerOwnership() (gas: 1089571) +WithdrawERC20_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 16382) +WithdrawERC20_Unit_Concrete_Test:test_RevertWhen_InsufficientERC20ToWithdraw() (gas: 24022) +WithdrawERC20_Unit_Concrete_Test:test_WithdrawERC20() (gas: 90472) +WithdrawLinearStream_Integration_Concret_Test:test_WithdrawLinearStream() (gas: 317093) +WithdrawNative_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 16402) +WithdrawNative_Unit_Concrete_Test:test_RevertWhen_InsufficientNativeToWithdraw() (gas: 16391) +WithdrawNative_Unit_Concrete_Test:test_WithdrawNative() (gas: 37436) +WithdrawTranchedStream_Integration_Concret_Test:test_WithdrawTranchedStream() (gas: 437924) \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 83348900..bdd3309d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,9 @@ [submodule "lib/nomad-xyz/excessively-safe-call"] path = lib/nomad-xyz/excessively-safe-call url = https://github.com/nomad-xyz/ExcessivelySafeCall +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable + url = https://github.com/openzeppelin/openzeppelin-contracts-upgradeable +[submodule "lib/openzeppelin-foundry-upgrades"] + path = lib/openzeppelin-foundry-upgrades + url = https://github.com/openzeppelin/openzeppelin-foundry-upgrades diff --git a/Makefile b/Makefile index 2d756f85..75bcfbcf 100644 --- a/Makefile +++ b/Makefile @@ -61,4 +61,16 @@ deploy-deterministic-module-keeper: $(CREATE2SALT) {INITIAL_OWNER} \ --sig "run(string,address)" --rpc-url {RPC_URL} \ --private-key $(PRIVATE_KEY) --etherscan-api-key $(ETHERSCAN_API_KEY) \ + --broadcast --verify + +# Deploy the {DockRegistry} contract deterministically +# Update the following configs before running the script: +# - {INITIAL_OWNER} with the address of the initial owner +# - {MODULE_KEEPER} with the address of the {ModuleKeeper} deployment +# - {RPC_URL} with the network RPC used for deployment +deploy-deterministic-dock-registry: + forge script script/DeployDeterministicDockRegistry.s.sol:DeployDeterministicDockRegistry \ + $(CREATE2SALT) {INITIAL_OWNER} {MODULE_KEEPER} \ + --sig "run(string,address,address)" --rpc-url {RPC_URL} \ + --private-key $(PRIVATE_KEY) --etherscan-api-key $(ETHERSCAN_API_KEY) \ --broadcast --verify \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 707a1632..501ce9fa 100644 --- a/foundry.toml +++ b/foundry.toml @@ -4,6 +4,7 @@ out = "out" libs = ["lib"] optimizer = true optimizer_runs = 1000 +gas_reports = ["ModuleKeeper", "DockRegistry", "Container"] [fmt] bracket_spacing = true diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 00000000..723f8cab --- /dev/null +++ b/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit 723f8cab09cdae1aca9ec9cc1cfa040c2d4b06c1 diff --git a/lib/openzeppelin-foundry-upgrades b/lib/openzeppelin-foundry-upgrades new file mode 160000 index 00000000..4cd15fc5 --- /dev/null +++ b/lib/openzeppelin-foundry-upgrades @@ -0,0 +1 @@ +Subproject commit 4cd15fc50b141c77d8cc9ff8efb44d00e841a299 diff --git a/script/DeployContainer.s.sol b/script/DeployContainer.s.sol index c45ebcf4..7d0baa3f 100644 --- a/script/DeployContainer.s.sol +++ b/script/DeployContainer.s.sol @@ -3,16 +3,17 @@ pragma solidity ^0.8.26; import { BaseScript } from "./Base.s.sol"; import { Container } from "../src/Container.sol"; -import { ModuleKeeper } from "./../src/ModuleKeeper.sol"; +import { DockRegistry } from "./../src/DockRegistry.sol"; /// @notice Deploys an instance of {Container} and enables initial module(s) contract DeployContainer is BaseScript { function run( + DockRegistry dockRegistry, address initialOwner, - ModuleKeeper moduleKeeper, + uint256 dockId, address[] memory initialModules ) public virtual broadcast returns (Container container) { - // Ddeploy the {InvoiceModule} contracts - container = new Container(initialOwner, moduleKeeper, initialModules); + // Deploy a new {Container} through the {DockRegistry} + container = Container(payable(dockRegistry.createContainer(dockId, initialOwner, initialModules))); } } diff --git a/script/DeployDeterministicContainer.s.sol b/script/DeployDeterministicDockRegistry.s.sol similarity index 59% rename from script/DeployDeterministicContainer.s.sol rename to script/DeployDeterministicDockRegistry.s.sol index 6328353a..ff5f6107 100644 --- a/script/DeployDeterministicContainer.s.sol +++ b/script/DeployDeterministicDockRegistry.s.sol @@ -2,23 +2,22 @@ pragma solidity ^0.8.26; import { BaseScript } from "./Base.s.sol"; -import { Container } from "../src/Container.sol"; +import { DockRegistry } from "./../src/DockRegistry.sol"; import { ModuleKeeper } from "./../src/ModuleKeeper.sol"; -/// @notice Deploys at deterministic addresses across chains an instance of {Container} and enables initial module(s) +/// @notice Deploys at deterministic addresses across chains an instance of {DockRegistry} /// @dev Reverts if any contract has already been deployed -contract DeployDeterministicContainer is BaseScript { +contract DeployDeterministicDockRegistry is BaseScript { /// @dev By using a salt, Forge will deploy the contract via a deterministic CREATE2 factory /// https://book.getfoundry.sh/tutorials/create2-tutorial?highlight=deter#deterministic-deployment-using-create2 function run( string memory create2Salt, address initialOwner, - ModuleKeeper moduleKeeper, - address[] memory initialModules - ) public virtual broadcast returns (Container container) { + ModuleKeeper moduleKeeper + ) public virtual broadcast returns (DockRegistry dockRegistry) { bytes32 salt = bytes32(abi.encodePacked(create2Salt)); - // Deterministically deploy a {Container} contract - container = new Container{ salt: salt }(initialOwner, moduleKeeper, initialModules); + // Deterministically deploy a {DockRegistry} contract + dockRegistry = new DockRegistry{ salt: salt }(initialOwner, moduleKeeper); } } diff --git a/src/Container.sol b/src/Container.sol index dd41cec6..92e0123c 100644 --- a/src/Container.sol +++ b/src/Container.sol @@ -8,14 +8,14 @@ import { ExcessivelySafeCall } from "@nomad-xyz/excessively-safe-call/src/Excess import { IContainer } from "./interfaces/IContainer.sol"; import { ModuleManager } from "./abstracts/ModuleManager.sol"; -import { Ownable } from "./abstracts/Ownable.sol"; import { IModuleManager } from "./interfaces/IModuleManager.sol"; import { Errors } from "./libraries/Errors.sol"; import { ModuleKeeper } from "./ModuleKeeper.sol"; +import { DockRegistry } from "./DockRegistry.sol"; /// @title Container /// @notice See the documentation in {IContainer} -contract Container is IContainer, Ownable, ModuleManager { +contract Container is IContainer, ModuleManager { using SafeERC20 for IERC20; using ExcessivelySafeCall for address; @@ -25,10 +25,21 @@ contract Container is IContainer, Ownable, ModuleManager { /// @dev Initializes the address of the {Container} owner, {ModuleKeeper} and enables the initial module(s) constructor( - address _owner, - ModuleKeeper _moduleKeeper, + DockRegistry _dockRegistry, address[] memory _initialModules - ) Ownable(_owner) ModuleManager(_moduleKeeper, _initialModules) { } + ) ModuleManager(_dockRegistry, _initialModules) { + dockRegistry = _dockRegistry; + } + + /*////////////////////////////////////////////////////////////////////////// + MODIFIERS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Reverts if the `msg.sender` is not the owner of the {Container} assigned in the registry + modifier onlyOwner() { + if (msg.sender != dockRegistry.ownerOfContainer(address(this))) revert Errors.CallerNotContainerOwner(); + _; + } /*////////////////////////////////////////////////////////////////////////// NON-CONSTANT FUNCTIONS diff --git a/src/DockRegistry.sol b/src/DockRegistry.sol new file mode 100644 index 00000000..eb4bc3c8 --- /dev/null +++ b/src/DockRegistry.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.26; + +import { Ownable } from "./abstracts/Ownable.sol"; +import { IDockRegistry } from "./interfaces/IDockRegistry.sol"; +import { Container } from "./Container.sol"; +import { ModuleKeeper } from "./ModuleKeeper.sol"; +import { Errors } from "./libraries/Errors.sol"; + +/// @title DockRegistry +/// @notice See the documentation in {IDockRegistry} +contract DockRegistry is IDockRegistry, Ownable { + /*////////////////////////////////////////////////////////////////////////// + PUBLIC STORAGE + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IDockRegistry + ModuleKeeper public override moduleKeeper; + + /// @inheritdoc IDockRegistry + mapping(uint256 dockId => address owner) public override ownerOfDock; + + /// @inheritdoc IDockRegistry + mapping(address container => uint256 dockId) public override dockIdOfContainer; + + /// @inheritdoc IDockRegistry + mapping(address container => address owner) public override ownerOfContainer; + + /*////////////////////////////////////////////////////////////////////////// + PRIVATE STORAGE + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Counter to keep track of the next dock ID + uint256 private _dockNextId; + + /*////////////////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Initializes the address of the {ModuleKeeper} contract, registry owner and sets the next dock ID to start from 1 + constructor(address _initialOwner, ModuleKeeper _moduleKeeper) Ownable(_initialOwner) { + _dockNextId = 1; + moduleKeeper = _moduleKeeper; + } + + /*////////////////////////////////////////////////////////////////////////// + NON-CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IDockRegistry + function createContainer( + uint256 dockId, + address owner, + address[] calldata initialModules + ) public returns (address container) { + // Checks: a new dock must be created first + if (dockId == 0) { + // Store the ID of the next dock + dockId = _dockNextId; + + // Effects: set the owner of the freshly created dock + ownerOfDock[dockId] = msg.sender; + + // Effects: increment the next dock ID + // Use unchecked because the dock ID cannot realistically overflow + unchecked { + _dockNextId++; + } + } else { + // Checks: `msg.sender` is the dock owner + if (ownerOfDock[dockId] != msg.sender) { + revert Errors.CallerNotDockOwner(); + } + } + + // Interactions: deploy a new {Container} + container = + address(new Container({ _dockRegistry: DockRegistry(address(this)), _initialModules: initialModules })); + + // Assign the ID of the dock to which the new container belongs + dockIdOfContainer[container] = dockId; + + // Assign the owner of the container + ownerOfContainer[container] = owner; + + // Log the {Container} creation + emit ContainerCreated(owner, dockId, container, initialModules); + } + + /// @inheritdoc IDockRegistry + function transferContainerOwnership(address container, address newOwner) external { + // Checks: `msg.sender` is the current owner of the {Container} + address currentOwner = ownerOfContainer[container]; + if (msg.sender != currentOwner) { + revert Errors.CallerNotContainerOwner(); + } + + // Checks: the new owner is not the zero address + if (newOwner == address(0)) { + revert Errors.InvalidOwnerZeroAddress(); + } + + // Effects: update container's ownership + ownerOfContainer[container] = newOwner; + + // Log the ownership transfer + emit ContainerOwnershipTransferred({ container: container, oldOwner: currentOwner, newOwner: newOwner }); + } + + /// @inheritdoc IDockRegistry + function transferDockOwnership(uint256 dockId, address newOwner) external { + // Checks: `msg.sender` is the current owner of the dock + address currentOwner = ownerOfDock[dockId]; + if (msg.sender != currentOwner) { + revert Errors.CallerNotDockOwner(); + } + + // Effects: update dock's ownership + ownerOfDock[dockId] = newOwner; + + // Log the ownership transfer + emit DockOwnershipTransferred({ dockId: dockId, oldOwner: currentOwner, newOwner: newOwner }); + } + + /// @inheritdoc IDockRegistry + function updateModuleKeeper(ModuleKeeper newModuleKeeper) external onlyOwner { + // Effects: update the {ModuleKeeper} address + moduleKeeper = newModuleKeeper; + + // Log the update + emit ModuleKeeperUpdated(newModuleKeeper); + } +} diff --git a/src/abstracts/ModuleManager.sol b/src/abstracts/ModuleManager.sol index 0ceb46ca..4a5490a4 100644 --- a/src/abstracts/ModuleManager.sol +++ b/src/abstracts/ModuleManager.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.26; import { IModuleManager } from "./../interfaces/IModuleManager.sol"; +import { DockRegistry } from "./../DockRegistry.sol"; import { ModuleKeeper } from "./../ModuleKeeper.sol"; import { Errors } from "./../libraries/Errors.sol"; @@ -13,7 +14,7 @@ abstract contract ModuleManager is IModuleManager { //////////////////////////////////////////////////////////////////////////*/ /// @inheritdoc IModuleManager - ModuleKeeper public immutable override moduleKeeper; + DockRegistry public immutable override dockRegistry; /// @inheritdoc IModuleManager mapping(address module => bool) public override isModuleEnabled; @@ -23,8 +24,8 @@ abstract contract ModuleManager is IModuleManager { //////////////////////////////////////////////////////////////////////////*/ /// @dev Initializes the {ModuleKeeper} address and initial module(s) enabled on the container - constructor(ModuleKeeper _moduleKeeper, address[] memory _initialModules) { - moduleKeeper = _moduleKeeper; + constructor(DockRegistry _dockRegistry, address[] memory _initialModules) { + dockRegistry = _dockRegistry; _enableBatchModules(_initialModules); } @@ -67,6 +68,8 @@ abstract contract ModuleManager is IModuleManager { /// @dev Enables one single module at a time function _enableModule(address module) internal { + ModuleKeeper moduleKeeper = dockRegistry.moduleKeeper(); + // Check: module is in the allowlist if (!moduleKeeper.isAllowlisted(module)) { revert Errors.ModuleNotAllowlisted(); diff --git a/src/interfaces/IDockRegistry.sol b/src/interfaces/IDockRegistry.sol new file mode 100644 index 00000000..3a0a19b5 --- /dev/null +++ b/src/interfaces/IDockRegistry.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.26; + +import { IContainer } from "./IContainer.sol"; +import { Container } from "./../Container.sol"; +import { IModuleKeeper } from "./IModuleKeeper.sol"; +import { ModuleKeeper } from "./../ModuleKeeper.sol"; + +/// @title IDockRegistry +/// @notice Contract that provides functionalities to create docks and deploy {Container}s from a single place +interface IDockRegistry { + /*////////////////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Emitted when a new {Container} contract gets deployed + /// @param owner The address of the owner + /// @param dockId The ID of the dock to which this {Container} belongs + /// @param container The address of the {Container} + /// @param initialModules Array of initially enabled modules + event ContainerCreated(address indexed owner, uint256 indexed dockId, address container, address[] initialModules); + + /// @notice Emitted when the ownership of a {Container} is transferred to a new owner + /// @param container The address of the {Container} + /// @param oldOwner The address of the current owner + /// @param newOwner The address of the new owner + event ContainerOwnershipTransferred(address indexed container, address oldOwner, address newOwner); + + /// @notice Emitted when the ownership of a {Dock} is transferred to a new owner + /// @param dockId The address of the {Dock} + /// @param oldOwner The address of the current owner + /// @param newOwner The address of the new owner + event DockOwnershipTransferred(uint256 indexed dockId, address oldOwner, address newOwner); + + /// @notice Emitted when the {ModuleKeeper} address is updated + /// @param newModuleKeeper The new address of the {ModuleKeeper} + event ModuleKeeperUpdated(IModuleKeeper newModuleKeeper); + + /*////////////////////////////////////////////////////////////////////////// + CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Returns the address of the {ModuleKeeper} contract + function moduleKeeper() external view returns (ModuleKeeper); + + /// @notice Retrieves the owner of the given dock ID + function ownerOfDock(uint256 dockId) external view returns (address); + + /// @notice Retrieves the dock ID of the given container address + function dockIdOfContainer(address container) external view returns (uint256); + + /// @notice Retrieves the owner address of the {Container}'s address + function ownerOfContainer(address container) external view returns (address); + + /*////////////////////////////////////////////////////////////////////////// + NON-CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Creates a new {Container} contract and attaches it to a dock + /// + /// Notes: + /// - if `dockId` equal zero, a new dock will be created + /// + /// Requirements: + /// - `msg.sender` MUST be the dock owner if a new container is to be attached to an existing dock + /// + /// @param dockId The ID of the dock to attach the {Container} to + /// @param owner The address of the {Container} owner + /// @param initialModules Array of initially enabled modules + function createContainer( + uint256 dockId, + address owner, + address[] memory initialModules + ) external returns (address container); + + /// @notice Transfers the ownership of the `container` container + /// + /// Requirements: + /// - reverts if `msg.sender` is not the current {Container} owner + /// - revert if `newOwner` is the zero-address + /// + /// @param container The address of the {Container} instance whose ownership is to be transferred + /// @param newOwner The address of the new owner + function transferContainerOwnership(address container, address newOwner) external; + + /// @notice Transfers the ownership of the `dockId` dock + /// + /// Notes: + /// - does not check for zero-address; ownership will be renounced if `newOwner` is the zero-address + /// + /// Requirements: + /// - `msg.sender` MUST be the current dock owner + /// + /// @param dockId The ID of the dock of whose ownership is to be transferred + /// @param newOwner The address of the new owner + function transferDockOwnership(uint256 dockId, address newOwner) external; + + /// @notice Updates the address of the {ModuleKeeper} + /// + /// Notes: + /// - does not check for zero-address; + /// + /// Requirements: + /// - reverts if `msg.sender` is not the {DockRegistry} owner + /// + /// @param newModuleKeeper The new address of the {ModuleKeeper} + function updateModuleKeeper(ModuleKeeper newModuleKeeper) external; +} diff --git a/src/interfaces/IModuleManager.sol b/src/interfaces/IModuleManager.sol index 6a323a10..86dcba5a 100644 --- a/src/interfaces/IModuleManager.sol +++ b/src/interfaces/IModuleManager.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.26; -import { ModuleKeeper } from "./../ModuleKeeper.sol"; +import { DockRegistry } from "./../DockRegistry.sol"; /// @title IModuleManager /// @notice Contract that provides functionalities to manage multiple modules within a {Container} contract @@ -22,8 +22,8 @@ interface IModuleManager { CONSTANT FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ - /// @notice Returns the address of the {ModuleKeeper} contract - function moduleKeeper() external view returns (ModuleKeeper); + /// @notice Returns the address of the {DockRegistry} contract + function dockRegistry() external view returns (DockRegistry); /// @notice Checks whether the `module` module is enabled on the container function isModuleEnabled(address module) external view returns (bool isEnabled); diff --git a/src/libraries/Errors.sol b/src/libraries/Errors.sol index 8c3b9217..0d739bda 100644 --- a/src/libraries/Errors.sol +++ b/src/libraries/Errors.sol @@ -4,12 +4,19 @@ pragma solidity ^0.8.26; /// @title Errors /// @notice Library containing all custom errors the protocol may revert with library Errors { + /*////////////////////////////////////////////////////////////////////////// + DOCK-REGISTRY + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Thrown when `msg.sender` is not the dock owner + error CallerNotDockOwner(); + /*////////////////////////////////////////////////////////////////////////// CONTAINER //////////////////////////////////////////////////////////////////////////*/ /// @notice Thrown when `msg.sender` is not the {Container} contract owner - error Unauthorized(); + error CallerNotContainerOwner(); /// @notice Thrown when a native token (ETH) withdrawal fails error NativeWithdrawFailed(); @@ -51,4 +58,7 @@ library Errors { /// @notice Thrown when attempting to transfer ownership to the zero address error InvalidOwnerZeroAddress(); + + /// @notice Thrown when `msg.sender` is not the contract owner + error Unauthorized(); } diff --git a/test/Base.t.sol b/test/Base.t.sol index 60d4a27e..cd0ef55c 100644 --- a/test/Base.t.sol +++ b/test/Base.t.sol @@ -10,6 +10,7 @@ import { MockModule } from "./mocks/MockModule.sol"; import { MockBadReceiver } from "./mocks/MockBadReceiver.sol"; import { Container } from "./../src/Container.sol"; import { ModuleKeeper } from "./../src/ModuleKeeper.sol"; +import { DockRegistry } from "./../src/DockRegistry.sol"; abstract contract Base_Test is Test, Events { /*////////////////////////////////////////////////////////////////////////// @@ -22,6 +23,7 @@ abstract contract Base_Test is Test, Events { TEST CONTRACTS //////////////////////////////////////////////////////////////////////////*/ + DockRegistry internal dockRegistry; Container internal container; ModuleKeeper internal moduleKeeper; MockERC20NoReturn internal usdt; @@ -29,6 +31,12 @@ abstract contract Base_Test is Test, Events { MockNonCompliantContainer internal mockNonCompliantContainer; MockBadReceiver internal mockBadReceiver; + /*////////////////////////////////////////////////////////////////////////// + TEST STORAGE + //////////////////////////////////////////////////////////////////////////*/ + + address[] internal mockModules; + /*////////////////////////////////////////////////////////////////////////// SET-UP FUNCTION //////////////////////////////////////////////////////////////////////////*/ @@ -41,10 +49,14 @@ abstract contract Base_Test is Test, Events { users = Users({ admin: createUser("admin"), eve: createUser("eve"), bob: createUser("bob") }); // Deploy test contracts + moduleKeeper = new ModuleKeeper({ _initialOwner: users.admin }); + dockRegistry = new DockRegistry({ _initialOwner: users.admin, _moduleKeeper: moduleKeeper }); mockModule = new MockModule(); mockNonCompliantContainer = new MockNonCompliantContainer({ _owner: users.admin }); mockBadReceiver = new MockBadReceiver(); - moduleKeeper = new ModuleKeeper({ _initialOwner: users.admin }); + + // Create a mock modules array + mockModules.push(address(mockModule)); // Label the test contracts so we can easily track them vm.label({ account: address(usdt), newLabel: "USDT" }); @@ -59,7 +71,7 @@ abstract contract Base_Test is Test, Events { /// @dev Deploys a new {Container} contract based on the provided `owner`, `moduleKeeper` and `initialModules` input params function deployContainer( address _owner, - ModuleKeeper _moduleKeeper, + uint256 _dockId, address[] memory _initialModules ) internal returns (Container _container) { vm.startPrank({ msgSender: users.admin }); @@ -68,7 +80,9 @@ abstract contract Base_Test is Test, Events { } vm.stopPrank(); - _container = new Container(_owner, _moduleKeeper, _initialModules); + _container = Container( + payable(dockRegistry.createContainer({ dockId: _dockId, owner: _owner, initialModules: _initialModules })) + ); } function allowlistModule(address _module) internal { @@ -87,4 +101,13 @@ abstract contract Base_Test is Test, Events { return user; } + + /// @dev Predicts the address of the next contract that is going to be deployed by the `deployer` + function computeDeploymentAddress(address deployer) internal view returns (address expectedAddress) { + // Calculate the current nonce of the deployer account + uint256 deployerNonce = vm.getNonce({ account: address(deployer) }); + + // Pre-compute the address of the next contract to be deployed + expectedAddress = vm.computeCreateAddress({ deployer: address(deployer), nonce: deployerNonce }); + } } diff --git a/test/integration/Integration.t.sol b/test/integration/Integration.t.sol index 12e50a68..82120aa2 100644 --- a/test/integration/Integration.t.sol +++ b/test/integration/Integration.t.sol @@ -33,7 +33,7 @@ abstract contract Integration_Test is Base_Test { modules[0] = address(invoiceModule); // Deploy the {Container} contract with the {InvoiceModule} enabled by default - container = deployContainer({ _owner: users.eve, _moduleKeeper: moduleKeeper, _initialModules: modules }); + container = deployContainer({ _owner: users.eve, _dockId: 0, _initialModules: modules }); // Label the test contracts so we can easily track them vm.label({ account: address(invoiceModule), newLabel: "InvoiceModule" }); diff --git a/test/unit/concrete/container/Container.t.sol b/test/unit/concrete/container/Container.t.sol index c663fe0b..c900f577 100644 --- a/test/unit/concrete/container/Container.t.sol +++ b/test/unit/concrete/container/Container.t.sol @@ -10,6 +10,6 @@ contract Container_Unit_Concrete_Test is Base_Test { address[] memory modules = new address[](1); modules[0] = address(mockModule); - container = deployContainer({ _owner: users.eve, _moduleKeeper: moduleKeeper, _initialModules: modules }); + container = deployContainer({ _owner: users.eve, _dockId: 0, _initialModules: modules }); } } diff --git a/test/unit/concrete/container/disable-module/disableModule.t.sol b/test/unit/concrete/container/disable-module/disableModule.t.sol index 03c0abaf..d552f78f 100644 --- a/test/unit/concrete/container/disable-module/disableModule.t.sol +++ b/test/unit/concrete/container/disable-module/disableModule.t.sol @@ -15,8 +15,8 @@ contract DisableModule_Unit_Concrete_Test is Container_Unit_Concrete_Test { // Make Bob the caller for this test suite who is not the owner of the container vm.startPrank({ msgSender: users.bob }); - // Expect the next call to revert with the {Unauthorized} error - vm.expectRevert(Errors.Unauthorized.selector); + // Expect the next call to revert with the {CallerNotContainerOwner} error + vm.expectRevert(Errors.CallerNotContainerOwner.selector); // Run the test container.disableModule({ module: address(0x1) }); diff --git a/test/unit/concrete/container/disable-module/disableModule.tree b/test/unit/concrete/container/disable-module/disableModule.tree index 640e6942..011a274b 100644 --- a/test/unit/concrete/container/disable-module/disableModule.tree +++ b/test/unit/concrete/container/disable-module/disableModule.tree @@ -1,6 +1,6 @@ disableModule.t.sol ├── when the caller IS NOT the container owner -│ └── it should revert with the {Unauthorized} error +│ └── it should revert with the {CallerNotContainerOwner} error └── when the caller IS the container owner └── given module enabled ├── it should mark the module as disabled diff --git a/test/unit/concrete/container/enable-module/enableModule.t.sol b/test/unit/concrete/container/enable-module/enableModule.t.sol index 7bc08db0..763394ac 100644 --- a/test/unit/concrete/container/enable-module/enableModule.t.sol +++ b/test/unit/concrete/container/enable-module/enableModule.t.sol @@ -15,8 +15,8 @@ contract EnableModule_Unit_Concrete_Test is Container_Unit_Concrete_Test { // Make Bob the caller for this test suite who is not the owner of the container vm.startPrank({ msgSender: users.bob }); - // Expect the next call to revert with the {Unauthorized} error - vm.expectRevert(Errors.Unauthorized.selector); + // Expect the next call to revert with the {CallerNotContainerOwner} error + vm.expectRevert(Errors.CallerNotContainerOwner.selector); // Run the test container.enableModule({ module: address(0x1) }); diff --git a/test/unit/concrete/container/enable-module/enableModule.tree b/test/unit/concrete/container/enable-module/enableModule.tree index ab530425..2c5fea4b 100644 --- a/test/unit/concrete/container/enable-module/enableModule.tree +++ b/test/unit/concrete/container/enable-module/enableModule.tree @@ -1,6 +1,6 @@ enableModule.t.sol ├── when the caller IS NOT the container owner -│ └── it should revert with the {Unauthorized} error +│ └── it should revert with the {CallerNotContainerOwner} error └── when the caller IS the container owner ├── when the module IS NOT allowlisted │ └── it should revert with the {ModuleNotAllowlisted} error diff --git a/test/unit/concrete/container/execute/execute.t.sol b/test/unit/concrete/container/execute/execute.t.sol index c7935d8a..589ae10a 100644 --- a/test/unit/concrete/container/execute/execute.t.sol +++ b/test/unit/concrete/container/execute/execute.t.sol @@ -14,8 +14,8 @@ contract Execute_Unit_Concrete_Test is Container_Unit_Concrete_Test { // Make Bob the caller for this test suite who is not the owner of the container vm.startPrank({ msgSender: users.bob }); - // Expect the next call to revert with the {Unauthorized} error - vm.expectRevert(Errors.Unauthorized.selector); + // Expect the next call to revert with the {CallerNotContainerOwner} error + vm.expectRevert(Errors.CallerNotContainerOwner.selector); // Run the test container.execute({ module: address(mockModule), value: 0, data: "" }); diff --git a/test/unit/concrete/container/execute/execute.tree b/test/unit/concrete/container/execute/execute.tree index cd19303e..5b8fb68c 100644 --- a/test/unit/concrete/container/execute/execute.tree +++ b/test/unit/concrete/container/execute/execute.tree @@ -1,6 +1,6 @@ execute.t.sol ├── when the caller IS NOT the container owner -│ └── it should revert with the {Unauthorized} error +│ └── it should revert with the {CallerNotContainerOwner} error └── when the caller IS the container owner ├── when the module IS NOT enabled │ └── it should revert with the {ModuleNotEnabled} error diff --git a/test/unit/concrete/container/transfer-ownership/transferOwnership.t.sol b/test/unit/concrete/container/transfer-ownership/transferOwnership.t.sol deleted file mode 100644 index fe73e71b..00000000 --- a/test/unit/concrete/container/transfer-ownership/transferOwnership.t.sol +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.26; - -import { Container_Unit_Concrete_Test } from "../Container.t.sol"; -import { MockModule } from "../../../../mocks/MockModule.sol"; -import { Events } from "../../../../utils/Events.sol"; -import { Errors } from "../../../../utils/Errors.sol"; - -contract TransferOwnership_Unit_Concrete_Test is Container_Unit_Concrete_Test { - function setUp() public virtual override { - Container_Unit_Concrete_Test.setUp(); - } - - function test_RevertWhen_CallerNotOwner() external { - // Make Bob the caller for this test suite who is not the owner of the container - vm.startPrank({ msgSender: users.bob }); - - // Expect the next call to revert with the {Unauthorized} error - vm.expectRevert(Errors.Unauthorized.selector); - - // Run the test - container.transferOwnership({ newOwner: users.eve }); - } - - modifier whenCallerOwner() { - // Make Eve the caller for the next test suite as she's the owner of the container - vm.startPrank({ msgSender: users.eve }); - _; - } - - function test_RevertWhen_InvalidOwnerZeroAddress() external whenCallerOwner { - // Expect the next call to revert with the {InvalidOwnerZeroAddress} - vm.expectRevert(Errors.InvalidOwnerZeroAddress.selector); - - // Run the test - container.transferOwnership({ newOwner: address(0) }); - } - - modifier whenNonZeroOwnerAddress() { - _; - } - - function test_TransferOwnership() external whenCallerOwner whenNonZeroOwnerAddress { - // Expect the {OwnershipTransferred} to be emitted - vm.expectEmit(); - emit Events.OwnershipTransferred({ oldOwner: users.eve, newOwner: users.bob }); - - // Run the test - container.transferOwnership({ newOwner: users.bob }); - - // Assert the actual and expected owner - address actualOwner = container.owner(); - assertEq(actualOwner, users.bob); - } -} diff --git a/test/unit/concrete/container/withdraw-erc20/withdrawERC20.t.sol b/test/unit/concrete/container/withdraw-erc20/withdrawERC20.t.sol index 97084def..81558b05 100644 --- a/test/unit/concrete/container/withdraw-erc20/withdrawERC20.t.sol +++ b/test/unit/concrete/container/withdraw-erc20/withdrawERC20.t.sol @@ -15,8 +15,8 @@ contract WithdrawERC20_Unit_Concrete_Test is Container_Unit_Concrete_Test { // Make Bob the caller for this test suite who is not the owner of the container vm.startPrank({ msgSender: users.bob }); - // Expect the next call to revert with the {Unauthorized} error - vm.expectRevert(Errors.Unauthorized.selector); + // Expect the next call to revert with the {CallerNotContainerOwner} error + vm.expectRevert(Errors.CallerNotContainerOwner.selector); // Run the test container.withdrawERC20({ asset: IERC20(address(0x0)), amount: 100e6 }); diff --git a/test/unit/concrete/container/withdraw-erc20/withdrawERC20.tree b/test/unit/concrete/container/withdraw-erc20/withdrawERC20.tree index da3b3d46..87126903 100644 --- a/test/unit/concrete/container/withdraw-erc20/withdrawERC20.tree +++ b/test/unit/concrete/container/withdraw-erc20/withdrawERC20.tree @@ -1,6 +1,6 @@ withdrawERC20.t.sol ├── when the caller IS NOT the container owner -│ └── it should revert with the {Unauthorized} error +│ └── it should revert with the {CallerNotContainerOwner} error └── when the caller IS the container owner ├── when container ERC-20 token balance IS INSUFFICIENT to support the withdrawal │ └── it should revert with the {InsufficientERC20ToWithdraw} error diff --git a/test/unit/concrete/container/withdraw-native/withdrawNative.t.sol b/test/unit/concrete/container/withdraw-native/withdrawNative.t.sol index 1a93a997..edc6fca4 100644 --- a/test/unit/concrete/container/withdraw-native/withdrawNative.t.sol +++ b/test/unit/concrete/container/withdraw-native/withdrawNative.t.sol @@ -14,8 +14,8 @@ contract WithdrawNative_Unit_Concrete_Test is Container_Unit_Concrete_Test { // Make Bob the caller for this test suite who is not the owner of the container vm.startPrank({ msgSender: users.bob }); - // Expect the next call to revert with the {Unauthorized} error - vm.expectRevert(Errors.Unauthorized.selector); + // Expect the next call to revert with the {CallerNotContainerOwner} error + vm.expectRevert(Errors.CallerNotContainerOwner.selector); // Run the test container.withdrawNative({ amount: 2 ether }); @@ -37,7 +37,7 @@ contract WithdrawNative_Unit_Concrete_Test is Container_Unit_Concrete_Test { modifier whenSufficientNativeToWithdraw() { // Deposit sufficient native tokens (ETH) into the container to enable the withdrawal - (bool success, ) = payable(container).call{ value: 2 ether }(""); + (bool success,) = payable(container).call{ value: 2 ether }(""); if (!success) revert(); _; } diff --git a/test/unit/concrete/container/withdraw-native/withdrawNative.tree b/test/unit/concrete/container/withdraw-native/withdrawNative.tree index 57c94542..50bcaecd 100644 --- a/test/unit/concrete/container/withdraw-native/withdrawNative.tree +++ b/test/unit/concrete/container/withdraw-native/withdrawNative.tree @@ -1,6 +1,6 @@ withdrawNative.t.sol ├── when the caller IS NOT the container owner -│ └── it should revert with the {Unauthorized} error +│ └── it should revert with the {CallerNotContainerOwner} error └── when the caller IS the container owner ├── when container native token (ETH) balance IS INSUFFICIENT to support the withdrawal │ └── it should revert with the {InsufficientERC20ToWithdraw} error diff --git a/test/unit/concrete/dock-registry/DockRegistry.t.sol b/test/unit/concrete/dock-registry/DockRegistry.t.sol new file mode 100644 index 00000000..f5ce8b9c --- /dev/null +++ b/test/unit/concrete/dock-registry/DockRegistry.t.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import { Base_Test } from "../../../Base.t.sol"; + +contract DockRegistry_Unit_Concrete_Test is Base_Test { + function setUp() public virtual override { + Base_Test.setUp(); + } +} diff --git a/test/unit/concrete/dock-registry/create-container/createContainer.t.sol b/test/unit/concrete/dock-registry/create-container/createContainer.t.sol new file mode 100644 index 00000000..db1d28f7 --- /dev/null +++ b/test/unit/concrete/dock-registry/create-container/createContainer.t.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import { DockRegistry_Unit_Concrete_Test } from "../DockRegistry.t.sol"; +import { Container } from "./../../../../../src/Container.sol"; +import { Errors } from "../../../../utils/Errors.sol"; +import { Events } from "../../../../utils/Events.sol"; + +contract CreateContainer_Unit_Concrete_Test is DockRegistry_Unit_Concrete_Test { + function setUp() public virtual override { + DockRegistry_Unit_Concrete_Test.setUp(); + } + + modifier whenDockIdZero() { + _; + } + + function test_CreateContainer_DockIdZero() external whenDockIdZero { + // The {DockRegistry} contract deploys each new {Container} contract. + // Therefore, we need to calculate the current nonce of the {DockRegistry} + // to pre-compute the address of the new {Container} before deployment. + address expectedContainer = computeDeploymentAddress({ deployer: address(dockRegistry) }); + + // Allowlist the mock modules on the {ModuleKeeper} contract from the admin account + vm.startPrank({ msgSender: users.admin }); + for (uint256 i; i < mockModules.length; ++i) { + allowlistModule(mockModules[i]); + } + vm.stopPrank(); + + // Expect the {ContainerCreated} to be emitted + vm.expectEmit(); + emit Events.ContainerCreated({ + owner: users.bob, + dockId: 1, + container: Container(payable(expectedContainer)), + initialModules: mockModules + }); + + // Run the test + dockRegistry.createContainer({ owner: users.bob, dockId: 0, initialModules: mockModules }); + + // Assert the expected and actual owner of the dock + address actualOwnerOfDock = dockRegistry.ownerOfDock({ dockId: 1 }); + assertEq(address(this), actualOwnerOfDock); + + // Assert the expected and actual owner of the {Container} + address actualOwnerOfContainer = dockRegistry.ownerOfContainer({ container: expectedContainer }); + assertEq(users.bob, actualOwnerOfContainer); + + // Assert the expected and actual dock ID of the {Container} + uint256 actualDockIdOfContainer = dockRegistry.dockIdOfContainer({ container: expectedContainer }); + assertEq(1, actualDockIdOfContainer); + } + + modifier whenDockIdNonZero() { + // Create & deploy a new container with Eve as the owner + address[] memory modules = new address[](1); + modules[0] = address(mockModule); + + container = deployContainer({ _owner: users.eve, _dockId: 0, _initialModules: modules }); + _; + } + + modifier whenCallerNotDockOwner() { + // Make Bob the caller in this test suite as he's not the owner of the dock #1 + vm.startPrank({ msgSender: users.bob }); + _; + } + + function test_RevertWhen_CallerNotDockOwner() external whenDockIdNonZero whenCallerNotDockOwner { + // Create a mock modules array + address[] memory modules = new address[](1); + modules[0] = address(mockModule); + + // Expect the {CallerNotDockOwner} to be emitted + vm.expectRevert(Errors.CallerNotDockOwner.selector); + + // Run the test + dockRegistry.createContainer({ owner: users.bob, dockId: 1, initialModules: modules }); + } + + modifier whenCallerDockOwner() { + _; + } + + function test_CreateContainer_DockIdNonZero() external whenDockIdNonZero whenCallerDockOwner { + // The {DockRegistry} contract deploys each new {Container} contract. + // Therefore, we need to calculate the current nonce of the {DockRegistry} + // to pre-compute the address of the new {Container} before deployment. + address expectedContainer = computeDeploymentAddress({ deployer: address(dockRegistry) }); + + // Allowlist the mock modules on the {ModuleKeeper} contract from the admin account + vm.startPrank({ msgSender: users.admin }); + for (uint256 i; i < mockModules.length; ++i) { + allowlistModule(mockModules[i]); + } + vm.stopPrank(); + + // Expect the {ContainerCreated} event to be emitted + vm.expectEmit(); + emit Events.ContainerCreated({ + owner: users.bob, + dockId: 1, + container: Container(payable(expectedContainer)), + initialModules: mockModules + }); + + // Run the test + dockRegistry.createContainer({ owner: users.bob, dockId: 1, initialModules: mockModules }); + + // Assert the expected and actual owner of the dock + address actualOwnerOfDock = dockRegistry.ownerOfDock({ dockId: 1 }); + assertEq(address(this), actualOwnerOfDock); + + // Assert the expected and actual owner of the {Container} + address actualOwnerOfContainer = dockRegistry.ownerOfContainer({ container: expectedContainer }); + assertEq(users.bob, actualOwnerOfContainer); + + // Assert the expected and actual dock ID of the {Container} + uint256 actualDockIdOfContainer = dockRegistry.dockIdOfContainer({ container: expectedContainer }); + assertEq(1, actualDockIdOfContainer); + } +} diff --git a/test/unit/concrete/dock-registry/create-container/createContainer.tree b/test/unit/concrete/dock-registry/create-container/createContainer.tree new file mode 100644 index 00000000..1ac325b3 --- /dev/null +++ b/test/unit/concrete/dock-registry/create-container/createContainer.tree @@ -0,0 +1,10 @@ +createContainer.t.sol +├── when dock ID is zero +│ └── it should create a new dock with the caller address as the owner +└── when dock ID is non-zero + ├── when the caller IS NOT the owner of the dock + │ └── it should revert with the {CallerNotDockOwner} error + └── when the IS the owner of the dock + ├── it should deploy a new {Container} + ├── it should set the dock ID to which the new deployed {Container} belongs + └── it should emit a {ContainerCreated} event diff --git a/test/unit/concrete/dock-registry/transfer-container-ownership/transferContainerOwnership.t.sol b/test/unit/concrete/dock-registry/transfer-container-ownership/transferContainerOwnership.t.sol new file mode 100644 index 00000000..edb2cf3f --- /dev/null +++ b/test/unit/concrete/dock-registry/transfer-container-ownership/transferContainerOwnership.t.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import { DockRegistry_Unit_Concrete_Test } from "../DockRegistry.t.sol"; +import { MockModule } from "../../../../mocks/MockModule.sol"; +import { Container } from "./../../../../../src/Container.sol"; +import { Events } from "../../../../utils/Events.sol"; +import { Errors } from "../../../../utils/Errors.sol"; + +contract TransferContainerOwnership_Unit_Concrete_Test is DockRegistry_Unit_Concrete_Test { + function setUp() public virtual override { + DockRegistry_Unit_Concrete_Test.setUp(); + } + + modifier givenContainerCreated() { + // Create & deploy a new container with Eve as the owner + address[] memory modules = new address[](1); + modules[0] = address(mockModule); + + container = deployContainer({ _owner: users.eve, _dockId: 0, _initialModules: modules }); + _; + } + + function test_RevertWhen_CallerNotOwner() external givenContainerCreated { + // Make Bob the caller for this test suite who is not the owner of the container + vm.startPrank({ msgSender: users.bob }); + + // Expect the next call to revert with the {CallerNotContainerOwner} error + vm.expectRevert(Errors.CallerNotContainerOwner.selector); + + // Run the test + dockRegistry.transferContainerOwnership({ container: address(container), newOwner: users.eve }); + } + + modifier whenCallerOwner() { + // Make Eve the caller for the next test suite as she's the owner of the container + vm.startPrank({ msgSender: users.eve }); + _; + } + + function test_RevertWhen_InvalidOwnerZeroAddress() external givenContainerCreated whenCallerOwner { + // Expect the next call to revert with the {InvalidOwnerZeroAddress} + vm.expectRevert(Errors.InvalidOwnerZeroAddress.selector); + + // Run the test + dockRegistry.transferContainerOwnership({ container: address(container), newOwner: address(0) }); + } + + modifier whenNonZeroOwnerAddress() { + _; + } + + function test_transferContainerOwnership() external givenContainerCreated whenCallerOwner whenNonZeroOwnerAddress { + // Expect the {ContainerOwnershipTransferred} to be emitted + vm.expectEmit(); + emit Events.ContainerOwnershipTransferred({ container: container, oldOwner: users.eve, newOwner: users.bob }); + + // Run the test + dockRegistry.transferContainerOwnership({ container: address(container), newOwner: users.bob }); + + // Assert the actual and expected owner + address actualOwner = dockRegistry.ownerOfContainer(address(container)); + assertEq(actualOwner, users.bob); + } +} diff --git a/test/unit/concrete/container/transfer-ownership/transferOwnership.tree b/test/unit/concrete/dock-registry/transfer-container-ownership/transferContainerOwnership.tree similarity index 65% rename from test/unit/concrete/container/transfer-ownership/transferOwnership.tree rename to test/unit/concrete/dock-registry/transfer-container-ownership/transferContainerOwnership.tree index 836cc7f7..07719f62 100644 --- a/test/unit/concrete/container/transfer-ownership/transferOwnership.tree +++ b/test/unit/concrete/dock-registry/transfer-container-ownership/transferContainerOwnership.tree @@ -1,9 +1,9 @@ -transferOwnership.t.sol +transferContainerOwnership.t.sol ├── when the caller IS NOT the container owner -│ └── it should revert with the {Unauthorized} error +│ └── it should revert with the {CallerNotContainerOwner} error └── when the caller IS the container owner ├── when the new owner address IS the zero address │ └── it should revert with the {InvalidOwnerZeroAddress} error └── when the new owner address IS NOT the zero address ├── it should update the owner - └── it should emit a {OwnershipTransferred} event + └── it should emit a {ContainerOwnershipTransferred} event diff --git a/test/utils/Errors.sol b/test/utils/Errors.sol index 106b7218..049041e4 100644 --- a/test/utils/Errors.sol +++ b/test/utils/Errors.sol @@ -2,12 +2,19 @@ pragma solidity ^0.8.26; library Errors { + /*////////////////////////////////////////////////////////////////////////// + DOCK-REGISTRY + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Thrown when `msg.sender` is not the dock owner + error CallerNotDockOwner(); + /*////////////////////////////////////////////////////////////////////////// CONTAINER //////////////////////////////////////////////////////////////////////////*/ /// @notice Thrown when `msg.sender` is not the {Container} contract owner - error Unauthorized(); + error CallerNotContainerOwner(); /// @notice Thrown when a native token (ETH) withdrawal fails error NativeWithdrawFailed(); @@ -115,4 +122,7 @@ library Errors { /// @notice Thrown when attempting to transfer ownership to the zero address error InvalidOwnerZeroAddress(); + + /// @notice Thrown when `msg.sender` is not the contract owner + error Unauthorized(); } diff --git a/test/utils/Events.sol b/test/utils/Events.sol index b754fc82..f9f05991 100644 --- a/test/utils/Events.sol +++ b/test/utils/Events.sol @@ -2,9 +2,40 @@ pragma solidity ^0.8.26; import { Types } from "./../../src/modules/invoice-module/libraries/Types.sol"; +import { Container } from "./../../src/Container.sol"; +import { ModuleKeeper } from "./../../src/ModuleKeeper.sol"; /// @notice Abstract contract to store all the events emitted in the tested contracts abstract contract Events { + /*////////////////////////////////////////////////////////////////////////// + MODULE-KEEPER + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Emitted when a new {Container} contract gets deployed + /// @param owner The address of the owner + /// @param dockId The ID of the dock to which this {Container} belongs + /// @param container The address of the {Container} + /// @param initialModules Array of initially enabled modules + event ContainerCreated( + address indexed owner, uint256 indexed dockId, Container container, address[] initialModules + ); + + /// @notice Emitted when the ownership of a {Container} is transferred to a new owner + /// @param container The address of the {Container} + /// @param oldOwner The address of the current owner + /// @param newOwner The address of the new owner + event ContainerOwnershipTransferred(Container indexed container, address oldOwner, address newOwner); + + /// @notice Emitted when the ownership of a {Dock} is transferred to a new owner + /// @param dockId The address of the {Dock} + /// @param oldOwner The address of the current owner + /// @param newOwner The address of the new owner + event DockOwnershipTransferred(uint256 indexed dockId, address oldOwner, address newOwner); + + /// @notice Emitted when the {ModuleKeeper} address is updated + /// @param newModuleKeeper The new address of the {ModuleKeeper} + event ModuleKeeperUpdated(ModuleKeeper newModuleKeeper); + /*////////////////////////////////////////////////////////////////////////// CONTAINER //////////////////////////////////////////////////////////////////////////*/ diff --git a/test/utils/Helpers.sol b/test/utils/Helpers.sol index 6f2403f6..b8337f2c 100644 --- a/test/utils/Helpers.sol +++ b/test/utils/Helpers.sol @@ -2,24 +2,24 @@ pragma solidity ^0.8.26; import { Types } from "./../../src/modules/invoice-module/libraries/Types.sol"; +import { Test } from "forge-std/Test.sol"; library Helpers { function createInvoiceDataType(address recipient) public view returns (Types.Invoice memory) { - return - Types.Invoice({ - recipient: recipient, - status: Types.Status.Pending, - startTime: 0, - endTime: uint40(block.timestamp) + 1 weeks, - payment: Types.Payment({ - method: Types.Method.Transfer, - recurrence: Types.Recurrence.OneOff, - paymentsLeft: 1, - asset: address(0), - amount: uint128(1 ether), - streamId: 0 - }) - }); + return Types.Invoice({ + recipient: recipient, + status: Types.Status.Pending, + startTime: 0, + endTime: uint40(block.timestamp) + 1 weeks, + payment: Types.Payment({ + method: Types.Method.Transfer, + recurrence: Types.Recurrence.OneOff, + paymentsLeft: 1, + asset: address(0), + amount: uint128(1 ether), + streamId: 0 + }) + }); } /// @dev Calculates the number of payments that must be done based on a Recurring invoice