diff --git a/.gas-snapshot b/.gas-snapshot index ef05e89..c3c02cd 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,98 +1,95 @@ -AddToAllowlist_Unit_Concrete_Test:test_AddToAllowlist() (gas: 294199) -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: 448598) -CancelInvoice_Integration_Concret_Test:test_CancelInvoice_PaymentMethodLinearStream_StatusPending() (gas: 32437) -CancelInvoice_Integration_Concret_Test:test_CancelInvoice_PaymentMethodTranchedStream_StatusOngoing() (gas: 448577) -CancelInvoice_Integration_Concret_Test:test_CancelInvoice_PaymentMethodTranchedStream_StatusPending() (gas: 32484) -CancelInvoice_Integration_Concret_Test:test_CancelInvoice_PaymentMethodTransfer() (gas: 32470) -CancelInvoice_Integration_Concret_Test:test_RevertWhen_InvoiceIsCanceled() (gas: 29284) -CancelInvoice_Integration_Concret_Test:test_RevertWhen_InvoiceIsPaid() (gas: 55920) -CancelInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodLinearStream_StatusOngoing_SenderNoInitialtStreamSender() (gas: 406709) -CancelInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodLinearStream_StatusPending_SenderNotInvoiceRecipient() (gas: 22770) -CancelInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodTranchedStream_StatusOngoing_SenderNoInitialtStreamSender() (gas: 406752) -CancelInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodTranchedStream_StatusPending_SenderNotInvoiceRecipient() (gas: 22815) -CancelInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodTransfer_SenderNotInvoiceRecipient() (gas: 22803) +AddToAllowlist_Unit_Concrete_Test:test_AddToAllowlist() (gas: 294177) +AddToAllowlist_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 13021) +AddToAllowlist_Unit_Concrete_Test:test_RevertWhen_InvalidZeroCodeModule() (gas: 13101) +CancelRequest_Integration_Concret_Test:test_CancelRequest_PaymentMethodLinearStream_StatusCanceled() (gas: 30130) +CancelRequest_Integration_Concret_Test:test_CancelRequest_PaymentMethodLinearStream_StatusPending() (gas: 456362) +CancelRequest_Integration_Concret_Test:test_CancelRequest_PaymentMethodTranchedStream_StatusCanceled() (gas: 30108) +CancelRequest_Integration_Concret_Test:test_CancelRequest_PaymentMethodTranchedStream_StatusPending() (gas: 455894) +CancelRequest_Integration_Concret_Test:test_CancelRequest_PaymentMethodTransfer() (gas: 30162) +CancelRequest_Integration_Concret_Test:test_RevertWhen_PaymentIsPaid() (gas: 55905) +CancelRequest_Integration_Concret_Test:test_RevertWhen_PaymentMethodLinearStream_StatusPending_SenderNoInitialtStreamSender() (gas: 412484) +CancelRequest_Integration_Concret_Test:test_RevertWhen_PaymentMethodLinearStream_StatusPending_SenderNotPaymentRecipient() (gas: 21892) +CancelRequest_Integration_Concret_Test:test_RevertWhen_PaymentMethodTranchedStream_StatusPending_SenderNoInitialtStreamSender() (gas: 412508) +CancelRequest_Integration_Concret_Test:test_RevertWhen_PaymentMethodTranchedStream_StatusPending_SenderNotPaymentRecipient() (gas: 21849) +CancelRequest_Integration_Concret_Test:test_RevertWhen_PaymentMethodTransfer_SenderNotPaymentRecipient() (gas: 21859) +CancelRequest_Integration_Concret_Test:test_RevertWhen_RequestCanceled() (gas: 29836) ComputeNumberOfPayments_Helpers_Test:test_ComputeNumberOfPayments_Monthly() (gas: 3530) ComputeNumberOfPayments_Helpers_Test:test_ComputeNumberOfPayments_Weekly() (gas: 3483) -ComputeNumberOfPayments_Helpers_Test:test_ComputeNumberOfPayments_Yearly() (gas: 3613) -Constructor_DockRegistry_Test:test_Constructor() (gas: 6152415) +ComputeNumberOfPayments_Helpers_Test:test_ComputeNumberOfPayments_Yearly() (gas: 3591) Constructor_ModuleKeeper_Test:test_Constructor() (gas: 241794) -Constructor_StreamManager_Integration_Concret_Test:test_Constructor() (gas: 1506380) -CreateAccount_Unit_Concrete_Test:test_CreateAccount_DockIdNonZero() (gas: 884365) -CreateAccount_Unit_Concrete_Test:test_CreateAccount_DockIdZero() (gas: 495512) -CreateAccount_Unit_Concrete_Test:test_RevertWhen_CallerNotDockOwner() (gas: 516778) -CreateInvoice_Integration_Concret_Test:test_CreateInvoice_LinearStream() (gas: 257050) -CreateInvoice_Integration_Concret_Test:test_CreateInvoice_PaymentMethodOneOffTransfer() (gas: 257350) -CreateInvoice_Integration_Concret_Test:test_CreateInvoice_RecurringTransfer() (gas: 258474) -CreateInvoice_Integration_Concret_Test:test_CreateInvoice_Tranched() (gas: 258702) -CreateInvoice_Integration_Concret_Test:test_RevertWhen_CallerNotContract() (gas: 87317) -CreateInvoice_Integration_Concret_Test:test_RevertWhen_EndTimeInThePast() (gas: 104070) -CreateInvoice_Integration_Concret_Test:test_RevertWhen_NonCompliantContainer() (gas: 92474) -CreateInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodLinearStream_PaymentAssetNativeToken() (gas: 104125) -CreateInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodRecurringTransfer_PaymentIntervalTooShortForSelectedRecurrence() (gas: 104863) -CreateInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodTranchedStream_PaymentAssetNativeToken() (gas: 105365) -CreateInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodTranchedStream_PaymentIntervalTooShortForSelectedRecurrence() (gas: 104907) -CreateInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodTranchedStream_RecurrenceSetToOneOff() (gas: 103551) -CreateInvoice_Integration_Concret_Test:test_RevertWhen_StartTimeGreaterThanEndTime() (gas: 103394) -CreateInvoice_Integration_Concret_Test:test_RevertWhen_ZeroPaymentAmount() (gas: 82787) -CreateInvoice_Integration_Fuzz_Test:testFuzz_CreateInvoice(uint8,uint8,uint40,uint40,uint128) (runs: 10003, μ: 200780, ~: 257141) -DisableModule_Unit_Concrete_Test:test_DisableModule() (gas: 297575) +Constructor_StationRegistry_Test:test_Constructor() (gas: 6000889) +Constructor_StreamManager_Integration_Concret_Test:test_Constructor() (gas: 1483582) +CreateAccount_Unit_Concrete_Test:test_CreateAccount_StationIdNonZero() (gas: 838312) +CreateAccount_Unit_Concrete_Test:test_CreateAccount_StationIdZero() (gas: 471586) +CreateAccount_Unit_Concrete_Test:test_RevertWhen_CallerNotStationOwner() (gas: 494552) +CreateRequest_Integration_Concret_Test:test_CreateRequest_LinearStream() (gas: 213120) +CreateRequest_Integration_Concret_Test:test_CreateRequest_PaymentMethodOneOffTransfer() (gas: 213582) +CreateRequest_Integration_Concret_Test:test_CreateRequest_RecurringTransfer() (gas: 214358) +CreateRequest_Integration_Concret_Test:test_CreateRequest_Tranched() (gas: 214732) +CreateRequest_Integration_Concret_Test:test_RevertWhen_CallerNotContract() (gas: 87433) +CreateRequest_Integration_Concret_Test:test_RevertWhen_EndTimeInThePast() (gas: 104518) +CreateRequest_Integration_Concret_Test:test_RevertWhen_NonCompliantSpace() (gas: 94507) +CreateRequest_Integration_Concret_Test:test_RevertWhen_PaymentMethodLinearStream_PaymentAssetNativeToken() (gas: 104640) +CreateRequest_Integration_Concret_Test:test_RevertWhen_PaymentMethodRecurringTransfer_PaymentIntervalTooShortForSelectedRecurrence() (gas: 105215) +CreateRequest_Integration_Concret_Test:test_RevertWhen_PaymentMethodTranchedStream_PaymentAssetNativeToken() (gas: 105883) +CreateRequest_Integration_Concret_Test:test_RevertWhen_PaymentMethodTranchedStream_PaymentIntervalTooShortForSelectedRecurrence() (gas: 105401) +CreateRequest_Integration_Concret_Test:test_RevertWhen_PaymentMethodTranchedStream_RecurrenceSetToOneOff() (gas: 104096) +CreateRequest_Integration_Concret_Test:test_RevertWhen_StartTimeGreaterThanEndTime() (gas: 103921) +CreateRequest_Integration_Concret_Test:test_RevertWhen_ZeroPaymentAmount() (gas: 83212) +CreateRequest_Integration_Fuzz_Test:testFuzz_CreateRequest(uint8,uint8,address,uint40,uint40,uint128) (runs: 10002, μ: 167414, ~: 213538) +DisableModule_Unit_Concrete_Test:test_DisableModule() (gas: 297619) DisableModule_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 18242) -EnableModule_Unit_Concrete_Test:test_EnableModule() (gas: 38285) -EnableModule_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 18232) -EnableModule_Unit_Concrete_Test:test_RevertWhen_ModuleNotAllowlisted() (gas: 28781) +EnableModule_Unit_Concrete_Test:test_EnableModule() (gas: 38307) +EnableModule_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 18210) +EnableModule_Unit_Concrete_Test:test_RevertWhen_ModuleNotAllowlisted() (gas: 28825) ExecuteBatch_Unit_Concrete_Test:test_ExecuteBatch() (gas: 310024) ExecuteBatch_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 32567) ExecuteBatch_Unit_Concrete_Test:test_RevertWhen_ModuleNotEnabled() (gas: 38738) -ExecuteBatch_Unit_Concrete_Test:test_RevertWhen_WrongArrayLengths() (gas: 57866) +ExecuteBatch_Unit_Concrete_Test:test_RevertWhen_WrongArrayLengths() (gas: 57822) Execute_Unit_Concrete_Test:test_Execute() (gas: 83058) -Execute_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 20690) -Execute_Unit_Concrete_Test:test_RevertWhen_ModuleNotEnabled() (gas: 21172) +Execute_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 20659) +Execute_Unit_Concrete_Test:test_RevertWhen_ModuleNotEnabled() (gas: 21166) Fallback_Unit_Concrete_Test:test_Fallback() (gas: 23595) -PayInvoice_Integration_Concret_Test:test_PayInvoice_PaymentMethodLinearStream() (gas: 311650) -PayInvoice_Integration_Concret_Test:test_PayInvoice_PaymentMethodTranchedStream() (gas: 438747) -PayInvoice_Integration_Concret_Test:test_PayInvoice_PaymentMethodTransfer_ERC20Token_Recurring() (gas: 106027) -PayInvoice_Integration_Concret_Test:test_PayInvoice_PaymentMethodTransfer_NativeToken_OneOff() (gas: 69528) -PayInvoice_Integration_Concret_Test:test_RevertWhen_InvoiceAlreadyPaid() (gas: 81929) -PayInvoice_Integration_Concret_Test:test_RevertWhen_InvoiceCanceled() (gas: 32071) -PayInvoice_Integration_Concret_Test:test_RevertWhen_InvoiceNull() (gas: 20514) -PayInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodTransfer_NativeTokenTransferFails() (gas: 185912) -PayInvoice_Integration_Concret_Test:test_RevertWhen_PaymentMethodTransfer_PaymentAmountLessThanInvoiceValue() (gas: 34136) -PayInvoice_Integration_Fuzz_Test:testFuzz_PayInvoice(uint8,uint8,uint40,uint40,uint128) (runs: 10003, μ: 365189, ~: 342220) +MintInvoice_Integration_Concret_Test:test_MintInvoice() (gas: 127976) +MintInvoice_Integration_Concret_Test:test_RevertWhen_CallerNotRelayer() (gas: 14112) +PayPayment_Integration_Concret_Test:test_PayRequest_PaymentMethodLinearStream() (gas: 317023) +PayPayment_Integration_Concret_Test:test_PayRequest_PaymentMethodTranchedStream() (gas: 445445) +PayPayment_Integration_Concret_Test:test_PayRequest_PaymentMethodTransfer_ERC20Token_Recurring() (gas: 106179) +PayPayment_Integration_Concret_Test:test_PayRequest_PaymentMethodTransfer_NativeToken_OneOff() (gas: 70456) +PayPayment_Integration_Concret_Test:test_RevertWhen_PaymentMethodTransfer_NativeTokenTransferFails() (gas: 146017) +PayPayment_Integration_Concret_Test:test_RevertWhen_PaymentMethodTransfer_PaymentAmountLessThanRequestedAmount() (gas: 31822) +PayPayment_Integration_Concret_Test:test_RevertWhen_RequestAlreadyPaid() (gas: 81025) +PayPayment_Integration_Concret_Test:test_RevertWhen_RequestCanceled() (gas: 32409) +PayPayment_Integration_Concret_Test:test_RevertWhen_RequestNull() (gas: 17722) +PayRequest_Integration_Fuzz_Test:testFuzz_PayRequest(uint8,uint8,uint40,uint40,uint128) (runs: 10001, μ: 348425, ~: 320864) Receive_Unit_Concrete_Test:test_Receive() (gas: 23390) 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: 509061) -TransferContainerOwnership_Unit_Concrete_Test:test_RevertWhen_InvalidOwnerZeroAddress() (gas: 507020) -TransferContainerOwnership_Unit_Concrete_Test:test_TransferContainerOwnership() (gas: 515004) -TransferDockOwnership_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 508825) -TransferDockOwnership_Unit_Concrete_Test:test_TransferDockOwnership() (gas: 514407) -TransferFrom_Integration_Concret_Test:test_RevertWhen_TokenDoesNotExist() (gas: 30340) -TransferFrom_Integration_Concret_Test:test_TransferFrom_PaymentMethodStream() (gas: 393146) -TransferFrom_Integration_Concret_Test:test_TransferFrom_PaymentTransfer() (gas: 61592) +TransferFrom_Integration_Concret_Test:test_TransferFrom() (gas: 15639) TransferOwnership_Unit_Concrete_Test:test_RevertWhen_CallerNotCurrentOwner() (gas: 15031) -TransferOwnership_Unit_Concrete_Test:test_RevertWhen_NewOwnerZeroAddress() (gas: 12972) +TransferOwnership_Unit_Concrete_Test:test_RevertWhen_NewOwnerZeroAddress() (gas: 12950) TransferOwnership_Unit_Concrete_Test:test_TransferOwnership() (gas: 22589) -TransferOwnership_Unit_Fuzz_Test:testFuzz_RevertWhen_CallerNotCurrentOwner(address) (runs: 10003, μ: 13560, ~: 13560) -TransferOwnership_Unit_Fuzz_Test:testFuzz_TransferOwnership(address) (runs: 10003, μ: 20802, ~: 20803) +TransferOwnership_Unit_Fuzz_Test:testFuzz_RevertWhen_CallerNotCurrentOwner(address) (runs: 10002, μ: 13560, ~: 13560) +TransferOwnership_Unit_Fuzz_Test:testFuzz_TransferOwnership(address) (runs: 10002, μ: 20803, ~: 20803) +TransferStationOwnership_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 486702) +TransferStationOwnership_Unit_Concrete_Test:test_TransferStationOwnership() (gas: 492259) UpdateModuleKeeper_Unit_Concrete_Test:test_RevertWhen_CallerNotRegistryOwner() (gas: 13994) -UpdateModuleKeeper_Unit_Concrete_Test:test_UpdateModuleKeeper() (gas: 21767) +UpdateModuleKeeper_Unit_Concrete_Test:test_UpdateModuleKeeper() (gas: 21811) UpdateStreamBrokerFee_Integration_Concret_Test:test_RevertWhen_CallerNotOwner() (gas: 12865) -UpdateStreamBrokerFee_Integration_Concret_Test:test_UpdateStreamBrokerFee() (gas: 38892) +UpdateStreamBrokerFee_Integration_Concret_Test:test_UpdateStreamBrokerFee() (gas: 38936) WithdrawERC1155_Unit_Concrete_Test:test_RevertWhen_CallerNotAdminOrEntryPoint() (gas: 32720) WithdrawERC1155_Unit_Concrete_Test:test_RevertWhen_InsufficientERC1155Balance() (gas: 44674) WithdrawERC1155_Unit_Concrete_Test:test_WithdrawERC1155() (gas: 117528) WithdrawERC1155_Unit_Concrete_Test:test_WithdrawERC1155_Batch() (gas: 136425) -WithdrawERC20_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 18242) -WithdrawERC20_Unit_Concrete_Test:test_RevertWhen_InsufficientERC20ToWithdraw() (gas: 25726) -WithdrawERC20_Unit_Concrete_Test:test_WithdrawERC20() (gas: 92210) -WithdrawERC721_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 18247) -WithdrawERC721_Unit_Concrete_Test:test_RevertWhen_NonexistentERC721Token() (gas: 31261) -WithdrawERC721_Unit_Concrete_Test:test_WithdrawERC721() (gas: 96612) -WithdrawLinearStream_Integration_Concret_Test:test_WithdrawStream_LinearStream() (gas: 319541) -WithdrawLinearStream_Integration_Concret_Test:test_WithdrawStream_TranchedStream() (gas: 443734) +WithdrawERC20_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 18302) +WithdrawERC20_Unit_Concrete_Test:test_RevertWhen_InsufficientERC20ToWithdraw() (gas: 25695) +WithdrawERC20_Unit_Concrete_Test:test_WithdrawERC20() (gas: 91444) +WithdrawERC721_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 18307) +WithdrawERC721_Unit_Concrete_Test:test_RevertWhen_NonexistentERC721Token() (gas: 31230) +WithdrawERC721_Unit_Concrete_Test:test_WithdrawERC721() (gas: 96680) WithdrawNative_Unit_Concrete_Test:test_RevertWhen_CallerNotOwner() (gas: 18144) WithdrawNative_Unit_Concrete_Test:test_RevertWhen_InsufficientNativeToWithdraw() (gas: 17974) WithdrawNative_Unit_Concrete_Test:test_RevertWhen_NativeWithdrawFailed() (gas: 33626) -WithdrawNative_Unit_Concrete_Test:test_WithdrawNative() (gas: 39195) \ No newline at end of file +WithdrawNative_Unit_Concrete_Test:test_WithdrawNative() (gas: 39195) +WithdrawRequestStream_Integration_Concret_Test:test_WithdrawStream_LinearStream() (gas: 315583) +WithdrawRequestStream_Integration_Concret_Test:test_WithdrawStream_TranchedStream() (gas: 438362) \ No newline at end of file diff --git a/.solhint.json b/.solhint.json new file mode 100644 index 0000000..3b098b5 --- /dev/null +++ b/.solhint.json @@ -0,0 +1,19 @@ +{ + "extends": "solhint:recommended", + "rules": { + "avoid-low-level-calls": "off", + "code-complexity": ["error", 9], + "compiler-version": ["error", ">=0.8.22"], + "contract-name-camelcase": "off", + "const-name-snakecase": "off", + "func-name-mixedcase": "off", + "func-visibility": ["error", { "ignoreConstructors": true }], + "gas-custom-errors": "off", + "max-line-length": ["error", 124], + "named-parameters-mapping": "warn", + "no-empty-blocks": "off", + "not-rely-on-time": "off", + "one-contract-per-file": "off", + "var-name-mixedcase": "off" + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json index b538903..ce12ac2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,4 @@ { - "solidity.packageDefaultDependenciesContractsDirectory": "src", - "solidity.packageDefaultDependenciesDirectory": "lib", "editor.formatOnSave": true, "[solidity]": { "editor.defaultFormatter": "NomicFoundation.hardhat-solidity" diff --git a/Makefile b/Makefile index dd7c8ae..05d321c 100644 --- a/Makefile +++ b/Makefile @@ -13,48 +13,20 @@ clean :; forge clean # See https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/solidity/coverage.sh tests-coverage :; ./script/coverage.sh -# Deploy the {InvoiceModule} contract deterministically -# See Sablier V2 deployments: https://docs.sablier.com/contracts/v2/deployments +# Deploys the {InvoiceCollection} peripheral # # Update the following configs before running the script: -# - {SABLIER_LOCKUP_LINEAR} with the according {SablierV2LockupLinear} deployment address -# - {SABLIER_LOCKUP_TRANCHED} with the according {SablierV2LockupTranched} deployment address -# - {BROKER_ADMIN} with the address of the account managing the Sablier V2 integration fee +# - {RELAYER} with the address of the Relayer responsible to mint the invoice NFTs +# - {NAME} with the name of the ERC-721 {InvoiceCollection} contract +# - {SYMBOL} with symbol of the ERC-721 {InvoiceCollection} contract # - {RPC_URL} with the network RPC used for deployment -deploy-deterministic-invoice-module: - forge script script/DeployDeterministicInvoiceModule.s.sol:DeployDeterministicInvoiceModule \ - $(CREATE2SALT) {SABLIER_LOCKUP_LINEAR} {SABLIER_LOCKUP_TRANCHED} {BROKER_ADMIN} \ - --sig "run(string,address,address,address)" --rpc-url {RPC_URL} --private-key $(PRIVATE_KEY) --etherscan-api-key $(ETHERSCAN_API_KEY) +deploy-invoice-collection: + forge script script/DeployInvoiceCollection.s.sol:DeployInvoiceCollection \ + $(CREATE2SALT) {RELAYER} {NAME} {SYMBOL} \ + --sig "run(address,string,string)" --rpc-url {RPC_URL} --private-key $(PRIVATE_KEY) --etherscan-api-key $(ETHERSCAN_API_KEY) --broadcast --verify - -# Deploy a {Container} contract deterministically -# Update the following configs before running the script: -# - {INITIAL_OWNER} with the address of the initial owner -# - {MODULE_KEEPER_ADDRESS} with the address of the {ModuleKeeper} deployment -# - {RPC_URL} with the network RPC used for deployment -deploy-deterministic-container: - forge script script/DeployDeterministicContainer.s.sol:DeployDeterministicContainer \ - $(CREATE2SALT) {INITIAL_OWNER} {MODULE_KEEPER_ADDRESS} [] \ - --sig "run(string,address,address,address[])" --rpc-url {RPC_URL} \ - --private-key $(PRIVATE_KEY) --etherscan-api-key $(ETHERSCAN_API_KEY) \ - --broadcast --verify - -# Deploy a {Container} contract -# Update the following configs before running the script: -# - {INITIAL_OWNER} with the address of the initial owner -# - {DOCK_REGISTRY} with the address of the {DockRegistr} factory -# - {DOCK_ID} with the ID of the dock to which the new {Container} will be deployed -# - {INITIAL_MODULES} with the addresses of the enabled initial modules (array) -# - {RPC_URL} with the network RPC used for deployment -deploy-container: - forge script script/DeployContainer.s.sol:DeployContainer \ - {INITIAL_OWNER} {DOCK_REGISTRY} {DOCK_ID} {INITIAL_MODULES} \ - --sig "run(address,address,uint256,address[])" --rpc-url {RPC_URL} \ - --private-key $(PRIVATE_KEY) --etherscan-api-key $(ETHERSCAN_API_KEY) \ - --broadcast --verify - -# Deploy the {ModuleKeeper} contract deterministically +# Deploys the {ModuleKeeper} contract deterministically # Update the following configs before running the script: # - {INITIAL_OWNER} with the address of the initial owner # - {RPC_URL} with the network RPC used for deployment @@ -65,14 +37,14 @@ deploy-deterministic-module-keeper: --private-key $(PRIVATE_KEY) --etherscan-api-key $(ETHERSCAN_API_KEY) \ --broadcast --verify -# Deploy the {DockRegistry} contract deterministically +# Deploys the {StationRegistry} 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 # - {ENTRYPOINT} with the address of the {Entrypoiny} contract (currently v6) +# - {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 \ + forge script script/DeployDeterministicStationRegistry.s.sol:DeployDeterministicStationRegistry \ $(CREATE2SALT) {INITIAL_OWNER} {ENTRYPOINT} {MODULE_KEEPER} \ --sig "run(string,address,address)" --rpc-url {RPC_URL} \ --private-key $(PRIVATE_KEY) --etherscan-api-key $(ETHERSCAN_API_KEY) \ diff --git a/bun.lockb b/bun.lockb index 5446f41..2a82ff7 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/foundry.toml b/foundry.toml index b6d7abe..9c740d2 100644 --- a/foundry.toml +++ b/foundry.toml @@ -18,7 +18,7 @@ runs = 10_000 bracket_spacing = true int_types = "long" line_length = 120 -multiline_func_header = "params_first" +multiline_func_header = "all" number_underscore = "thousands" quote_style = "double" tab_width = 4 diff --git a/package.json b/package.json index b8c9aca..c534718 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,16 @@ { + "scripts": { + "build": "forge build", + "lint": "bun run lint:sol && bun run prettier:check", + "lint:sol": "forge fmt --check && bun solhint \"{precompiles,script,src,test}/**/*.sol\"", + "prettier:check": "prettier --check --plugin=prettier-plugin-solidity \"**/*.{json,md,svg,yml}\"", + "prettier:write": "prettier --write --plugin=prettier-plugin-solidity \"**/*.{json,md,svg,yml,sol}\"" + }, "devDependencies": { - "forge-std": "github:foundry-rs/forge-std#v1.9.4" + "forge-std": "github:foundry-rs/forge-std#v1.9.4", + "prettier": "^3.3.3", + "prettier-plugin-solidity": "^1.4.1", + "solhint": "^5.0.3" }, "dependencies": { "@openzeppelin/contracts": "^5.1.0", diff --git a/script/Base.s.sol b/script/Base.s.sol index 8c941cc..7c6a7e8 100644 --- a/script/Base.s.sol +++ b/script/Base.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.20; +pragma solidity ^0.8.22; import { Script } from "forge-std/Script.sol"; @@ -19,7 +19,7 @@ contract BaseScript is Script { deployer = from; } else { mnemonic = vm.envOr({ name: "MNEMONIC", defaultValue: TEST_MNEMONIC }); - (deployer, ) = deriveRememberKey(mnemonic, 0); + (deployer,) = deriveRememberKey(mnemonic, 0); } } diff --git a/script/DeployDeterministicInvoiceModule.s.sol b/script/DeployDeterministicInvoiceModule.s.sol deleted file mode 100644 index a8d3081..0000000 --- a/script/DeployDeterministicInvoiceModule.s.sol +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.26; - -import { BaseScript } from "./Base.s.sol"; -import { InvoiceModule } from "../src/modules/invoice-module/InvoiceModule.sol"; -import { ISablierV2LockupLinear } from "@sablier/v2-core/src/interfaces/ISablierV2LockupLinear.sol"; -import { ISablierV2LockupTranched } from "@sablier/v2-core/src/interfaces/ISablierV2LockupTranched.sol"; - -/// @notice Deploys and initializes the {InvoiceModule} contracts at deterministic addresses across chains -/// @dev Reverts if any contract has already been deployed -contract DeployDeterministicInvoiceModule 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, - ISablierV2LockupLinear sablierLockupLinear, - ISablierV2LockupTranched sablierLockupTranched, - address brokerAdmin, - string memory baseURI - ) public virtual broadcast returns (InvoiceModule invoiceModule) { - bytes32 salt = bytes32(abi.encodePacked(create2Salt)); - - // Deterministically deploy the {InvoiceModule} contracts - invoiceModule = - new InvoiceModule{ salt: salt }(sablierLockupLinear, sablierLockupTranched, brokerAdmin, baseURI); - } -} diff --git a/script/DeployDeterministicModuleKeeper.s.sol b/script/DeployDeterministicModuleKeeper.s.sol index 55a3e1c..bc176e9 100644 --- a/script/DeployDeterministicModuleKeeper.s.sol +++ b/script/DeployDeterministicModuleKeeper.s.sol @@ -12,7 +12,12 @@ contract DeployDeterministicModuleKeeper is BaseScript { function run( string memory create2Salt, address initialOwner - ) public virtual broadcast returns (ModuleKeeper moduleKeeper) { + ) + public + virtual + broadcast + returns (ModuleKeeper moduleKeeper) + { bytes32 salt = bytes32(abi.encodePacked(create2Salt)); // Deterministically deploy the {ModuleKeeper} contract diff --git a/script/DeployDeterministicStationRegistry.s.sol b/script/DeployDeterministicStationRegistry.s.sol index 07f1b59..ad23539 100644 --- a/script/DeployDeterministicStationRegistry.s.sol +++ b/script/DeployDeterministicStationRegistry.s.sol @@ -16,7 +16,12 @@ contract DeployDeterministicStationRegistry is BaseScript { address initialAdmin, IEntryPoint entrypoint, ModuleKeeper moduleKeeper - ) public virtual broadcast returns (StationRegistry stationRegistry) { + ) + public + virtual + broadcast + returns (StationRegistry stationRegistry) + { bytes32 salt = bytes32(abi.encodePacked(create2Salt)); // Deterministically deploy the {StationRegistry} smart account factory diff --git a/script/DeployInvoiceCollection.s.sol b/script/DeployInvoiceCollection.s.sol new file mode 100644 index 0000000..b001fdb --- /dev/null +++ b/script/DeployInvoiceCollection.s.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.26; + +import { BaseScript } from "./Base.s.sol"; +import { InvoiceCollection } from "../src/peripherals/invoice-collection/InvoiceCollection.sol"; + +/// @notice Deploys and initializes the {InvoiceCollection} contract at deterministic addresses across chains +/// @dev Reverts if any contract has already been deployed +contract DeployInvoiceCollection 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( + address relayer, + string memory name, + string memory symbol + ) + public + virtual + broadcast + returns (InvoiceCollection invoiceCollection) + { + // Deploy the {InvoiceCollection} contract + invoiceCollection = new InvoiceCollection(relayer, name, symbol); + } +} diff --git a/script/DeploySpace.sol b/script/DeploySpace.sol index 80a3cc8..ac33114 100644 --- a/script/DeploySpace.sol +++ b/script/DeploySpace.sol @@ -12,7 +12,12 @@ contract DeploySpace is BaseScript { StationRegistry stationRegistry, uint256 stationId, address[] memory initialModules - ) public virtual broadcast returns (Space space) { + ) + public + virtual + broadcast + returns (Space space) + { // Get the number of total accounts created by the `initialAdmin` deployer uint256 totalAccountsOfAdmin = stationRegistry.totalAccountsOfSigner(initialAdmin); diff --git a/src/ModuleKeeper.sol b/src/ModuleKeeper.sol index a794e6d..fda2c70 100644 --- a/src/ModuleKeeper.sol +++ b/src/ModuleKeeper.sol @@ -20,7 +20,7 @@ contract ModuleKeeper is IModuleKeeper, Ownable { //////////////////////////////////////////////////////////////////////////*/ /// @dev Initializes the initial owner of the {ModuleKeeper} - constructor(address _initialOwner) Ownable(_initialOwner) { } + constructor(address _initialOwner) Ownable(_initialOwner) {} /*////////////////////////////////////////////////////////////////////////// NON-CONSTANT FUNCTIONS diff --git a/src/Space.sol b/src/Space.sol index a97833e..16aaa56 100644 --- a/src/Space.sol +++ b/src/Space.sol @@ -37,11 +37,11 @@ contract Space is ISpace, AccountCore, ERC1271, ModuleManager { //////////////////////////////////////////////////////////////////////////*/ /// @dev Initializes the address of the EIP 4337 factory and EntryPoint contract - constructor(IEntryPoint _entrypoint, address _factory) AccountCore(_entrypoint, _factory) { } + constructor(IEntryPoint _entrypoint, address _factory) AccountCore(_entrypoint, _factory) {} /// @notice Initializes the {ModuleKeeper}, enables initial modules and configures the {Space} smart account function initialize(address _defaultAdmin, bytes calldata _data) public override { - (,, address[] memory initialModules) = abi.decode(_data, (uint256, uint256, address[])); + (, , address[] memory initialModules) = abi.decode(_data, (uint256, uint256, address[])); // Enable the initial module(s) ModuleKeeper moduleKeeper = StationRegistry(factory).moduleKeeper(); @@ -153,9 +153,21 @@ contract Space is ISpace, AccountCore, ERC1271, ModuleManager { // therefore the `onERC1155Received` hook must be implemented // - depending on the length of the `ids` array, we're using `safeBatchTransferFrom` or `safeTransferFrom` if (ids.length > 1) { - collection.safeBatchTransferFrom({ from: address(this), to: msg.sender, ids: ids, values: amounts, data: "" }); + collection.safeBatchTransferFrom({ + from: address(this), + to: msg.sender, + ids: ids, + values: amounts, + data: "" + }); } else { - collection.safeTransferFrom({ from: address(this), to: msg.sender, id: ids[0], value: amounts[0], data: "" }); + collection.safeTransferFrom({ + from: address(this), + to: msg.sender, + id: ids[0], + value: amounts[0], + data: "" + }); } // Log the successful ERC-1155 token withdrawal @@ -163,14 +175,12 @@ contract Space is ISpace, AccountCore, ERC1271, ModuleManager { } /// @inheritdoc ISpace - function withdrawNative( - uint256 amount - ) public onlyAdminOrEntrypoint { + function withdrawNative(uint256 amount) public onlyAdminOrEntrypoint { // Checks: the native balance of the space minus the amount locked for operations is greater than the requested amount if (amount > address(this).balance) revert Errors.InsufficientNativeToWithdraw(); // Interactions: withdraw by transferring the amount to the sender - (bool success,) = msg.sender.call{ value: amount }(""); + (bool success, ) = msg.sender.call{ value: amount }(""); // Revert if the call failed if (!success) revert Errors.NativeWithdrawFailed(); @@ -179,9 +189,7 @@ contract Space is ISpace, AccountCore, ERC1271, ModuleManager { } /// @inheritdoc IModuleManager - function enableModule( - address module - ) public override onlyAdminOrEntrypoint { + function enableModule(address module) public override onlyAdminOrEntrypoint { // Retrieve the address of the {ModuleKeeper} ModuleKeeper moduleKeeper = StationRegistry(factory).moduleKeeper(); @@ -190,9 +198,7 @@ contract Space is ISpace, AccountCore, ERC1271, ModuleManager { } /// @inheritdoc IModuleManager - function disableModule( - address module - ) public override onlyAdminOrEntrypoint { + function disableModule(address module) public override onlyAdminOrEntrypoint { // Effects: disable the module _disableModule(module); } @@ -202,10 +208,7 @@ contract Space is ISpace, AccountCore, ERC1271, ModuleManager { //////////////////////////////////////////////////////////////////////////*/ /// @inheritdoc ERC1271 - function isValidSignature( - bytes32 _hash, - bytes memory _signature - ) public view override returns (bytes4 magicValue) { + function isValidSignature(bytes32 _hash, bytes memory _signature) public view override returns (bytes4 magicValue) { // Compute the hash of message the should be signed bytes32 targetDigest = getMessageHash(_hash); @@ -230,20 +233,19 @@ contract Space is ISpace, AccountCore, ERC1271, ModuleManager { } /// @inheritdoc ISpace - function getMessageHash( - bytes32 _hash - ) public view returns (bytes32) { + function getMessageHash(bytes32 _hash) public view returns (bytes32) { bytes32 messageHash = keccak256(abi.encode(_hash)); bytes32 typedDataHash = keccak256(abi.encode(MSG_TYPEHASH, messageHash)); return keccak256(abi.encodePacked("\x19\x01", _domainSeparatorV4(), typedDataHash)); } /// @inheritdoc IERC165 - function supportsInterface( - bytes4 interfaceId - ) public pure returns (bool) { - return interfaceId == type(ISpace).interfaceId || interfaceId == type(IERC1155Receiver).interfaceId - || interfaceId == type(IERC721Receiver).interfaceId || interfaceId == type(IERC165).interfaceId; + function supportsInterface(bytes4 interfaceId) public pure returns (bool) { + return + interfaceId == type(ISpace).interfaceId || + interfaceId == type(IERC1155Receiver).interfaceId || + interfaceId == type(IERC721Receiver).interfaceId || + interfaceId == type(IERC165).interfaceId; } /// @inheritdoc IERC721Receiver diff --git a/src/StationRegistry.sol b/src/StationRegistry.sol index c19b638..cbb0c30 100644 --- a/src/StationRegistry.sol +++ b/src/StationRegistry.sol @@ -111,9 +111,7 @@ contract StationRegistry is IStationRegistry, BaseAccountFactory, PermissionsEnu } /// @inheritdoc IStationRegistry - function updateModuleKeeper( - ModuleKeeper newModuleKeeper - ) external onlyRole(DEFAULT_ADMIN_ROLE) { + function updateModuleKeeper(ModuleKeeper newModuleKeeper) external onlyRole(DEFAULT_ADMIN_ROLE) { // Effects: update the {ModuleKeeper} address moduleKeeper = newModuleKeeper; @@ -126,9 +124,7 @@ contract StationRegistry is IStationRegistry, BaseAccountFactory, PermissionsEnu //////////////////////////////////////////////////////////////////////////*/ /// @inheritdoc IStationRegistry - function totalAccountsOfSigner( - address signer - ) public view returns (uint256) { + function totalAccountsOfSigner(address signer) public view returns (uint256) { return accountsOfSigner[signer].length(); } diff --git a/src/modules/invoice-module/InvoiceModule.sol b/src/modules/invoice-module/InvoiceModule.sol deleted file mode 100644 index 174db91..0000000 --- a/src/modules/invoice-module/InvoiceModule.sol +++ /dev/null @@ -1,410 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.26; - -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; -import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; -import { ISablierV2LockupLinear } from "@sablier/v2-core/src/interfaces/ISablierV2LockupLinear.sol"; -import { ISablierV2LockupTranched } from "@sablier/v2-core/src/interfaces/ISablierV2LockupTranched.sol"; - -import { Types } from "./libraries/Types.sol"; -import { Errors } from "./libraries/Errors.sol"; -import { IInvoiceModule } from "./interfaces/IInvoiceModule.sol"; -import { ISpace } from "./../../interfaces/ISpace.sol"; -import { StreamManager } from "./sablier-v2/StreamManager.sol"; -import { Helpers } from "./libraries/Helpers.sol"; - -/// @title InvoiceModule -/// @notice See the documentation in {IInvoiceModule} -contract InvoiceModule is IInvoiceModule, StreamManager, ERC721 { - using SafeERC20 for IERC20; - using Strings for uint256; - - /*////////////////////////////////////////////////////////////////////////// - PRIVATE STORAGE - //////////////////////////////////////////////////////////////////////////*/ - - /// @dev Invoice details mapped by the `id` invoice ID - mapping(uint256 id => Types.Invoice) private _invoices; - - /// @dev Counter to keep track of the next ID used to create a new invoice - uint256 private _nextInvoiceId; - - /// @dev Base URI used to get the ERC-721 `tokenURI` metadata JSON schema - string private _collectionURI; - - /*////////////////////////////////////////////////////////////////////////// - CONSTRUCTOR - //////////////////////////////////////////////////////////////////////////*/ - - /// @dev Initializes the {StreamManager} contract and first invoice ID - constructor( - ISablierV2LockupLinear _sablierLockupLinear, - ISablierV2LockupTranched _sablierLockupTranched, - address _brokerAdmin, - string memory _URI - ) - StreamManager(_sablierLockupLinear, _sablierLockupTranched, _brokerAdmin) - ERC721("Metastation Invoice NFT", "MD-INVOICES") - { - // Start the invoice IDs from 1 - _nextInvoiceId = 1; - - // Set the ERC721 baseURI - _collectionURI = _URI; - } - - /*////////////////////////////////////////////////////////////////////////// - MODIFIERS - //////////////////////////////////////////////////////////////////////////*/ - - /// @dev Allow only calls from contracts implementing the {ISpace} interface - modifier onlySpace() { - // Checks: the sender is a valid non-zero code size contract - if (msg.sender.code.length == 0) { - revert Errors.SpaceZeroCodeSize(); - } - - // Checks: the sender implements the ERC-165 interface required by {ISpace} - bytes4 interfaceId = type(ISpace).interfaceId; - if (!ISpace(msg.sender).supportsInterface(interfaceId)) revert Errors.SpaceUnsupportedInterface(); - _; - } - - /*////////////////////////////////////////////////////////////////////////// - CONSTANT FUNCTIONS - //////////////////////////////////////////////////////////////////////////*/ - - /// @inheritdoc IInvoiceModule - function getInvoice(uint256 id) external view returns (Types.Invoice memory invoice) { - return _invoices[id]; - } - - /*////////////////////////////////////////////////////////////////////////// - NON-CONSTANT FUNCTIONS - //////////////////////////////////////////////////////////////////////////*/ - - /// @inheritdoc IInvoiceModule - function createInvoice(Types.Invoice calldata invoice) external onlySpace returns (uint256 invoiceId) { - // Checks: the amount is non-zero - if (invoice.payment.amount == 0) { - revert Errors.ZeroPaymentAmount(); - } - - // Checks: the start time is stricly lower than the end time - if (invoice.startTime > invoice.endTime) { - revert Errors.StartTimeGreaterThanEndTime(); - } - - // Checks: end time is not in the past - uint40 currentTime = uint40(block.timestamp); - if (currentTime >= invoice.endTime) { - revert Errors.EndTimeInThePast(); - } - - // Checks: the recurrence type is not equal to one-off if dealing with a tranched stream-based invoice - if (invoice.payment.method == Types.Method.TranchedStream) { - // The recurrence cannot be set to one-off - if (invoice.payment.recurrence == Types.Recurrence.OneOff) { - revert Errors.TranchedStreamInvalidOneOffRecurence(); - } - } - - // Validates the invoice interval (endTime - startTime) and returns the number of payments of the invoice - // based on the payment method, interval and recurrence type - // - // Notes: - // - The number of payments is taken into account only for transfer-based invoices - // - There should be only one payment when dealing with a one-off transfer-based invoice - // - When dealing with a recurring transfer, the number of payments must be calculated based - // on the payment interval (endTime - startTime) and recurrence type - uint40 numberOfPayments; - if (invoice.payment.method == Types.Method.Transfer && invoice.payment.recurrence == Types.Recurrence.OneOff) { - numberOfPayments = 1; - } else if (invoice.payment.method != Types.Method.LinearStream) { - numberOfPayments = _checkIntervalPayments({ - recurrence: invoice.payment.recurrence, - startTime: invoice.startTime, - endTime: invoice.endTime - }); - - // Set the number of payments to zero if dealing with a tranched-based invoice - // The `_checkIntervalPayment` method is still called for a tranched-based invoice just - // to validate the interval and ensure it can support multiple payments based on the chosen recurrence - if (invoice.payment.method == Types.Method.TranchedStream) numberOfPayments = 0; - } - - // Checks: the asset is different than the native token if dealing with either a linear or tranched stream-based invoice - if (invoice.payment.method != Types.Method.Transfer) { - if (invoice.payment.asset == address(0)) { - revert Errors.OnlyERC20StreamsAllowed(); - } - } - - // Get the next invoice ID - invoiceId = _nextInvoiceId; - - // Effects: create the invoice - _invoices[invoiceId] = Types.Invoice({ - status: Types.Status.Pending, - startTime: invoice.startTime, - endTime: invoice.endTime, - payment: Types.Payment({ - recurrence: invoice.payment.recurrence, - method: invoice.payment.method, - paymentsLeft: numberOfPayments, - amount: invoice.payment.amount, - asset: invoice.payment.asset, - streamId: 0 - }) - }); - - // Effects: increment the next invoice id - // Use unchecked because the invoice id cannot realistically overflow - unchecked { - ++_nextInvoiceId; - } - - // Effects: mint the invoice NFT to the recipient space - _mint({ to: msg.sender, tokenId: invoiceId }); - - // Log the invoice creation - emit InvoiceCreated({ - id: invoiceId, - recipient: msg.sender, - status: Types.Status.Pending, - startTime: invoice.startTime, - endTime: invoice.endTime, - payment: invoice.payment - }); - } - - /// @inheritdoc IInvoiceModule - function payInvoice(uint256 id) external payable { - // Load the invoice from storage - Types.Invoice memory invoice = _invoices[id]; - - // Retrieve the recipient of the invoice - // This will also check if the invoice is minted or not burned - address recipient = ownerOf(id); - - // Checks: the invoice is not already paid or canceled - if (invoice.status == Types.Status.Paid) { - revert Errors.InvoiceAlreadyPaid(); - } else if (invoice.status == Types.Status.Canceled) { - revert Errors.InvoiceCanceled(); - } - - // Handle the payment workflow depending on the payment method type - if (invoice.payment.method == Types.Method.Transfer) { - // Effects: pay the invoice and update its status to `Paid` or `Ongoing` depending on the payment type - _payByTransfer(id, invoice, recipient); - } else { - uint256 streamId; - // Check to see whether the invoice must be paid through a linear or tranched stream - if (invoice.payment.method == Types.Method.LinearStream) { - streamId = _payByLinearStream(invoice, recipient); - } else { - streamId = _payByTranchedStream(invoice, recipient); - } - - // Effects: update the status of the invoice to `Ongoing` and the stream ID - // if dealing with a linear or tranched-based invoice - _invoices[id].status = Types.Status.Ongoing; - _invoices[id].payment.streamId = streamId; - } - - // Log the payment transaction - emit InvoicePaid({ id: id, payer: msg.sender, status: _invoices[id].status, payment: _invoices[id].payment }); - } - - /// @inheritdoc IInvoiceModule - function cancelInvoice(uint256 id) external { - // Load the invoice from storage - Types.Invoice memory invoice = _invoices[id]; - - // Checks: the invoice is paid or already canceled - if (invoice.status == Types.Status.Paid) { - revert Errors.CannotCancelPaidInvoice(); - } else if (invoice.status == Types.Status.Canceled) { - revert Errors.InvoiceAlreadyCanceled(); - } - - // Checks: `msg.sender` is the recipient if invoice status is pending - // - // Notes: - // - Once a linear or tranched stream is created, the `msg.sender` is checked in the - // {SablierV2Lockup} `cancel` method - if (invoice.status == Types.Status.Pending) { - // Retrieve the recipient of the invoice - address recipient = ownerOf(id); - - if (recipient != msg.sender) { - revert Errors.OnlyInvoiceRecipient(); - } - } - // Checks, Effects, Interactions: cancel the stream if status is ongoing - // - // Notes: - // - A transfer-based invoice can be canceled directly - // - A linear or tranched stream MUST be canceled by calling the `cancel` method on the according - // {ISablierV2Lockup} contract - else if (invoice.status == Types.Status.Ongoing) { - _cancelStream({ streamType: invoice.payment.method, streamId: invoice.payment.streamId }); - } - - // Effects: mark the invoice as canceled - _invoices[id].status = Types.Status.Canceled; - - // Log the invoice cancelation - emit InvoiceCanceled(id); - } - - /// @inheritdoc IInvoiceModule - function withdrawInvoiceStream(uint256 id) public returns (uint128 withdrawnAmount) { - // Load the invoice from storage - Types.Invoice memory invoice = _invoices[id]; - - // Retrieve the recipient of the invoice - address recipient = ownerOf(id); - - // Effects: update the invoice status to `Paid` once the full payment amount has been successfully streamed - uint128 streamedAmount = - streamedAmountOf({ streamType: invoice.payment.method, streamId: invoice.payment.streamId }); - if (streamedAmount == invoice.payment.amount) { - _invoices[id].status = Types.Status.Paid; - } - - // Check, Effects, Interactions: withdraw from the stream - return - _withdrawStream({ streamType: invoice.payment.method, streamId: invoice.payment.streamId, to: recipient }); - } - - /// @inheritdoc ERC721 - function tokenURI(uint256 tokenId) public view override returns (string memory) { - // Checks: the `tokenId` was minted or is not burned - _requireOwned(tokenId); - - // Create the `tokenURI` by concatenating the `baseURI`, `tokenId` and metadata extension (.json) - string memory baseURI = _baseURI(); - return string.concat(baseURI, tokenId.toString(), ".json"); - } - - /// @inheritdoc ERC721 - function transferFrom(address from, address to, uint256 tokenId) public override { - // Retrieve the invoice details - Types.Invoice memory invoice = _invoices[tokenId]; - - // Checks: the payment request has been accepted and a stream has already been - // created if dealing with a stream-based payment - if (invoice.payment.streamId != 0) { - // Checks and Effects: withdraw the maximum withdrawable amount to the current stream recipient - // and transfer the stream NFT to the new recipient - _withdrawMaxAndTransferStream({ - streamType: invoice.payment.method, - streamId: invoice.payment.streamId, - newRecipient: to - }); - } - - // Checks, Effects and Interactions: transfer the invoice NFT - super.transferFrom(from, to, tokenId); - } - - /*////////////////////////////////////////////////////////////////////////// - INTERNAL-METHODS - //////////////////////////////////////////////////////////////////////////*/ - - /// @dev Pays the `id` invoice by transfer - function _payByTransfer(uint256 id, Types.Invoice memory invoice, address recipient) internal { - // Effects: update the invoice status to `Paid` if the required number of payments has been made - // Using unchecked because the number of payments left cannot underflow as the invoice status - // will be updated to `Paid` once `paymentLeft` is zero - unchecked { - uint40 paymentsLeft = invoice.payment.paymentsLeft - 1; - _invoices[id].payment.paymentsLeft = paymentsLeft; - if (paymentsLeft == 0) { - _invoices[id].status = Types.Status.Paid; - } else if (invoice.status == Types.Status.Pending) { - _invoices[id].status = Types.Status.Ongoing; - } - } - - // Check if the payment must be done in native token (ETH) or an ERC-20 token - if (invoice.payment.asset == address(0)) { - // Checks: the payment amount matches the invoice value - if (msg.value < invoice.payment.amount) { - revert Errors.PaymentAmountLessThanInvoiceValue({ amount: invoice.payment.amount }); - } - - // Interactions: pay the recipient with native token (ETH) - (bool success,) = payable(recipient).call{ value: invoice.payment.amount }(""); - if (!success) revert Errors.NativeTokenPaymentFailed(); - } else { - // Interactions: pay the recipient with the ERC-20 token - IERC20(invoice.payment.asset).safeTransferFrom({ - from: msg.sender, - to: recipient, - value: invoice.payment.amount - }); - } - } - - /// @dev Create the linear stream payment - function _payByLinearStream(Types.Invoice memory invoice, address recipient) internal returns (uint256 streamId) { - streamId = StreamManager.createLinearStream({ - asset: IERC20(invoice.payment.asset), - totalAmount: invoice.payment.amount, - startTime: invoice.startTime, - endTime: invoice.endTime, - recipient: recipient - }); - } - - /// @dev Create the tranched stream payment - function _payByTranchedStream( - Types.Invoice memory invoice, - address recipient - ) internal returns (uint256 streamId) { - uint40 numberOfTranches = - Helpers.computeNumberOfPayments(invoice.payment.recurrence, invoice.endTime - invoice.startTime); - - streamId = StreamManager.createTranchedStream({ - asset: IERC20(invoice.payment.asset), - totalAmount: invoice.payment.amount, - startTime: invoice.startTime, - recipient: recipient, - numberOfTranches: numberOfTranches, - recurrence: invoice.payment.recurrence - }); - } - - /// @notice Calculates the number of payments to be made for a recurring transfer and tranched stream-based invoice - /// @dev Reverts if the number of payments is zero, indicating that either the interval or recurrence type was set incorrectly - function _checkIntervalPayments( - Types.Recurrence recurrence, - uint40 startTime, - uint40 endTime - ) internal pure returns (uint40 numberOfPayments) { - // Checks: the invoice payment interval matches the recurrence type - // This cannot underflow as the start time is stricly lower than the end time when this call executes - uint40 interval; - unchecked { - interval = endTime - startTime; - } - - // Check and calculate the expected number of payments based on the invoice recurrence and payment interval - numberOfPayments = Helpers.computeNumberOfPayments(recurrence, interval); - - // Revert if there are zero payments to be made since the payment method due to invalid interval and recurrence type - if (numberOfPayments == 0) { - revert Errors.PaymentIntervalTooShortForSelectedRecurrence(); - } - } - - /// @inheritdoc ERC721 - function _baseURI() internal view override returns (string memory) { - return _collectionURI; - } -} diff --git a/src/modules/invoice-module/interfaces/IInvoiceModule.sol b/src/modules/invoice-module/interfaces/IInvoiceModule.sol deleted file mode 100644 index ca83343..0000000 --- a/src/modules/invoice-module/interfaces/IInvoiceModule.sol +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.26; - -import { Types } from "./../libraries/Types.sol"; - -/// @title IInvoiceModule -/// @notice Contract module that provides functionalities to issue and pay an on-chain invoice -interface IInvoiceModule { - /*////////////////////////////////////////////////////////////////////////// - EVENTS - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Emitted when an invoice is created - /// @param id The ID of the invoice - /// @param recipient The address receiving the payment - /// @param status The status of the invoice - /// @param startTime The timestamp when the invoice takes effect - /// @param endTime The timestamp by which the invoice must be paid - /// @param payment Struct representing the payment details associated with the invoice - event InvoiceCreated( - uint256 id, - address indexed recipient, - Types.Status status, - uint40 startTime, - uint40 endTime, - Types.Payment payment - ); - - /// @notice Emitted when an invoice is paid - /// @param id The ID of the invoice - /// @param payer The address of the payer - /// @param status The status of the invoice - /// @param payment Struct representing the payment details associated with the invoice - event InvoicePaid(uint256 indexed id, address indexed payer, Types.Status status, Types.Payment payment); - - /// @notice Emitted when an invoice is canceled - /// @param id The ID of the invoice - event InvoiceCanceled(uint256 indexed id); - - /*////////////////////////////////////////////////////////////////////////// - CONSTANT FUNCTIONS - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Retrieves the details of the `id` invoice - /// @param id The ID of the invoice for which to get the details - function getInvoice(uint256 id) external view returns (Types.Invoice memory invoice); - - /*////////////////////////////////////////////////////////////////////////// - NON-CONSTANT FUNCTIONS - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Creates a new invoice - /// - /// Requirements: - /// - `msg.sender` must be a contract implementing the {ISpace} interface - /// - /// Notes: - /// - `recipient` is not checked because the call is enforced to be made through a {Space} contract - /// - /// @param invoice The details of the invoice following the {Invoice} struct format - /// @return id The on-chain ID of the invoice - function createInvoice(Types.Invoice calldata invoice) external returns (uint256 id); - - /// @notice Pays a transfer-based invoice - /// - /// Notes: - /// - `msg.sender` is enforced to be a specific payer address - /// - /// @param id The ID of the invoice to pay - function payInvoice(uint256 id) external payable; - - /// @notice Cancels the `id` invoice - /// - /// Notes: - /// - A transfer-based invoice can be canceled only by its creator (recipient) - /// - A linear/tranched stream-based invoice can be canceled by its creator only if its - /// status is `Pending`; otherwise only the stream sender can cancel it - /// - if the invoice has a linear or tranched stream payment method, the streaming flow will be - /// stopped and the remaining funds will be refunded to the stream payer - /// - /// Important: - /// - if the invoice has a linear or tranched stream payment method, the portion that has already - /// been streamed is NOT automatically transferred - /// - /// @param id The ID of the invoice - function cancelInvoice(uint256 id) external; - - /// @notice Withdraws the maximum withdrawable amount from the stream associated with the `id` invoice - /// - /// Notes: - /// - reverts if `msg.sender` is not the stream recipient - /// - reverts if the payment method of the `id` invoice is not linear or tranched stream based - /// - /// @param id The ID of the invoice - function withdrawInvoiceStream(uint256 id) external returns (uint128 withdrawnAmount); -} diff --git a/src/modules/payment-module/PaymentModule.sol b/src/modules/payment-module/PaymentModule.sol new file mode 100644 index 0000000..db0f5b0 --- /dev/null +++ b/src/modules/payment-module/PaymentModule.sol @@ -0,0 +1,413 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.26; + +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { ISablierV2LockupLinear } from "@sablier/v2-core/src/interfaces/ISablierV2LockupLinear.sol"; +import { ISablierV2LockupTranched } from "@sablier/v2-core/src/interfaces/ISablierV2LockupTranched.sol"; +import { Lockup } from "@sablier/v2-core/src/types/DataTypes.sol"; + +import { Types } from "./libraries/Types.sol"; +import { Errors } from "./libraries/Errors.sol"; +import { IPaymentModule } from "./interfaces/IPaymentModule.sol"; +import { ISpace } from "./../../interfaces/ISpace.sol"; +import { StreamManager } from "./sablier-v2/StreamManager.sol"; +import { Helpers } from "./libraries/Helpers.sol"; + +/// @title PaymentModule +/// @notice See the documentation in {IPaymentModule} +contract PaymentModule is IPaymentModule, StreamManager { + using SafeERC20 for IERC20; + using Strings for uint256; + + /*////////////////////////////////////////////////////////////////////////// + PRIVATE STORAGE + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Payment requests details mapped by the `id` payment request ID + mapping(uint256 id => Types.PaymentRequest) private _requests; + + /// @dev Counter to keep track of the next ID used to create a new payment request + uint256 private _nextRequestId; + + /*////////////////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Initializes the {StreamManager} contract and first request ID + constructor( + ISablierV2LockupLinear _sablierLockupLinear, + ISablierV2LockupTranched _sablierLockupTranched, + address _brokerAdmin + ) + StreamManager(_sablierLockupLinear, _sablierLockupTranched, _brokerAdmin) + { + // Start the first payment request ID from 1 + _nextRequestId = 1; + } + + /*////////////////////////////////////////////////////////////////////////// + MODIFIERS + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Allow only calls from contracts implementing the {ISpace} interface + modifier onlySpace() { + // Checks: the sender is a valid non-zero code size contract + if (msg.sender.code.length == 0) { + revert Errors.SpaceZeroCodeSize(); + } + + // Checks: the sender implements the ERC-165 interface required by {ISpace} + bytes4 interfaceId = type(ISpace).interfaceId; + if (!ISpace(msg.sender).supportsInterface(interfaceId)) revert Errors.SpaceUnsupportedInterface(); + _; + } + + /*////////////////////////////////////////////////////////////////////////// + CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IPaymentModule + function getRequest(uint256 requestId) external view returns (Types.PaymentRequest memory request) { + return _requests[requestId]; + } + + /// @inheritdoc IPaymentModule + function statusOf(uint256 requestId) public view returns (Types.Status status) { + status = _statusOf(requestId); + } + + /*////////////////////////////////////////////////////////////////////////// + NON-CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IPaymentModule + function createRequest(Types.PaymentRequest calldata request) public onlySpace returns (uint256 requestId) { + // Checks: the recipient address is not the zero address + if (request.recipient == address(0)) { + revert Errors.InvalidZeroAddressRecipient(); + } + + // Checks: the amount is non-zero + if (request.config.amount == 0) { + revert Errors.ZeroPaymentAmount(); + } + + // Checks: the start time is stricly lower than the end time + if (request.startTime > request.endTime) { + revert Errors.StartTimeGreaterThanEndTime(); + } + + // Checks: end time is not in the past + uint40 currentTime = uint40(block.timestamp); + if (currentTime >= request.endTime) { + revert Errors.EndTimeInThePast(); + } + + // Checks: the recurrence type is not equal to one-off if dealing with a tranched stream-based request + if (request.config.method == Types.Method.TranchedStream) { + // The recurrence cannot be set to one-off + if (request.config.recurrence == Types.Recurrence.OneOff) { + revert Errors.TranchedStreamInvalidOneOffRecurence(); + } + } + + // Validates the payment request interval (endTime - startTime) and returns the number of payments + // based on the payment method, interval and recurrence type + // + // Notes: + // - The number of payments is validated only for requests with payment method set on Tranched Stream or Recurring Transfer + // - There should be only one payment when dealing with a one-off transfer-based request + // - When dealing with a recurring transfer, the number of payments must be calculated based + // on the payment interval (endTime - startTime) and recurrence type + uint40 numberOfPayments = 1; + if (request.config.method != Types.Method.LinearStream && request.config.recurrence != Types.Recurrence.OneOff) + { + numberOfPayments = _checkIntervalPayments({ + recurrence: request.config.recurrence, + startTime: request.startTime, + endTime: request.endTime + }); + } + + // Set the number of payments back to one if dealing with a tranched-based request + // The `_checkIntervalPayment` method is still called for a tranched-based request just + // to validate the interval and ensure it can support multiple payments based on the chosen recurrence + if (request.config.method == Types.Method.TranchedStream) numberOfPayments = 1; + + // Checks: the asset is different than the native token if dealing with either a linear or tranched stream-based payment + if (request.config.method != Types.Method.Transfer) { + if (request.config.asset == address(0)) { + revert Errors.OnlyERC20StreamsAllowed(); + } + } + + // Get the next payment request ID + requestId = _nextRequestId; + + // Effects: create the payment request + _requests[requestId] = Types.PaymentRequest({ + wasCanceled: false, + wasAccepted: false, + startTime: request.startTime, + endTime: request.endTime, + recipient: request.recipient, + config: Types.Config({ + recurrence: request.config.recurrence, + method: request.config.method, + paymentsLeft: numberOfPayments, + amount: request.config.amount, + asset: request.config.asset, + streamId: 0 + }) + }); + + // Effects: increment the next payment request ID + // Use unchecked because the request id cannot realistically overflow + unchecked { + ++_nextRequestId; + } + + // Log the payment request creation + emit RequestCreated({ + requestId: requestId, + recipient: request.recipient, + startTime: request.startTime, + endTime: request.endTime, + config: request.config + }); + } + + /// @inheritdoc IPaymentModule + function payRequest(uint256 requestId) external payable { + // Load the payment request state from storage + Types.PaymentRequest memory request = _requests[requestId]; + + // Checks: the payment request is not null + if (request.recipient == address(0)) { + revert Errors.NullRequest(); + } + + // Retrieve the request status + Types.Status requestStatus = _statusOf(requestId); + + // Checks: the payment request is not already paid or canceled + // Note: for stream-based requests the `status` changes to `Paid` only after the funds are fully streamed + if (requestStatus == Types.Status.Paid || request.config.paymentsLeft == 0) { + revert Errors.RequestPaid(); + } else if (requestStatus == Types.Status.Canceled) { + revert Errors.RequestCanceled(); + } + + // Handle the payment workflow depending on the payment method type + if (request.config.method == Types.Method.Transfer) { + // Effects: pay the request and update its status to `Paid` or `Accepted` depending on the payment type + _payByTransfer(request); + } else { + uint256 streamId; + + // Check to see whether the request must be paid through a linear or tranched stream + if (request.config.method == Types.Method.LinearStream) { + streamId = _payByLinearStream(request); + } else { + streamId = _payByTranchedStream(request); + } + + // Effects: set the stream ID of the payment request + _requests[requestId].config.streamId = streamId; + } + + // Effects: decrease the number of payments left + // Using unchecked because the number of payments left cannot underflow: + // - For transfer-based requests, the status will be updated to `Paid` when `paymentsLeft` reaches zero; + // - For stream-based requests, `paymentsLeft` is validated before decrementing; + uint40 paymentsLeft; + unchecked { + paymentsLeft = request.config.paymentsLeft - 1; + _requests[requestId].config.paymentsLeft = paymentsLeft; + } + + // Effects: mark the payment request as accepted + _requests[requestId].wasAccepted = true; + + // Log the payment transaction + emit RequestPaid({ requestId: requestId, payer: msg.sender, config: _requests[requestId].config }); + } + + /// @inheritdoc IPaymentModule + function cancelRequest(uint256 requestId) external { + // Load the payment request state from storage + Types.PaymentRequest memory request = _requests[requestId]; + + // Retrieve the request status + Types.Status requestStatus = _statusOf(requestId); + + // Checks: the payment request is already paid or canceled + if (requestStatus == Types.Status.Paid) { + revert Errors.RequestPaid(); + } else if (requestStatus == Types.Status.Canceled) { + revert Errors.RequestCanceled(); + } + + // Checks: `msg.sender` is the recipient if the payment request status is `Pending` + // + // Notes: + // - Once a linear or tranched stream is created, the `msg.sender` is checked in the + // {SablierV2Lockup} `cancel` method + if (requestStatus == Types.Status.Pending) { + if (request.recipient != msg.sender) { + revert Errors.OnlyRequestRecipient(); + } + } + // Checks, Effects, Interactions: cancel the stream if payment request has already been accepted + // and the payment method is either linear or tranched stream + // + // Notes: + // - A transfer-based payment request can be canceled directly + // - A linear or tranched stream MUST be canceled by calling the `cancel` method on the according + // {ISablierV2Lockup} contract + else if (request.config.method != Types.Method.Transfer) { + _cancelStream({ streamType: request.config.method, streamId: request.config.streamId }); + } + + // Effects: mark the payment request as canceled + _requests[requestId].wasCanceled = true; + + // Log the payment request cancelation + emit RequestCanceled(requestId); + } + + /// @inheritdoc IPaymentModule + function withdrawRequestStream(uint256 requestId) public returns (uint128 withdrawnAmount) { + // Load the payment request state from storage + Types.PaymentRequest memory request = _requests[requestId]; + + // Check, Effects, Interactions: withdraw from the stream + return _withdrawStream({ + streamType: request.config.method, + streamId: request.config.streamId, + to: request.recipient + }); + } + + /*////////////////////////////////////////////////////////////////////////// + INTERNAL-METHODS + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Pays the `id` request by transfer + function _payByTransfer(Types.PaymentRequest memory request) internal { + // Check if the payment must be done in native token (ETH) or an ERC-20 token + if (request.config.asset == address(0)) { + // Checks: the payment amount matches the request value + if (msg.value < request.config.amount) { + revert Errors.PaymentAmountLessThanRequestedAmount({ amount: request.config.amount }); + } + + // Interactions: pay the recipient with native token (ETH) + (bool success,) = payable(request.recipient).call{ value: request.config.amount }(""); + if (!success) revert Errors.NativeTokenPaymentFailed(); + } else { + // Interactions: pay the recipient with the ERC-20 token + IERC20(request.config.asset).safeTransferFrom({ + from: msg.sender, + to: request.recipient, + value: request.config.amount + }); + } + } + + /// @dev Create the linear stream payment + function _payByLinearStream(Types.PaymentRequest memory request) internal returns (uint256 streamId) { + streamId = StreamManager.createLinearStream({ + asset: IERC20(request.config.asset), + totalAmount: request.config.amount, + startTime: request.startTime, + endTime: request.endTime, + recipient: request.recipient + }); + } + + /// @dev Create the tranched stream payment + function _payByTranchedStream(Types.PaymentRequest memory request) internal returns (uint256 streamId) { + uint40 numberOfTranches = + Helpers.computeNumberOfPayments(request.config.recurrence, request.endTime - request.startTime); + + streamId = StreamManager.createTranchedStream({ + asset: IERC20(request.config.asset), + totalAmount: request.config.amount, + startTime: request.startTime, + recipient: request.recipient, + numberOfTranches: numberOfTranches, + recurrence: request.config.recurrence + }); + } + + /// @notice Calculates the number of payments to be made for a recurring transfer and tranched stream-based request + /// @dev Reverts if the number of payments is zero, indicating that either the interval or recurrence type was set incorrectly + function _checkIntervalPayments( + Types.Recurrence recurrence, + uint40 startTime, + uint40 endTime + ) + internal + pure + returns (uint40 numberOfPayments) + { + // Checks: the request payment interval matches the recurrence type + // This cannot underflow as the start time is stricly lower than the end time when this call executes + uint40 interval; + unchecked { + interval = endTime - startTime; + } + + // Check and calculate the expected number of payments based on the recurrence and payment interval + numberOfPayments = Helpers.computeNumberOfPayments(recurrence, interval); + + // Revert if there are zero payments to be made since the payment method due to invalid interval and recurrence type + if (numberOfPayments == 0) { + revert Errors.PaymentIntervalTooShortForSelectedRecurrence(); + } + } + + /// @notice Retrieves the status of the `requestId` payment request + /// Note: + /// - The status of a payment request is determined by the `wasCanceled` and `wasAccepted` flags and: + /// - For a stream-based payment request, by the status of the underlying stream; + /// - For a transfer-based payment request, by the number of payments left; + function _statusOf(uint256 requestId) internal view returns (Types.Status status) { + // Load the payment request state from storage + Types.PaymentRequest memory request = _requests[requestId]; + + if (!request.wasAccepted && !request.wasCanceled) { + return Types.Status.Pending; + } + + // Check if dealing with a stream-based payment request + if (request.config.streamId != 0) { + Lockup.Status statusOfStream = StreamManager.statusOfStream(request.config.method, request.config.streamId); + + if (statusOfStream == Lockup.Status.SETTLED) { + return Types.Status.Paid; + } else if (statusOfStream == Lockup.Status.DEPLETED) { + // Retrieve the total streamed amount until now + uint128 streamedAmount = + streamedAmountOf({ streamType: request.config.method, streamId: request.config.streamId }); + + // Check if the payment request is canceled or paid + streamedAmount < request.config.amount ? Types.Status.Canceled : Types.Status.Paid; + } else { + return Types.Status.Accepted; + } + } + + // Otherwise, the payment request is a transfer-based one + if (request.wasCanceled) { + return Types.Status.Canceled; + } else if (request.config.paymentsLeft == 0) { + return Types.Status.Paid; + } + + return Types.Status.Accepted; + } +} diff --git a/src/modules/payment-module/interfaces/IPaymentModule.sol b/src/modules/payment-module/interfaces/IPaymentModule.sol new file mode 100644 index 0000000..aec84c1 --- /dev/null +++ b/src/modules/payment-module/interfaces/IPaymentModule.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.26; + +import { Types } from "./../libraries/Types.sol"; + +/// @title IPaymentModule +/// @notice Contract module that provides functionalities to issue on-chain payment requests +interface IPaymentModule { + /*////////////////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Emitted when a payment request is created + /// @param requestId The ID of the payment request + /// @param recipient The address receiving the payment + /// @param startTime The timestamp when the payment request takes effect + /// @param endTime The timestamp by which the payment request must be paid + /// @param config Struct representing the payment details associated with the payment request + event RequestCreated( + uint256 requestId, address indexed recipient, uint40 startTime, uint40 endTime, Types.Config config + ); + + /// @notice Emitted when a payment is made for a payment request + /// @param requestId The ID of the payment request + /// @param payer The address of the payer + /// @param config Struct representing the payment details + event RequestPaid(uint256 indexed requestId, address indexed payer, Types.Config config); + + /// @notice Emitted when a payment request is canceled + /// @param requestId The ID of the payment request + event RequestCanceled(uint256 indexed requestId); + + /*////////////////////////////////////////////////////////////////////////// + CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Retrieves the details of the `id` payment request + /// @param requestId The ID of the payment request for which to get the details + function getRequest(uint256 requestId) external view returns (Types.PaymentRequest memory request); + + /// @notice Retrieves the status of the `requestId` payment request + /// @param requestId The ID of the payment request for which to retrieve the status + function statusOf(uint256 requestId) external view returns (Types.Status status); + + /*////////////////////////////////////////////////////////////////////////// + NON-CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Creates a new payment request + /// + /// Requirements: + /// - `msg.sender` must be a contract implementing the {ISpace} interface + /// + /// Notes: + /// - `recipient` is not checked because the call is enforced to be made through a {Space} contract + /// + /// @param request request The details of the payment request following the {PaymentRequest} struct format + /// @return requestId The on-chain ID of the payment request + function createRequest(Types.PaymentRequest calldata request) external returns (uint256 requestId); + + /// @notice Pays a transfer-based payment request + /// + /// Notes: + /// - `msg.sender` is enforced to be a specific payer address + /// + /// @param requestId The ID of the payment request to pay + function payRequest(uint256 requestId) external payable; + + /// @notice Cancels the `id` payment request + /// + /// Notes: + /// - A transfer-based payment request can be canceled only by its creator (recipient) + /// - A linear/tranched stream-based payment request can be canceled by its creator only if its + /// status is `Pending`; otherwise only the stream sender can cancel it + /// - if the payment request has a linear or tranched stream payment method, the streaming flow will be + /// stopped and the remaining funds will be refunded to the stream payer + /// + /// Important: + /// - if the payment request has a linear or tranched stream payment method, the portion that has already + /// been streamed is NOT automatically transferred + /// + /// @param requestId The ID of the payment request + function cancelRequest(uint256 requestId) external; + + /// @notice Withdraws the maximum withdrawable amount from the stream associated with the `id` payment request + /// + /// Notes: + /// - reverts if `msg.sender` is not the stream recipient + /// - reverts if the payment method of the `id` payment request is not linear or tranched stream + /// + /// @param requestId The ID of the payment request + function withdrawRequestStream(uint256 requestId) external returns (uint128 withdrawnAmount); +} diff --git a/src/modules/invoice-module/libraries/Errors.sol b/src/modules/payment-module/libraries/Errors.sol similarity index 62% rename from src/modules/invoice-module/libraries/Errors.sol rename to src/modules/payment-module/libraries/Errors.sol index e9f0c88..e4cefdf 100644 --- a/src/modules/invoice-module/libraries/Errors.sol +++ b/src/modules/payment-module/libraries/Errors.sol @@ -2,10 +2,10 @@ pragma solidity ^0.8.26; /// @title Errors -/// @notice Library containing all custom errors the {InvoiceModule} and {StreamManager} may revert with +/// @notice Library containing all custom errors the {PaymentModule} and {StreamManager} may revert with library Errors { /*////////////////////////////////////////////////////////////////////////// - INVOICE-MODULE + PAYMENT-MODULE //////////////////////////////////////////////////////////////////////////*/ /// @notice Thrown when the caller is an invalid zero code contract or EOA @@ -14,17 +14,17 @@ library Errors { /// @notice Thrown when the caller is a contract that does not implement the {ISpace} interface error SpaceUnsupportedInterface(); - /// @notice Thrown when the end time of an invoice is in the past + /// @notice Thrown when the end time of a payment request is in the past error EndTimeInThePast(); /// @notice Thrown when the start time is later than the end time error StartTimeGreaterThanEndTime(); - /// @notice Thrown when the payment amount set for a new invoice is zero + /// @notice Thrown when the payment amount set for a new paymentRequest is zero error ZeroPaymentAmount(); - /// @notice Thrown when the payment amount is less than the invoice value - error PaymentAmountLessThanInvoiceValue(uint256 amount); + /// @notice Thrown when the payment amount is less than the payment request value + error PaymentAmountLessThanRequestedAmount(uint256 amount); /// @notice Thrown when a payment in the native token (ETH) fails error NativeTokenPaymentFailed(); @@ -32,20 +32,17 @@ library Errors { /// @notice Thrown when a linear or tranched stream is created with the native token as the payment asset error OnlyERC20StreamsAllowed(); - /// @notice Thrown when a payer attempts to pay an invoice that has already been paid - error InvoiceAlreadyPaid(); + /// @notice Thrown when a payer attempts to pay a canceled payment request + error RequestCanceled(); - /// @notice Thrown when a payer attempts to pay a canceled invoice - error InvoiceCanceled(); + /// @notice Thrown when a payer attempts to pay a completed payment request + error RequestPaid(); - /// @notice Thrown when the invoice ID references a null invoice - error InvoiceNull(); + /// @notice Thrown when `msg.sender` is not the payment request recipient + error OnlyRequestRecipient(); - /// @notice Thrown when `msg.sender` attempts to withdraw from an invoice that is not stream-based - error InvoiceNotStreamBased(); - - /// @notice Thrown when `msg.sender` is not the invoice recipient - error OnlyInvoiceRecipient(); + /// @notice Thrown when the recipient address is the zero address + error InvalidZeroAddressRecipient(); /// @notice Thrown when the payment interval (endTime - startTime) is too short for the selected recurrence /// i.e. recurrence is set to weekly but interval is shorter than 1 week @@ -54,15 +51,12 @@ library Errors { /// @notice Thrown when a tranched stream has a one-off recurrence type error TranchedStreamInvalidOneOffRecurence(); - /// @notice Thrown when an attempt is made to cancel an already paid invoice - error CannotCancelPaidInvoice(); - - /// @notice Thrown when an attempt is made to cancel an already canceled invoice - error InvoiceAlreadyCanceled(); - /// @notice Thrown when the caller is not the initial stream sender error OnlyInitialStreamSender(address initialSender); + /// @notice Thrown when the payment request is null + error NullRequest(); + /*////////////////////////////////////////////////////////////////////////// STREAM-MANAGER //////////////////////////////////////////////////////////////////////////*/ diff --git a/src/modules/invoice-module/libraries/Helpers.sol b/src/modules/payment-module/libraries/Helpers.sol similarity index 82% rename from src/modules/invoice-module/libraries/Helpers.sol rename to src/modules/payment-module/libraries/Helpers.sol index bb09d62..56d430f 100644 --- a/src/modules/invoice-module/libraries/Helpers.sol +++ b/src/modules/payment-module/libraries/Helpers.sol @@ -4,16 +4,20 @@ pragma solidity ^0.8.26; import { Types } from "./Types.sol"; /// @title Helpers -/// @notice Library with helpers used across the {InvoiceModule} contract +/// @notice Library with helpers used across the {PaymentModule} contract library Helpers { - /// @dev Calculates the number of payments that must be done for a recurring transfer or tranched stream invoice + /// @dev Calculates the number of payments that must be done for a recurring transfer or tranched stream paymentRequest /// Notes: /// - Known issue: due to leap seconds, not every year equals 365 days and not every day has 24 hours /// - See https://docs.soliditylang.org/en/v0.8.26/units-and-global-variables.html#time-units function computeNumberOfPayments( Types.Recurrence recurrence, uint40 interval - ) internal pure returns (uint40 numberOfPayments) { + ) + internal + pure + returns (uint40 numberOfPayments) + { // Calculate the number of payments based on the recurrence type if (recurrence == Types.Recurrence.Weekly) { numberOfPayments = interval / 1 weeks; diff --git a/src/modules/invoice-module/libraries/Types.sol b/src/modules/payment-module/libraries/Types.sol similarity index 60% rename from src/modules/invoice-module/libraries/Types.sol rename to src/modules/payment-module/libraries/Types.sol index 5bc14c3..2f41757 100644 --- a/src/modules/invoice-module/libraries/Types.sol +++ b/src/modules/payment-module/libraries/Types.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.26; -/// @notice Namespace for the structs used across the Invoice Module contracts +/// @notice Namespace for the structs used across the {PaymentModule} related contracts library Types { /// @notice Enum representing the different recurrences a payment can have /// @custom:value OneOff One single payment that must be made either as a single transfer or through a linear stream @@ -15,7 +15,7 @@ library Types { Yearly } - /// @notice Enum representing the different payment methods an invoice can have + /// @notice Enum representing the different payment methods /// @custom:value Transfer Payment method must be made through a transfer /// @custom:value LinearStream Payment method must be made through a linear stream /// @custom:value TranchedStream Payment method must be made through a tranched stream @@ -25,14 +25,14 @@ library Types { TranchedStream } - /// @notice Struct encapsulating the different values describing a payment + /// @notice Struct encapsulating the different values describing a payment config /// @param method The payment method /// @param recurrence The payment recurrence - /// @param paymentsLeft The number of payments required to fully settle the invoice (only for transfer or tranched stream based invoices) + /// @param paymentsLeft The number of payments required to fully settle the payment request (only for transfer or tranched stream based paymentRequests) /// @param asset The address of the payment asset /// @param amount The amount that must be paid /// @param streamId The ID of the linear or tranched stream if payment method is either `LinearStream` or `TranchedStream`, otherwise 0 - struct Payment { + struct Config { // slot 0 Method method; Recurrence recurrence; @@ -44,30 +44,33 @@ library Types { uint256 streamId; } - /// @notice Enum representing the different statuses an invoice can have - /// @custom:value Pending Invoice waiting to be paid - /// @custom:value Ongoing Invoice is being paid; if the payment method is a One-Off Transfer, the invoice status will - /// automatically be set to `Paid`. Otherwise, it will remain `Ongoing` until the invoice is fully paid. - /// @custom:value Canceled Invoice cancelled by the recipient (if Transfer-based) or stream sender + /// @notice Enum representing the different statuses a payment request can have + /// @custom:value Pending Payment request waiting to be accepted by the payer + /// @custom:value Accepted Payment request has been accepted and is being paid; if the payment method is a One-Off Transfer, + /// the payment request status will automatically be set to `Completed`. Otherwise, it will remain `Accepted` until it is fully paid + /// @custom:value Paid Payment request has been fully paid + /// @custom:value Canceled Payment request canceled by declined by the recipient (if Transfer-based) or stream sender enum Status { Pending, - Ongoing, + Accepted, Paid, Canceled } - /// @notice Struct encapsulating the different values describing an invoice - /// @param recipient The address of the payee - /// @param status The status of the invoice - /// @param startTime The unix timestamp indicating when the invoice payment starts - /// @param endTime The unix timestamp indicating when the invoice payment ends - /// @param payment The payment struct describing the invoice payment - struct Invoice { + /// @notice Struct encapsulating the different values describing a payment request + /// @param status The status of the payment request + /// @param startTime The unix timestamp indicating when the payment starts + /// @param endTime The unix timestamp indicating when the payment ends + /// @param recipient The address to which the payment is made + /// @param payment The payment configurations + struct PaymentRequest { // slot 0 - Status status; + bool wasCanceled; + bool wasAccepted; uint40 startTime; uint40 endTime; + address recipient; // slot 1, 2 and 3 - Payment payment; + Config config; } } diff --git a/src/modules/invoice-module/sablier-v2/StreamManager.sol b/src/modules/payment-module/sablier-v2/StreamManager.sol similarity index 94% rename from src/modules/invoice-module/sablier-v2/StreamManager.sol rename to src/modules/payment-module/sablier-v2/StreamManager.sol index bf27b56..209af16 100644 --- a/src/modules/invoice-module/sablier-v2/StreamManager.sol +++ b/src/modules/payment-module/sablier-v2/StreamManager.sol @@ -5,7 +5,7 @@ import { ISablierV2LockupLinear } from "@sablier/v2-core/src/interfaces/ISablier import { ISablierV2LockupTranched } from "@sablier/v2-core/src/interfaces/ISablierV2LockupTranched.sol"; import { ISablierV2Lockup } from "@sablier/v2-core/src/interfaces/ISablierV2Lockup.sol"; import { LockupLinear, LockupTranched } from "@sablier/v2-core/src/types/DataTypes.sol"; -import { Broker, LockupLinear } from "@sablier/v2-core/src/types/DataTypes.sol"; +import { Broker, LockupLinear, Lockup } from "@sablier/v2-core/src/types/DataTypes.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { ud60x18, UD60x18, ud, intoUint128 } from "@prb/math/src/UD60x18.sol"; @@ -78,7 +78,11 @@ abstract contract StreamManager is IStreamManager { function withdrawableAmountOf( Types.Method streamType, uint256 streamId - ) public view returns (uint128 withdrawableAmount) { + ) + public + view + returns (uint128 withdrawableAmount) + { withdrawableAmount = _getISablierV2Lockup(streamType).withdrawableAmountOf(streamId); } @@ -87,6 +91,11 @@ abstract contract StreamManager is IStreamManager { streamedAmount = _getISablierV2Lockup(streamType).streamedAmountOf(streamId); } + /// @inheritdoc IStreamManager + function statusOfStream(Types.Method streamType, uint256 streamId) public view returns (Lockup.Status status) { + status = _getISablierV2Lockup(streamType).statusOf(streamId); + } + /*////////////////////////////////////////////////////////////////////////// NON-CONSTANT FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ @@ -98,7 +107,10 @@ abstract contract StreamManager is IStreamManager { uint40 startTime, uint40 endTime, address recipient - ) public returns (uint256 streamId) { + ) + public + returns (uint256 streamId) + { // Transfer the provided amount of ERC-20 tokens to this contract and approve the Sablier contract to spend it _transferFromAndApprove({ asset: asset, amount: totalAmount, spender: address(LOCKUP_LINEAR) }); @@ -117,7 +129,10 @@ abstract contract StreamManager is IStreamManager { address recipient, uint128 numberOfTranches, Types.Recurrence recurrence - ) public returns (uint256 streamId) { + ) + public + returns (uint256 streamId) + { // Transfer the provided amount of ERC-20 tokens to this contract and approve the Sablier contract to spend it _transferFromAndApprove({ asset: asset, amount: totalAmount, spender: address(LOCKUP_TRANCHED) }); @@ -152,7 +167,10 @@ abstract contract StreamManager is IStreamManager { uint40 startTime, uint40 endTime, address recipient - ) internal returns (uint256 streamId) { + ) + internal + returns (uint256 streamId) + { // Declare the params struct LockupLinear.CreateWithTimestamps memory params; @@ -179,7 +197,10 @@ abstract contract StreamManager is IStreamManager { address recipient, uint128 numberOfTranches, Types.Recurrence recurrence - ) internal returns (uint256 streamId) { + ) + internal + returns (uint256 streamId) + { // Declare the params struct LockupTranched.CreateWithTimestamps memory params; @@ -235,7 +256,10 @@ abstract contract StreamManager is IStreamManager { Types.Method streamType, uint256 streamId, address to - ) internal returns (uint128 withdrawnAmount) { + ) + internal + returns (uint128 withdrawnAmount) + { // Set the according {ISablierV2Lockup} based on the stream type ISablierV2Lockup sablier = _getISablierV2Lockup(streamType); @@ -250,7 +274,10 @@ abstract contract StreamManager is IStreamManager { Types.Method streamType, uint256 streamId, address newRecipient - ) internal returns (uint128 withdrawnAmount) { + ) + internal + returns (uint128 withdrawnAmount) + { // Set the according {ISablierV2Lockup} based on the stream type ISablierV2Lockup sablier = _getISablierV2Lockup(streamType); diff --git a/src/modules/invoice-module/sablier-v2/interfaces/IStreamManager.sol b/src/modules/payment-module/sablier-v2/interfaces/IStreamManager.sol similarity index 89% rename from src/modules/invoice-module/sablier-v2/interfaces/IStreamManager.sol rename to src/modules/payment-module/sablier-v2/interfaces/IStreamManager.sol index 6ec2a41..36a739f 100644 --- a/src/modules/invoice-module/sablier-v2/interfaces/IStreamManager.sol +++ b/src/modules/payment-module/sablier-v2/interfaces/IStreamManager.sol @@ -6,6 +6,7 @@ import { ISablierV2LockupTranched } from "@sablier/v2-core/src/interfaces/ISabli import { LockupLinear, LockupTranched } from "@sablier/v2-core/src/types/DataTypes.sol"; import { ISablierV2Lockup } from "@sablier/v2-core/src/interfaces/ISablierV2Lockup.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Lockup } from "@sablier/v2-core/src/types/DataTypes.sol"; import { UD60x18 } from "@prb/math/src/UD60x18.sol"; import { Types } from "./../../libraries/Types.sol"; @@ -57,7 +58,10 @@ interface IStreamManager { function withdrawableAmountOf( Types.Method streamType, uint256 streamId - ) external view returns (uint128 withdrawableAmount); + ) + external + view + returns (uint128 withdrawableAmount); /// @notice See the documentation in {ISablierV2Lockup-streamedAmountOf} /// Notes: @@ -65,7 +69,15 @@ interface IStreamManager { function streamedAmountOf( Types.Method streamType, uint256 streamId - ) external view returns (uint128 streamedAmount); + ) + external + view + returns (uint128 streamedAmount); + + /// @notice See the documentation in {ISablierV2Lockup-statusOf} + /// Notes: + /// - `streamType` parameter has been added to retrieve from the according {ISablierV2Lockup} contract + function statusOfStream(Types.Method streamType, uint256 streamId) external view returns (Lockup.Status status); /*////////////////////////////////////////////////////////////////////////// NON-CONSTANT FUNCTIONS @@ -83,7 +95,9 @@ interface IStreamManager { uint40 startTime, uint40 endTime, address recipient - ) external returns (uint256 streamId); + ) + external + returns (uint256 streamId); /// @notice Creates a Lockup Tranched stream; See https://docs.sablier.com/concepts/protocol/stream-types#lockup-tranched /// @param asset The address of the ERC-20 token to be streamed @@ -99,7 +113,9 @@ interface IStreamManager { address recipient, uint128 numberOfTranches, Types.Recurrence recurrence - ) external returns (uint256 streamId); + ) + external + returns (uint256 streamId); /// @notice Updates the fee charged by the broker /// diff --git a/src/peripherals/invoice-collection/InvoiceCollection.sol b/src/peripherals/invoice-collection/InvoiceCollection.sol new file mode 100644 index 0000000..e828c3a --- /dev/null +++ b/src/peripherals/invoice-collection/InvoiceCollection.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.26; + +import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import { ERC721URIStorage } from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { Errors } from "./libraries/Errors.sol"; +import { IInvoiceCollection } from "./interfaces/IInvoiceCollection.sol"; + +/// @title InvoiceCollection +/// @notice See the documentation in {IInvoiceCollection} +contract InvoiceCollection is IInvoiceCollection, ERC721URIStorage { + using Strings for uint256; + + /*////////////////////////////////////////////////////////////////////////// + PUBLIC STORAGE + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev The address of the off-chain Relayer responsible to mint on-chain invoices + address public relayer; + + /// @dev Token ID of the invoicemapped to the payment request ID + mapping(uint256 tokenId => string paymentRequestId) public tokenIdToPaymentRequestId; + + /*////////////////////////////////////////////////////////////////////////// + PRIVATE STORAGE + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Counter to keep track of the next ID used to mint a new token per invoice + uint256 private _nextTokenId; + + /*////////////////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Initializes the {InvoiceCollection} contract + constructor(address _relayer, string memory _name, string memory _symbol) ERC721(_name, _symbol) { + // Set the authorized Relayer + relayer = _relayer; + + // Start the invoice token IDs from 1 + _nextTokenId = 1; + } + + /*////////////////////////////////////////////////////////////////////////// + NON-CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IInvoiceCollection + function mintInvoice( + string memory invoiceURI, + address paymentRecipient, + string memory paymentRequestId + ) + public + returns (uint256 tokenId) + { + // Checks: `msg.sender` is the authorized Relayer to mint tokens + if (msg.sender != relayer) { + revert Errors.Unauthorized(); + } + + // Get the next token ID + tokenId = _nextTokenId; + + // Effects: increment the next token ID + // Use unchecked because the token ID cannot realistically overflow + unchecked { + ++_nextTokenId; + } + + // Effects: set the `paymentRequestId` that belongs to the `tokenId` invoice + tokenIdToPaymentRequestId[tokenId] = paymentRequestId; + + // Effects: mint the invoice NFT to the payment recipient + _mint({ to: paymentRecipient, tokenId: tokenId }); + + // Effects: set the `invoiceURI` for the `tokenId` invoice + _setTokenURI(tokenId, invoiceURI); + + // Log the invoice minting + emit InvoiceMinted({ to: paymentRecipient, tokenId: tokenId, paymentRequestId: paymentRequestId }); + } + + /*////////////////////////////////////////////////////////////////////////// + INTERNAL-METHODS + //////////////////////////////////////////////////////////////////////////*/ + + /// @inheritdoc ERC721 + /// @dev Guard tokens from being transferred making them Soulbound Tokens (SBT) + function _update(address to, uint256 tokenId, address auth) internal override(ERC721) returns (address) { + address from = _ownerOf(tokenId); + if (from != address(0) && to != address(0)) { + revert("Soulbound token!"); + } + + return super._update(to, tokenId, auth); + } +} diff --git a/src/peripherals/invoice-collection/interfaces/IInvoiceCollection.sol b/src/peripherals/invoice-collection/interfaces/IInvoiceCollection.sol new file mode 100644 index 0000000..003024c --- /dev/null +++ b/src/peripherals/invoice-collection/interfaces/IInvoiceCollection.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.26; + +/// @title IInvoiceCollection +/// @notice Peripheral contract that provides functionalities to mint ERC-721 tokens representing off-chain invoices +interface IInvoiceCollection { + /*////////////////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Emitted when an invoice is created + /// @param to The address of the payment recipient of the invoice + /// @param tokenId The ID of the NFT representing the invoice + /// @param paymentRequestId The ID of the payment request associated with the invoice + event InvoiceMinted(address to, uint256 tokenId, string paymentRequestId); + + /*////////////////////////////////////////////////////////////////////////// + NON-CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Creates an on-chain representation of an off-chain invoice by creating a payment request and minting an ERC-721 token + /// @param invoiceURI The metadata URI of the invoice + /// @param paymentRecipient The address of the payment recipient of the invoice + /// @param paymentRequestId The ID of the payment request associated with the invoice + /// @return tokenId The ID of the NFT representing the invoice + function mintInvoice( + string memory invoiceURI, + address paymentRecipient, + string memory paymentRequestId + ) + external + returns (uint256 tokenId); +} diff --git a/src/peripherals/invoice-collection/libraries/Errors.sol b/src/peripherals/invoice-collection/libraries/Errors.sol new file mode 100644 index 0000000..aa25fde --- /dev/null +++ b/src/peripherals/invoice-collection/libraries/Errors.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.26; + +/// @title Errors +/// @notice Library containing all custom errors the {InvoiceCollection} may revert with +library Errors { + /*////////////////////////////////////////////////////////////////////////// + INVOICE-MODULE + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Thrown when the caller is unathorized to execute a call + error Unauthorized(); +} diff --git a/src/utils/BaseAccountFactory.sol b/src/utils/BaseAccountFactory.sol index 6892c94..f728f9f 100644 --- a/src/utils/BaseAccountFactory.sol +++ b/src/utils/BaseAccountFactory.sol @@ -75,9 +75,7 @@ abstract contract BaseAccountFactory is IAccountFactory, Multicall { //////////////////////////////////////////////////////////////*/ /// @notice Callback function for an Account to register itself on the factory. - function onRegister( - bytes32 _salt - ) external { + function onRegister(bytes32 _salt) external { address account = msg.sender; require(_isAccountOfFactory(account, _salt), "AccountFactory: not an account."); @@ -112,9 +110,7 @@ abstract contract BaseAccountFactory is IAccountFactory, Multicall { //////////////////////////////////////////////////////////////*/ /// @notice Returns whether an account is registered on this factory. - function isRegistered( - address _account - ) external view returns (bool) { + function isRegistered(address _account) external view returns (bool) { return allAccounts.contains(_account); } @@ -147,9 +143,7 @@ abstract contract BaseAccountFactory is IAccountFactory, Multicall { } /// @notice Returns all accounts that the given address is a signer of. - function getAccountsOfSigner( - address signer - ) external view returns (address[] memory accounts) { + function getAccountsOfSigner(address signer) external view returns (address[] memory accounts) { return accountsOfSigner[signer].values(); } @@ -163,9 +157,7 @@ abstract contract BaseAccountFactory is IAccountFactory, Multicall { return _account == predicted; } - function _getImplementation( - address cloneAddress - ) internal view returns (address) { + function _getImplementation(address cloneAddress) internal view returns (address) { bytes memory code = cloneAddress.code; return BytesLib.toAddress(code, 10); } diff --git a/test/Base.t.sol b/test/Base.t.sol index bafd1df..01a4098 100644 --- a/test/Base.t.sol +++ b/test/Base.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.20; +pragma solidity ^0.8.22; import { Events } from "./utils/Events.sol"; import { Users } from "./utils/Types.sol"; @@ -89,9 +89,12 @@ abstract contract Base_Test is Test, Events { /// @dev Deploys a new {Space} smart account based on the provided `owner`, `moduleKeeper` and `initialModules` input params function deploySpace( address _owner, - uint256 _spaceId, + uint256 _stationId, address[] memory _initialModules - ) internal returns (Space _container) { + ) + internal + returns (Space _space) + { vm.startPrank({ msgSender: users.admin }); for (uint256 i; i < _initialModules.length; ++i) { allowlistModule(_initialModules[i]); @@ -99,19 +102,22 @@ abstract contract Base_Test is Test, Events { vm.stopPrank(); bytes memory data = - computeCreateAccountCalldata({ deployer: _owner, stationId: _spaceId, initialModules: _initialModules }); + computeCreateAccountCalldata({ deployer: _owner, stationId: _stationId, initialModules: _initialModules }); vm.prank({ msgSender: _owner }); - _container = Space(payable(stationRegistry.createAccount({ _admin: _owner, _data: data }))); + _space = Space(payable(stationRegistry.createAccount({ _admin: _owner, _data: data }))); vm.stopPrank(); } /// @dev Deploys a new {MockBadSpace} smart account based on the provided `owner`, `moduleKeeper` and `initialModules` input params function deployBadSpace( address _owner, - uint256 _spaceId, + uint256 _stationId, address[] memory _initialModules - ) internal returns (MockBadSpace _badSpace) { + ) + internal + returns (MockBadSpace _badSpace) + { vm.startPrank({ msgSender: users.admin }); for (uint256 i; i < _initialModules.length; ++i) { allowlistModule(_initialModules[i]); @@ -119,16 +125,14 @@ abstract contract Base_Test is Test, Events { vm.stopPrank(); bytes memory data = - computeCreateAccountCalldata({ deployer: _owner, stationId: _spaceId, initialModules: _initialModules }); + computeCreateAccountCalldata({ deployer: _owner, stationId: _stationId, initialModules: _initialModules }); vm.prank({ msgSender: _owner }); _badSpace = MockBadSpace(payable(stationRegistry.createAccount({ _admin: _owner, _data: data }))); vm.stopPrank(); } - function allowlistModule( - address _module - ) internal { + function allowlistModule(address _module) internal { moduleKeeper.addToAllowlist({ module: _module }); } @@ -137,9 +141,7 @@ abstract contract Base_Test is Test, Events { //////////////////////////////////////////////////////////////////////////*/ /// @dev Generates a user, labels its address, and funds it with test assets - function createUser( - string memory name - ) internal returns (address payable) { + function createUser(string memory name) internal returns (address payable) { address payable user = payable(makeAddr(name)); vm.deal({ account: user, newBalance: 100 ether }); deal({ token: address(usdt), to: user, give: 10_000_000e18 }); @@ -153,7 +155,11 @@ abstract contract Base_Test is Test, Events { address deployer, uint256 stationId, address[] memory initialModules - ) internal view returns (address expectedAddress, bytes memory data) { + ) + internal + view + returns (address expectedAddress, bytes memory data) + { data = computeCreateAccountCalldata(deployer, stationId, initialModules); // Compute the final salt made by the deployer address and initialization data @@ -169,7 +175,11 @@ abstract contract Base_Test is Test, Events { address deployer, uint256 stationId, address[] memory initialModules - ) internal view returns (bytes memory data) { + ) + internal + view + returns (bytes memory data) + { // Get the total account deployed by `deployer` and use it as a unique salt field // because a signer must be able to deploy multiple smart accounts within one // station with the same initial modules diff --git a/test/integration/Integration.t.sol b/test/integration/Integration.t.sol index 181d3d5..1e08bc8 100644 --- a/test/integration/Integration.t.sol +++ b/test/integration/Integration.t.sol @@ -2,7 +2,8 @@ pragma solidity ^0.8.26; import { Base_Test } from "../Base.t.sol"; -import { InvoiceModule } from "./../../src/modules/invoice-module/InvoiceModule.sol"; +import { PaymentModule } from "./../../src/modules/payment-module/PaymentModule.sol"; +import { InvoiceCollection } from "./../../src/peripherals/invoice-collection/InvoiceCollection.sol"; import { SablierV2LockupLinear } from "@sablier/v2-core/src/SablierV2LockupLinear.sol"; import { SablierV2LockupTranched } from "@sablier/v2-core/src/SablierV2LockupTranched.sol"; import { MockNFTDescriptor } from "../mocks/MockNFTDescriptor.sol"; @@ -15,7 +16,8 @@ abstract contract Integration_Test is Base_Test { TEST CONTRACTS //////////////////////////////////////////////////////////////////////////*/ - InvoiceModule internal invoiceModule; + PaymentModule internal paymentModule; + InvoiceCollection internal invoiceCollection; // Sablier V2 related test contracts MockNFTDescriptor internal mockNFTDescriptor; SablierV2LockupLinear internal sablierV2LockupLinear; @@ -30,24 +32,27 @@ abstract contract Integration_Test is Base_Test { function setUp() public virtual override { Base_Test.setUp(); - // Deploy the {InvoiceModule} modul - deployInvoiceModule(); + // Deploy the {PaymentModule} module + deployPaymentModule(); - // Setup the initial {InvoiceModule} module to be initialized on the {Space} + // Deploy the {InvoiceCollection} module + deployInvoiceCollection(); + + // Enable the {PaymentModule} module on the {Space} contract address[] memory modules = new address[](1); - modules[0] = address(invoiceModule); + modules[0] = address(paymentModule); - // Deploy the {Space} contract with the {InvoiceModule} enabled by default - space = deploySpace({ _owner: users.eve, _spaceId: 0, _initialModules: modules }); + // Deploy the {Space} contract with the {PaymentModule} enabled by default + space = deploySpace({ _owner: users.eve, _stationId: 0, _initialModules: modules }); // Deploy a "bad" {Space} with the `mockBadReceiver` as the owner - badSpace = deployBadSpace({ _owner: address(mockBadReceiver), _spaceId: 0, _initialModules: modules }); + badSpace = deployBadSpace({ _owner: address(mockBadReceiver), _stationId: 0, _initialModules: modules }); // Deploy the mock {StreamManager} mockStreamManager = new MockStreamManager(sablierV2LockupLinear, sablierV2LockupTranched, users.admin); // Label the test contracts so we can easily track them - vm.label({ account: address(invoiceModule), newLabel: "InvoiceModule" }); + vm.label({ account: address(paymentModule), newLabel: "PaymentModule" }); vm.label({ account: address(sablierV2LockupLinear), newLabel: "SablierV2LockupLinear" }); vm.label({ account: address(sablierV2LockupTranched), newLabel: "SablierV2LockupTranched" }); vm.label({ account: address(space), newLabel: "Eve's Space" }); @@ -58,8 +63,25 @@ abstract contract Integration_Test is Base_Test { DEPLOYMENT-RELATED FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ - /// @dev Deploys the {InvoiceModule} module by initializing the Sablier v2-required contracts first - function deployInvoiceModule() internal { + /// @dev Deploys the {PaymentModule} module by initializing the Sablier v2-required contracts first + function deployPaymentModule() internal { + deploySablierContracts(); + + paymentModule = new PaymentModule({ + _sablierLockupLinear: sablierV2LockupLinear, + _sablierLockupTranched: sablierV2LockupTranched, + _brokerAdmin: users.admin + }); + } + + /// @dev Deploys the {InvoiceCollection} peripheral + function deployInvoiceCollection() internal { + invoiceCollection = + new InvoiceCollection({ _relayer: users.admin, _name: "Werk Invoice NFTs", _symbol: "WERK-INVOICES" }); + } + + /// @dev Deploys the Sablier v2-required contracts + function deploySablierContracts() internal { mockNFTDescriptor = new MockNFTDescriptor(); sablierV2LockupLinear = new SablierV2LockupLinear({ initialAdmin: users.admin, initialNFTDescriptor: mockNFTDescriptor }); @@ -68,11 +90,5 @@ abstract contract Integration_Test is Base_Test { initialNFTDescriptor: mockNFTDescriptor, maxTrancheCount: 1000 }); - invoiceModule = new InvoiceModule({ - _sablierLockupLinear: sablierV2LockupLinear, - _sablierLockupTranched: sablierV2LockupTranched, - _brokerAdmin: users.admin, - _URI: "ipfs://CID/" - }); } } diff --git a/test/integration/concrete/invoice-collection/mint-invoice/mint-invoice.sol b/test/integration/concrete/invoice-collection/mint-invoice/mint-invoice.sol new file mode 100644 index 0000000..edc8a3a --- /dev/null +++ b/test/integration/concrete/invoice-collection/mint-invoice/mint-invoice.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import { Integration_Test } from "../../../Integration.t.sol"; +import { Errors } from "../../../../utils/Errors.sol"; +import { Events } from "../../../../utils/Events.sol"; + +contract MintInvoice_Integration_Concret_Test is Integration_Test { + function setUp() public virtual override { + Integration_Test.setUp(); + } + + function test_RevertWhen_CallerNotRelayer() external { + // Make Bob the caller in this test suite which is the authorized Relayer + vm.startPrank({ msgSender: users.bob }); + + // Expect the call to revert with the {Unauthorized} error + vm.expectRevert(Errors.Unauthorized.selector); + + // Run the test + invoiceCollection.mintInvoice({ + invoiceURI: "ipfs://QmSomeHash", + paymentRecipient: users.bob, + paymentRequestId: "1" + }); + } + + modifier whenCallerRelayer() { + // Make Admin the caller for the next test suite as they're the authorized Relayer + vm.startPrank({ msgSender: users.admin }); + + _; + } + + function test_MintInvoice() external whenCallerRelayer { + // Expect the {MintInvoice} event to be emitted + vm.expectEmit(); + emit Events.InvoiceMinted({ to: users.bob, tokenId: 1, paymentRequestId: "1" }); + + // Run the test + invoiceCollection.mintInvoice({ + invoiceURI: "ipfs://QmSomeHash", + paymentRecipient: users.bob, + paymentRequestId: "1" + }); + + // Assert the actual and expected payment request ID associated with the invoice NFT + string memory actualPaymentRequestId = invoiceCollection.tokenIdToPaymentRequestId(1); + assertEq(actualPaymentRequestId, "1"); + + // Assert the actual and expected invoice URI associated with the invoice NFT + string memory actualInvoiceURI = invoiceCollection.tokenURI(1); + assertEq(actualInvoiceURI, "ipfs://QmSomeHash"); + + // Assert the actual and expected owner of the invoice NFT + assertEq(invoiceCollection.ownerOf(1), users.bob); + } +} diff --git a/test/integration/concrete/invoice-collection/mint-invoice/mint-invoice.tree b/test/integration/concrete/invoice-collection/mint-invoice/mint-invoice.tree new file mode 100644 index 0000000..9f40db6 --- /dev/null +++ b/test/integration/concrete/invoice-collection/mint-invoice/mint-invoice.tree @@ -0,0 +1,8 @@ +mintInvoice.t.sol +├── when the caller IS NOT the authorized Relayer +│ └── it should revert with the {Unathorized} error +└── when the caller IS the authorized Relayer + ├── it should emit the {InvoiceMinted} event + ├── it should set the `paymentRequestId` for the `tokenId` + ├── it should mint the invoice NFT to the payment recipient + └── it should set the `tokenURI` for the `tokenId` \ No newline at end of file diff --git a/test/integration/concrete/invoice-collection/transfer-from/transferFrom.t.sol b/test/integration/concrete/invoice-collection/transfer-from/transferFrom.t.sol new file mode 100644 index 0000000..586c4a3 --- /dev/null +++ b/test/integration/concrete/invoice-collection/transfer-from/transferFrom.t.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import { Integration_Test } from "../../../Integration.t.sol"; + +contract TransferFrom_Integration_Concret_Test is Integration_Test { + function setUp() public virtual override { + Integration_Test.setUp(); + + // Mint an invoice NFT to Bob + vm.startPrank({ msgSender: users.admin }); + invoiceCollection.mintInvoice({ + invoiceURI: "ipfs://QmSomeHash", + paymentRecipient: users.bob, + paymentRequestId: "1" + }); + vm.stopPrank(); + } + + function test_TransferFrom() external { + // Expect the transfer to revert with the "Soulbound token!" reason + vm.expectRevert("Soulbound token!"); + + // Make Bob the caller as he's the owner of the invoice NFT + vm.startPrank({ msgSender: users.bob }); + + // Run the test + invoiceCollection.transferFrom(users.bob, users.eve, 1); + } +} diff --git a/test/integration/concrete/invoice-collection/transfer-from/transferFrom.tree b/test/integration/concrete/invoice-collection/transfer-from/transferFrom.tree new file mode 100644 index 0000000..cfb84ec --- /dev/null +++ b/test/integration/concrete/invoice-collection/transfer-from/transferFrom.tree @@ -0,0 +1,3 @@ +transferFrom.t.sol +└── when the token exists + └── it should revert with the "Soulbound token!" reason \ No newline at end of file diff --git a/test/integration/concrete/invoice-module/cancel-invoice/cancelInvoice.t.sol b/test/integration/concrete/invoice-module/cancel-invoice/cancelInvoice.t.sol deleted file mode 100644 index 460e3ea..0000000 --- a/test/integration/concrete/invoice-module/cancel-invoice/cancelInvoice.t.sol +++ /dev/null @@ -1,312 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.26; - -import { CancelInvoice_Integration_Shared_Test } from "../../../shared/cancelInvoice.t.sol"; -import { Types } from "./../../../../../src/modules/invoice-module/libraries/Types.sol"; -import { Events } from "../../../../utils/Events.sol"; -import { Errors } from "../../../../utils/Errors.sol"; - -contract CancelInvoice_Integration_Concret_Test is CancelInvoice_Integration_Shared_Test { - function setUp() public virtual override { - CancelInvoice_Integration_Shared_Test.setUp(); - } - - function test_RevertWhen_InvoiceIsPaid() external { - // Set the one-off ETH transfer invoice as current one - uint256 invoiceId = 2; - - // Make Bob the payer for the default invoice - vm.startPrank({ msgSender: users.bob }); - - // Pay the invoice first - invoiceModule.payInvoice{ value: invoices[invoiceId].payment.amount }({ id: invoiceId }); - - // Make Eve the caller who is the recipient of the invoice - vm.startPrank({ msgSender: users.eve }); - - // Expect the call to revert with the {CannotCancelPaidInvoice} error - vm.expectRevert(Errors.CannotCancelPaidInvoice.selector); - - // Run the test - invoiceModule.cancelInvoice({ id: invoiceId }); - } - - function test_RevertWhen_InvoiceIsCanceled() external whenInvoiceNotAlreadyPaid { - // Set the one-off ETH transfer invoice as current one - uint256 invoiceId = 2; - - // Make Eve's space the caller which is the recipient of the invoice - vm.startPrank({ msgSender: address(space) }); - - // Cancel the invoice first - invoiceModule.cancelInvoice({ id: invoiceId }); - - // Expect the call to revert with the {InvoiceAlreadyCanceled} error - vm.expectRevert(Errors.InvoiceAlreadyCanceled.selector); - - // Run the test - invoiceModule.cancelInvoice({ id: invoiceId }); - } - - function test_RevertWhen_PaymentMethodTransfer_SenderNotInvoiceRecipient() - external - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodTransfer - { - // Set the one-off ETH transfer invoice as current one - uint256 invoiceId = 2; - - // Make Bob the caller who IS NOT the recipient of the invoice - vm.startPrank({ msgSender: users.bob }); - - // Expect the call to revert with the {OnlyInvoiceRecipient} error - vm.expectRevert(Errors.OnlyInvoiceRecipient.selector); - - // Run the test - invoiceModule.cancelInvoice({ id: invoiceId }); - } - - function test_CancelInvoice_PaymentMethodTransfer() - external - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodTransfer - whenSenderInvoiceRecipient - { - // Set the one-off ETH transfer invoice as current one - uint256 invoiceId = 2; - - // Make Eve's space the caller which is the recipient of the invoice - vm.startPrank({ msgSender: address(space) }); - - // Expect the {InvoiceCanceled} event to be emitted - vm.expectEmit(); - emit Events.InvoiceCanceled({ id: invoiceId }); - - // Run the test - invoiceModule.cancelInvoice({ id: invoiceId }); - - // Assert the actual and expected invoice status - Types.Invoice memory invoice = invoiceModule.getInvoice({ id: invoiceId }); - assertEq(uint8(invoice.status), uint8(Types.Status.Canceled)); - } - - function test_RevertWhen_PaymentMethodLinearStream_StatusPending_SenderNotInvoiceRecipient() - external - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodLinearStream - givenInvoiceStatusPending - { - // Set current invoice as a linear stream-based one - uint256 invoiceId = 5; - - // Make Bob the caller who IS NOT the recipient of the invoice - vm.startPrank({ msgSender: users.bob }); - - // Expect the call to revert with the {OnlyInvoiceRecipient} error - vm.expectRevert(Errors.OnlyInvoiceRecipient.selector); - - // Run the test - invoiceModule.cancelInvoice({ id: invoiceId }); - } - - function test_CancelInvoice_PaymentMethodLinearStream_StatusPending() - external - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodLinearStream - givenInvoiceStatusPending - whenSenderInvoiceRecipient - { - // Set current invoice as a linear stream-based one - uint256 invoiceId = 5; - - // Make Eve's space the caller which is the recipient of the invoice - vm.startPrank({ msgSender: address(space) }); - - // Expect the {InvoiceCanceled} event to be emitted - vm.expectEmit(); - emit Events.InvoiceCanceled({ id: invoiceId }); - - // Run the test - invoiceModule.cancelInvoice({ id: invoiceId }); - - // Assert the actual and expected invoice status - Types.Invoice memory invoice = invoiceModule.getInvoice({ id: invoiceId }); - assertEq(uint8(invoice.status), uint8(Types.Status.Canceled)); - } - - function test_RevertWhen_PaymentMethodLinearStream_StatusOngoing_SenderNoInitialtStreamSender() - external - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodLinearStream - givenInvoiceStatusOngoing - { - // Set current invoice as a linear stream-based one - uint256 invoiceId = 5; - - // The invoice must be paid for its status to be updated to `Ongoing` - // Make Bob the payer of the invoice (also Bob will be the stream sender) - vm.startPrank({ msgSender: users.bob }); - - // Approve the {InvoiceModule} to transfer the USDT tokens on Bob's behalf - usdt.approve({ spender: address(invoiceModule), amount: invoices[invoiceId].payment.amount }); - - // Pay the invoice first (status will be updated to `Ongoing`) - invoiceModule.payInvoice{ value: invoices[invoiceId].payment.amount }({ id: invoiceId }); - - // Make Eve the caller who IS NOT the initial stream sender but rather the recipient - vm.startPrank({ msgSender: users.eve }); - - // Expect the call to revert with the {OnlyInitialStreamSender} error - vm.expectRevert(abi.encodeWithSelector(Errors.OnlyInitialStreamSender.selector, users.bob)); - - // Run the test - invoiceModule.cancelInvoice({ id: invoiceId }); - } - - function test_CancelInvoice_PaymentMethodLinearStream_StatusOngoing() - external - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodLinearStream - givenInvoiceStatusOngoing - whenSenderInitialStreamSender - { - // Set current invoice as a linear stream-based one - uint256 invoiceId = 5; - - // The invoice must be paid for its status to be updated to `Ongoing` - // Make Bob the payer of the invoice (also Bob will be the initial stream sender) - vm.startPrank({ msgSender: users.bob }); - - // Approve the {InvoiceModule} to transfer the USDT tokens on Bob's behalf - usdt.approve({ spender: address(invoiceModule), amount: invoices[invoiceId].payment.amount }); - - // Pay the invoice first (status will be updated to `Ongoing`) - invoiceModule.payInvoice{ value: invoices[invoiceId].payment.amount }({ id: invoiceId }); - - // Expect the {InvoiceCanceled} event to be emitted - vm.expectEmit(); - emit Events.InvoiceCanceled({ id: invoiceId }); - - // Run the test - invoiceModule.cancelInvoice({ id: invoiceId }); - - // Assert the actual and expected invoice status - Types.Invoice memory invoice = invoiceModule.getInvoice({ id: invoiceId }); - assertEq(uint8(invoice.status), uint8(Types.Status.Canceled)); - } - - function test_RevertWhen_PaymentMethodTranchedStream_StatusPending_SenderNotInvoiceRecipient() - external - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodTranchedStream - givenInvoiceStatusPending - { - // Set current invoice as a tranched stream-based one - uint256 invoiceId = 5; - - // Make Bob the caller who IS NOT the recipient of the invoice - vm.startPrank({ msgSender: users.bob }); - - // Expect the call to revert with the {OnlyInvoiceRecipient} error - vm.expectRevert(Errors.OnlyInvoiceRecipient.selector); - - // Run the test - invoiceModule.cancelInvoice({ id: invoiceId }); - } - - function test_CancelInvoice_PaymentMethodTranchedStream_StatusPending() - external - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodTranchedStream - givenInvoiceStatusPending - whenSenderInvoiceRecipient - { - // Set current invoice as a tranched stream-based one - uint256 invoiceId = 5; - - // Make Eve's space the caller which is the recipient of the invoice - vm.startPrank({ msgSender: address(space) }); - - // Expect the {InvoiceCanceled} event to be emitted - vm.expectEmit(); - emit Events.InvoiceCanceled({ id: invoiceId }); - - // Run the test - invoiceModule.cancelInvoice({ id: invoiceId }); - - // Assert the actual and expected invoice status - Types.Invoice memory invoice = invoiceModule.getInvoice({ id: invoiceId }); - assertEq(uint8(invoice.status), uint8(Types.Status.Canceled)); - } - - function test_RevertWhen_PaymentMethodTranchedStream_StatusOngoing_SenderNoInitialtStreamSender() - external - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodTranchedStream - givenInvoiceStatusOngoing - { - // Set current invoice as a tranched stream-based one - uint256 invoiceId = 5; - - // The invoice must be paid for its status to be updated to `Ongoing` - // Make Bob the payer of the invoice (also Bob will be the stream sender) - vm.startPrank({ msgSender: users.bob }); - - // Approve the {InvoiceModule} to transfer the USDT tokens on Bob's behalf - usdt.approve({ spender: address(invoiceModule), amount: invoices[invoiceId].payment.amount }); - - // Pay the invoice first (status will be updated to `Ongoing`) - invoiceModule.payInvoice{ value: invoices[invoiceId].payment.amount }({ id: invoiceId }); - - // Make Eve the caller who IS NOT the initial stream sender but rather the recipient - vm.startPrank({ msgSender: users.eve }); - - // Expect the call to revert with the {OnlyInitialStreamSender} error - vm.expectRevert(abi.encodeWithSelector(Errors.OnlyInitialStreamSender.selector, users.bob)); - - // Run the test - invoiceModule.cancelInvoice({ id: invoiceId }); - } - - function test_CancelInvoice_PaymentMethodTranchedStream_StatusOngoing() - external - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodTranchedStream - givenInvoiceStatusOngoing - whenSenderInitialStreamSender - { - // Set current invoice as a tranched stream-based one - uint256 invoiceId = 5; - - // The invoice must be paid for its status to be updated to `Ongoing` - // Make Bob the payer of the invoice (also Bob will be the initial stream sender) - vm.startPrank({ msgSender: users.bob }); - - // Approve the {InvoiceModule} to transfer the USDT tokens on Bob's behalf - usdt.approve({ spender: address(invoiceModule), amount: invoices[invoiceId].payment.amount }); - - // Pay the invoice first (status will be updated to `Ongoing`) - invoiceModule.payInvoice{ value: invoices[invoiceId].payment.amount }({ id: invoiceId }); - - // Expect the {InvoiceCanceled} event to be emitted - vm.expectEmit(); - emit Events.InvoiceCanceled({ id: invoiceId }); - - // Run the test - invoiceModule.cancelInvoice({ id: invoiceId }); - - // Assert the actual and expected invoice status - Types.Invoice memory invoice = invoiceModule.getInvoice({ id: invoiceId }); - assertEq(uint8(invoice.status), uint8(Types.Status.Canceled)); - } -} diff --git a/test/integration/concrete/invoice-module/cancel-invoice/cancelInvoice.tree b/test/integration/concrete/invoice-module/cancel-invoice/cancelInvoice.tree deleted file mode 100644 index f076dea..0000000 --- a/test/integration/concrete/invoice-module/cancel-invoice/cancelInvoice.tree +++ /dev/null @@ -1,40 +0,0 @@ -cancelInvoice.t.sol -├── when the invoice status IS Paid -│ └── it should revert with the {CannotCancelPaidInvoice} error -└── when the invoice status IS NOT Paid - ├── when the invoice status IS Canceled - │ └── it should revert with the {InvoiceAlreadyCanceled} error - └── when the invoice status IS NOT Canceled - ├── given the payment method is transfer - │ ├── when the sender IS NOT the invoice recipient - │ │ └── it should revert with the {OnlyInvoiceRecipient} - │ └── when the sender IS the invoice recipient - │ ├── it should mark the invoice as Canceled - │ └── it should emit an {InvoiceCanceled} event - ├── given the payment method is linear stream-based - │ ├── given the invoice status is Pending - │ │ ├── when the sender IS NOT the invoice recipient - │ │ │ └── it should revert with the {OnlyInvoiceRecipient} - │ │ └── when the sender IS the invoice recipient - │ │ ├── it should mark the invoice as Canceled - │ │ └── it should emit an {InvoiceCanceled} event - │ └── given the invoice status is Ongoing - │ ├── when the sender IS NOT the initial stream sender - │ │ └── it should revert with the {OnlyInitialStreamSender} error - │ └── when the sender IS the initial stream sender - │ ├── it should mark the invoice as Canceled - │ └── it should emit an {InvoiceCanceled} event - └── given the payment method is tranched stream-based - ├── given the invoice status is Pending - │ ├── when the sender IS NOT the invoice recipient - │ │ └── it should revert with the {OnlyInvoiceRecipient} - │ └── when the sender IS the invoice recipient - │ ├── it should mark the invoice as Canceled - │ └── it should emit an {InvoiceCanceled} event - └── given the invoice status is Ongoing - ├── when the sender IS NOT the initial stream sender - │ └──it should revert with the {OnlyInitialStreamSender} error - └── when the sender IS the initial stream sender - ├── it should mark the invoice as Canceled - └── it should emit an {InvoiceCanceled} event - diff --git a/test/integration/concrete/invoice-module/create-invoice/createInvoice.t.sol b/test/integration/concrete/invoice-module/create-invoice/createInvoice.t.sol deleted file mode 100644 index 6dd4c51..0000000 --- a/test/integration/concrete/invoice-module/create-invoice/createInvoice.t.sol +++ /dev/null @@ -1,504 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.26; - -import { CreateInvoice_Integration_Shared_Test } from "../../../shared/createInvoice.t.sol"; -import { Types } from "./../../../../../src/modules/invoice-module/libraries/Types.sol"; -import { Errors } from "../../../../utils/Errors.sol"; -import { Events } from "../../../../utils/Events.sol"; - -contract CreateInvoice_Integration_Concret_Test is CreateInvoice_Integration_Shared_Test { - Types.Invoice invoice; - - function setUp() public virtual override { - CreateInvoice_Integration_Shared_Test.setUp(); - } - - function test_RevertWhen_CallerNotContract() external { - // Make Bob the caller in this test suite which is an EOA - vm.startPrank({ msgSender: users.bob }); - - // Expect the call to revert with the {SpaceZeroCodeSize} error - vm.expectRevert(Errors.SpaceZeroCodeSize.selector); - - // Create an one-off transfer invoice - invoice = createInvoiceWithOneOffTransfer({ asset: address(usdt) }); - - // Run the test - invoiceModule.createInvoice(invoice); - } - - function test_RevertWhen_NonCompliantSpace() external whenCallerContract { - // Make Eve the caller in this test suite as she's the owner of the {Space} contract - vm.startPrank({ msgSender: users.eve }); - - // Create an one-off transfer invoice - invoice = createInvoiceWithOneOffTransfer({ asset: address(usdt) }); - - // Create the calldata for the Invoice Module execution - bytes memory data = abi.encodeWithSignature( - "createInvoice((uint8,uint40,uint40,(uint8,uint8,uint40,address,uint128,uint256)))", invoice - ); - - // Expect the call to revert with the {SpaceUnsupportedInterface} error - vm.expectRevert(Errors.SpaceUnsupportedInterface.selector); - - // Run the test - mockNonCompliantSpace.execute({ module: address(invoiceModule), value: 0, data: data }); - } - - function test_RevertWhen_ZeroPaymentAmount() external whenCallerContract whenCompliantSpace { - // Make Eve the caller in this test suite as she's the owner of the {Space} contract - vm.startPrank({ msgSender: users.eve }); - - // Create an one-off transfer invoice - invoice = createInvoiceWithOneOffTransfer({ asset: address(usdt) }); - - // Set the payment amount to zero to simulate the error - invoice.payment.amount = 0; - - // Create the calldata for the Invoice Module execution - bytes memory data = abi.encodeWithSignature( - "createInvoice((uint8,uint40,uint40,(uint8,uint8,uint40,address,uint128,uint256)))", invoice - ); - - // Expect the call to revert with the {ZeroPaymentAmount} error - vm.expectRevert(Errors.ZeroPaymentAmount.selector); - - // Run the test - space.execute({ module: address(invoiceModule), value: 0, data: data }); - } - - function test_RevertWhen_StartTimeGreaterThanEndTime() - external - whenCallerContract - whenCompliantSpace - whenNonZeroPaymentAmount - { - // Make Eve the caller in this test suite as she's the owner of the {Space} contract - vm.startPrank({ msgSender: users.eve }); - - // Create an one-off transfer invoice - invoice = createInvoiceWithOneOffTransfer({ asset: address(usdt) }); - - // Set the start time to be the current timestamp and the end time one second earlier - invoice.startTime = uint40(block.timestamp); - invoice.endTime = uint40(block.timestamp) - 1; - - // Create the calldata for the Invoice Module execution - bytes memory data = abi.encodeWithSignature( - "createInvoice((uint8,uint40,uint40,(uint8,uint8,uint40,address,uint128,uint256)))", invoice - ); - - // Expect the call to revert with the {StartTimeGreaterThanEndTime} error - vm.expectRevert(Errors.StartTimeGreaterThanEndTime.selector); - - // Run the test - space.execute({ module: address(invoiceModule), value: 0, data: data }); - } - - function test_RevertWhen_EndTimeInThePast() - external - whenCallerContract - whenCompliantSpace - whenNonZeroPaymentAmount - whenStartTimeLowerThanEndTime - { - // Make Eve the caller in this test suite as she's the owner of the {Space} contract - vm.startPrank({ msgSender: users.eve }); - - // Create an one-off transfer invoice - invoice = createInvoiceWithOneOffTransfer({ asset: address(usdt) }); - - // Set the block.timestamp to 1641070800 - vm.warp(1_641_070_800); - - // Set the start time to be the lower than the end time so the 'start time lower than end time' passes - // but set the end time in the past to get the {EndTimeInThePast} revert - invoice.startTime = uint40(block.timestamp) - 2 days; - invoice.endTime = uint40(block.timestamp) - 1 days; - - // Create the calldata for the Invoice Module execution - bytes memory data = abi.encodeWithSignature( - "createInvoice((uint8,uint40,uint40,(uint8,uint8,uint40,address,uint128,uint256)))", invoice - ); - - // Expect the call to revert with the {EndTimeInThePast} error - vm.expectRevert(Errors.EndTimeInThePast.selector); - - // Run the test - space.execute({ module: address(invoiceModule), value: 0, data: data }); - } - - function test_CreateInvoice_PaymentMethodOneOffTransfer() - external - whenCallerContract - whenCompliantSpace - whenNonZeroPaymentAmount - whenStartTimeLowerThanEndTime - whenEndTimeInTheFuture - givenPaymentMethodOneOffTransfer - { - // Make Eve the caller in this test suite as she's the owner of the {Space} contract - vm.startPrank({ msgSender: users.eve }); - - // Create a recurring transfer invoice that must be paid on a monthly basis - // Hence, the interval between the start and end time must be at least 1 month - invoice = createInvoiceWithOneOffTransfer({ asset: address(usdt) }); - - // Create the calldata for the Invoice Module execution - bytes memory data = abi.encodeWithSignature( - "createInvoice((uint8,uint40,uint40,(uint8,uint8,uint40,address,uint128,uint256)))", invoice - ); - - // Expect the module call to emit an {InvoiceCreated} event - vm.expectEmit(); - emit Events.InvoiceCreated({ - id: 1, - recipient: address(space), - status: Types.Status.Pending, - startTime: invoice.startTime, - endTime: invoice.endTime, - payment: invoice.payment - }); - - // Expect the {Space} contract to emit a {ModuleExecutionSucceded} event - vm.expectEmit(); - emit Events.ModuleExecutionSucceded({ module: address(invoiceModule), value: 0, data: data }); - - // Run the test - space.execute({ module: address(invoiceModule), value: 0, data: data }); - - // Assert the actual and expected invoice state - Types.Invoice memory actualInvoice = invoiceModule.getInvoice({ id: 1 }); - address expectedRecipient = invoiceModule.ownerOf(1); - - assertEq(expectedRecipient, address(space)); - assertEq(uint8(actualInvoice.status), uint8(Types.Status.Pending)); - assertEq(actualInvoice.startTime, invoice.startTime); - assertEq(actualInvoice.endTime, invoice.endTime); - assertEq(uint8(actualInvoice.payment.method), uint8(Types.Method.Transfer)); - assertEq(uint8(actualInvoice.payment.recurrence), uint8(Types.Recurrence.OneOff)); - assertEq(actualInvoice.payment.paymentsLeft, 1); - assertEq(actualInvoice.payment.asset, invoice.payment.asset); - assertEq(actualInvoice.payment.amount, invoice.payment.amount); - assertEq(actualInvoice.payment.streamId, 0); - } - - function test_RevertWhen_PaymentMethodRecurringTransfer_PaymentIntervalTooShortForSelectedRecurrence() - external - whenCallerContract - whenCompliantSpace - whenNonZeroPaymentAmount - whenStartTimeLowerThanEndTime - whenEndTimeInTheFuture - givenPaymentMethodRecurringTransfer - { - // Make Eve the caller in this test suite as she's the owner of the {Space} contract - vm.startPrank({ msgSender: users.eve }); - - // Create a recurring transfer invoice that must be paid on a monthly basis - // Hence, the interval between the start and end time must be at least 1 month - invoice = createInvoiceWithRecurringTransfer({ recurrence: Types.Recurrence.Monthly }); - - // Alter the end time to be 3 weeks from now - invoice.endTime = uint40(block.timestamp) + 3 weeks; - - // Create the calldata for the Invoice Module execution - bytes memory data = abi.encodeWithSignature( - "createInvoice((uint8,uint40,uint40,(uint8,uint8,uint40,address,uint128,uint256)))", invoice - ); - - // Expect the call to revert with the {PaymentIntervalTooShortForSelectedRecurrence} error - vm.expectRevert(Errors.PaymentIntervalTooShortForSelectedRecurrence.selector); - - // Run the test - space.execute({ module: address(invoiceModule), value: 0, data: data }); - } - - function test_CreateInvoice_RecurringTransfer() - external - whenCallerContract - whenCompliantSpace - whenNonZeroPaymentAmount - whenStartTimeLowerThanEndTime - whenEndTimeInTheFuture - givenPaymentMethodRecurringTransfer - whenPaymentIntervalLongEnough - { - // Make Eve the caller in this test suite as she's the owner of the {Space} contract - vm.startPrank({ msgSender: users.eve }); - - // Create a recurring transfer invoice that must be paid on weekly basis - invoice = createInvoiceWithRecurringTransfer({ recurrence: Types.Recurrence.Weekly }); - - // Create the calldata for the Invoice Module execution - bytes memory data = abi.encodeWithSignature( - "createInvoice((uint8,uint40,uint40,(uint8,uint8,uint40,address,uint128,uint256)))", invoice - ); - - // Expect the module call to emit an {InvoiceCreated} event - vm.expectEmit(); - emit Events.InvoiceCreated({ - id: 1, - recipient: address(space), - status: Types.Status.Pending, - startTime: invoice.startTime, - endTime: invoice.endTime, - payment: invoice.payment - }); - - // Expect the {Space} contract to emit a {ModuleExecutionSucceded} event - vm.expectEmit(); - emit Events.ModuleExecutionSucceded({ module: address(invoiceModule), value: 0, data: data }); - - // Run the test - space.execute({ module: address(invoiceModule), value: 0, data: data }); - - // Assert the actual and expected invoice state - Types.Invoice memory actualInvoice = invoiceModule.getInvoice({ id: 1 }); - address expectedRecipient = invoiceModule.ownerOf(1); - - assertEq(expectedRecipient, address(space)); - assertEq(uint8(actualInvoice.status), uint8(Types.Status.Pending)); - assertEq(actualInvoice.startTime, invoice.startTime); - assertEq(actualInvoice.endTime, invoice.endTime); - assertEq(uint8(actualInvoice.payment.method), uint8(Types.Method.Transfer)); - assertEq(uint8(actualInvoice.payment.recurrence), uint8(Types.Recurrence.Weekly)); - assertEq(actualInvoice.payment.paymentsLeft, 4); - assertEq(actualInvoice.payment.asset, invoice.payment.asset); - assertEq(actualInvoice.payment.amount, invoice.payment.amount); - assertEq(actualInvoice.payment.streamId, 0); - } - - function test_RevertWhen_PaymentMethodTranchedStream_RecurrenceSetToOneOff() - external - whenCallerContract - whenCompliantSpace - whenNonZeroPaymentAmount - whenStartTimeLowerThanEndTime - whenEndTimeInTheFuture - givenPaymentMethodTranchedStream - { - // Make Eve the caller in this test suite as she's the owner of the {Space} contract - vm.startPrank({ msgSender: users.eve }); - - // Create a new invoice with a tranched stream payment - invoice = createInvoiceWithTranchedStream({ recurrence: Types.Recurrence.Weekly }); - - // Alter the payment recurrence by setting it to one-off - invoice.payment.recurrence = Types.Recurrence.OneOff; - - // Expect the call to revert with the {TranchedStreamInvalidOneOffRecurence} error - vm.expectRevert(Errors.TranchedStreamInvalidOneOffRecurence.selector); - - // Create the calldata for the Invoice Module execution - bytes memory data = abi.encodeWithSignature( - "createInvoice((uint8,uint40,uint40,(uint8,uint8,uint40,address,uint128,uint256)))", invoice - ); - - // Run the test - space.execute({ module: address(invoiceModule), value: 0, data: data }); - } - - function test_RevertWhen_PaymentMethodTranchedStream_PaymentIntervalTooShortForSelectedRecurrence() - external - whenCallerContract - whenCompliantSpace - whenNonZeroPaymentAmount - whenStartTimeLowerThanEndTime - whenEndTimeInTheFuture - givenPaymentMethodTranchedStream - whenTranchedStreamWithGoodRecurring - { - // Make Eve the caller in this test suite as she's the owner of the {Space} contract - vm.startPrank({ msgSender: users.eve }); - - // Create a new invoice with a tranched stream payment - invoice = createInvoiceWithTranchedStream({ recurrence: Types.Recurrence.Monthly }); - - // Alter the end time to be 3 weeks from now - invoice.endTime = uint40(block.timestamp) + 3 weeks; - - // Expect the call to revert with the {PaymentIntervalTooShortForSelectedRecurrence} error - vm.expectRevert(Errors.PaymentIntervalTooShortForSelectedRecurrence.selector); - - // Create the calldata for the Invoice Module execution - bytes memory data = abi.encodeWithSignature( - "createInvoice((uint8,uint40,uint40,(uint8,uint8,uint40,address,uint128,uint256)))", invoice - ); - - // Run the test - space.execute({ module: address(invoiceModule), value: 0, data: data }); - } - - function test_RevertWhen_PaymentMethodTranchedStream_PaymentAssetNativeToken() - external - whenCallerContract - whenCompliantSpace - whenNonZeroPaymentAmount - whenStartTimeLowerThanEndTime - whenEndTimeInTheFuture - givenPaymentMethodTranchedStream - whenTranchedStreamWithGoodRecurring - whenPaymentIntervalLongEnough - { - // Make Eve the caller in this test suite as she's the owner of the {Space} contract - vm.startPrank({ msgSender: users.eve }); - - // Create a new invoice with a linear stream payment - invoice = createInvoiceWithTranchedStream({ recurrence: Types.Recurrence.Weekly }); - - // Alter the payment asset by setting it to - invoice.payment.asset = address(0); - - // Expect the call to revert with the {OnlyERC20StreamsAllowed} error - vm.expectRevert(Errors.OnlyERC20StreamsAllowed.selector); - - // Create the calldata for the Invoice Module execution - bytes memory data = abi.encodeWithSignature( - "createInvoice((uint8,uint40,uint40,(uint8,uint8,uint40,address,uint128,uint256)))", invoice - ); - - // Run the test - space.execute({ module: address(invoiceModule), value: 0, data: data }); - } - - function test_CreateInvoice_Tranched() - external - whenCallerContract - whenCompliantSpace - whenNonZeroPaymentAmount - whenStartTimeLowerThanEndTime - whenEndTimeInTheFuture - givenPaymentMethodTranchedStream - whenPaymentAssetNotNativeToken - { - // Make Eve the caller in this test suite as she's the owner of the {Space} contract - vm.startPrank({ msgSender: users.eve }); - - // Create a new invoice with a tranched stream payment - invoice = createInvoiceWithTranchedStream({ recurrence: Types.Recurrence.Weekly }); - - // Create the calldata for the Invoice Module execution - bytes memory data = abi.encodeWithSignature( - "createInvoice((uint8,uint40,uint40,(uint8,uint8,uint40,address,uint128,uint256)))", invoice - ); - - // Expect the module call to emit an {InvoiceCreated} event - vm.expectEmit(); - emit Events.InvoiceCreated({ - id: 1, - recipient: address(space), - status: Types.Status.Pending, - startTime: invoice.startTime, - endTime: invoice.endTime, - payment: invoice.payment - }); - - // Expect the {Space} contract to emit a {ModuleExecutionSucceded} event - vm.expectEmit(); - emit Events.ModuleExecutionSucceded({ module: address(invoiceModule), value: 0, data: data }); - - // Run the test - space.execute({ module: address(invoiceModule), value: 0, data: data }); - - // Assert the actual and expected invoice state - Types.Invoice memory actualInvoice = invoiceModule.getInvoice({ id: 1 }); - address expectedRecipient = invoiceModule.ownerOf(1); - - assertEq(expectedRecipient, address(space)); - assertEq(uint8(actualInvoice.status), uint8(Types.Status.Pending)); - assertEq(actualInvoice.startTime, invoice.startTime); - assertEq(actualInvoice.endTime, invoice.endTime); - assertEq(uint8(actualInvoice.payment.method), uint8(Types.Method.TranchedStream)); - assertEq(uint8(actualInvoice.payment.recurrence), uint8(Types.Recurrence.Weekly)); - assertEq(actualInvoice.payment.paymentsLeft, 0); - assertEq(actualInvoice.payment.asset, invoice.payment.asset); - assertEq(actualInvoice.payment.amount, invoice.payment.amount); - assertEq(actualInvoice.payment.streamId, 0); - } - - function test_RevertWhen_PaymentMethodLinearStream_PaymentAssetNativeToken() - external - whenCallerContract - whenCompliantSpace - whenNonZeroPaymentAmount - whenStartTimeLowerThanEndTime - whenEndTimeInTheFuture - givenPaymentMethodLinearStream - { - // Make Eve the caller in this test suite as she's the owner of the {Space} contract - vm.startPrank({ msgSender: users.eve }); - - // Create a new invoice with a linear stream payment - invoice = createInvoiceWithLinearStream(); - - // Alter the payment asset by setting it to - invoice.payment.asset = address(0); - - // Expect the call to revert with the {OnlyERC20StreamsAllowed} error - vm.expectRevert(Errors.OnlyERC20StreamsAllowed.selector); - - // Create the calldata for the Invoice Module execution - bytes memory data = abi.encodeWithSignature( - "createInvoice((uint8,uint40,uint40,(uint8,uint8,uint40,address,uint128,uint256)))", invoice - ); - - // Run the test - space.execute({ module: address(invoiceModule), value: 0, data: data }); - } - - function test_CreateInvoice_LinearStream() - external - whenCallerContract - whenCompliantSpace - whenNonZeroPaymentAmount - whenStartTimeLowerThanEndTime - whenEndTimeInTheFuture - givenPaymentMethodLinearStream - whenPaymentAssetNotNativeToken - { - // Make Eve the caller in this test suite as she's the owner of the {Space} contract - vm.startPrank({ msgSender: users.eve }); - - // Create a new invoice with a linear stream payment - invoice = createInvoiceWithLinearStream(); - - // Create the calldata for the Invoice Module execution - bytes memory data = abi.encodeWithSignature( - "createInvoice((uint8,uint40,uint40,(uint8,uint8,uint40,address,uint128,uint256)))", invoice - ); - - // Expect the module call to emit an {InvoiceCreated} event - vm.expectEmit(); - emit Events.InvoiceCreated({ - id: 1, - recipient: address(space), - status: Types.Status.Pending, - startTime: invoice.startTime, - endTime: invoice.endTime, - payment: invoice.payment - }); - - // Expect the {Space} contract to emit a {ModuleExecutionSucceded} event - vm.expectEmit(); - emit Events.ModuleExecutionSucceded({ module: address(invoiceModule), value: 0, data: data }); - - // Run the test - space.execute({ module: address(invoiceModule), value: 0, data: data }); - - // Assert the actual and expected invoice state - Types.Invoice memory actualInvoice = invoiceModule.getInvoice({ id: 1 }); - address expectedRecipient = invoiceModule.ownerOf(1); - - assertEq(expectedRecipient, address(space)); - assertEq(uint8(actualInvoice.status), uint8(Types.Status.Pending)); - assertEq(actualInvoice.startTime, invoice.startTime); - assertEq(actualInvoice.endTime, invoice.endTime); - assertEq(uint8(actualInvoice.payment.method), uint8(Types.Method.LinearStream)); - assertEq(uint8(actualInvoice.payment.recurrence), uint8(Types.Recurrence.Weekly)); - assertEq(actualInvoice.payment.asset, invoice.payment.asset); - assertEq(actualInvoice.payment.amount, invoice.payment.amount); - assertEq(actualInvoice.payment.streamId, 0); - } -} diff --git a/test/integration/concrete/invoice-module/pay-invoice/payInvoice.t.sol b/test/integration/concrete/invoice-module/pay-invoice/payInvoice.t.sol deleted file mode 100644 index 5fb2473..0000000 --- a/test/integration/concrete/invoice-module/pay-invoice/payInvoice.t.sol +++ /dev/null @@ -1,327 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.26; - -import { PayInvoice_Integration_Shared_Test } from "../../../shared/payInvoice.t.sol"; -import { Types } from "./../../../../../src/modules/invoice-module/libraries/Types.sol"; -import { Events } from "../../../../utils/Events.sol"; -import { Errors } from "../../../../utils/Errors.sol"; - -import { LockupLinear, LockupTranched } from "@sablier/v2-core/src/types/DataTypes.sol"; - -contract PayInvoice_Integration_Concret_Test is PayInvoice_Integration_Shared_Test { - function setUp() public virtual override { - PayInvoice_Integration_Shared_Test.setUp(); - } - - function test_RevertWhen_InvoiceNull() external { - // Expect the call to revert with the {ERC721NonexistentToken} error - vm.expectRevert(abi.encodeWithSelector(Errors.ERC721NonexistentToken.selector, 99)); - - // Run the test - invoiceModule.payInvoice({ id: 99 }); - } - - function test_RevertWhen_InvoiceAlreadyPaid() external whenInvoiceNotNull { - // Set the one-off USDT transfer invoice as current one - uint256 invoiceId = 1; - - // Make Bob the payer for the default invoice - vm.startPrank({ msgSender: users.bob }); - - // Approve the {InvoiceModule} to transfer the ERC-20 token on Bob's behalf - usdt.approve({ spender: address(invoiceModule), amount: invoices[invoiceId].payment.amount }); - - // Pay first the invoice - invoiceModule.payInvoice({ id: invoiceId }); - - // Expect the call to be reverted with the {InvoiceAlreadyPaid} error - vm.expectRevert(Errors.InvoiceAlreadyPaid.selector); - - // Run the test - invoiceModule.payInvoice({ id: invoiceId }); - } - - function test_RevertWhen_InvoiceCanceled() external whenInvoiceNotNull whenInvoiceNotAlreadyPaid { - // Set the one-off USDT transfer invoice as current one - uint256 invoiceId = 1; - - // Make Eve's space the caller in this test suite as his space is the owner of the invoice - vm.startPrank({ msgSender: address(space) }); - - // Cancel the invoice first - invoiceModule.cancelInvoice({ id: invoiceId }); - - // Make Bob the payer of this invoice - vm.startPrank({ msgSender: users.bob }); - - // Expect the call to be reverted with the {InvoiceCanceled} error - vm.expectRevert(Errors.InvoiceCanceled.selector); - - // Run the test - invoiceModule.payInvoice({ id: invoiceId }); - } - - function test_RevertWhen_PaymentMethodTransfer_PaymentAmountLessThanInvoiceValue() - external - whenInvoiceNotNull - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodTransfer - givenPaymentAmountInNativeToken - { - // Set the one-off ETH transfer invoice as current one - uint256 invoiceId = 2; - - // Make Bob the payer for the default invoice - vm.startPrank({ msgSender: users.bob }); - - // Expect the call to be reverted with the {PaymentAmountLessThanInvoiceValue} error - vm.expectRevert( - abi.encodeWithSelector( - Errors.PaymentAmountLessThanInvoiceValue.selector, invoices[invoiceId].payment.amount - ) - ); - - // Run the test - invoiceModule.payInvoice{ value: invoices[invoiceId].payment.amount - 1 }({ id: invoiceId }); - } - - function test_RevertWhen_PaymentMethodTransfer_NativeTokenTransferFails() - external - whenInvoiceNotNull - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodTransfer - givenPaymentAmountInNativeToken - whenPaymentAmountEqualToInvoiceValue - { - // Create a mock invoice with a one-off ETH transfer from the Eve's space - Types.Invoice memory invoice = createInvoiceWithOneOffTransfer({ asset: address(0) }); - executeCreateInvoice({ invoice: invoice, user: users.eve }); - - uint256 invoiceId = _nextInvoiceId; - - // Make Eve's space the caller for the next call to approve & transfer the invoice NFT to a bad receiver - vm.startPrank({ msgSender: address(space) }); - - // Approve the {InvoiceModule} to transfer the token - invoiceModule.approve({ to: address(invoiceModule), tokenId: invoiceId }); - - // Transfer the invoice to a bad receiver so we can test against `NativeTokenPaymentFailed` - invoiceModule.transferFrom({ from: address(space), to: address(mockBadReceiver), tokenId: invoiceId }); - - // Make Bob the payer for this invoice - vm.startPrank({ msgSender: users.bob }); - - // Expect the call to be reverted with the {NativeTokenPaymentFailed} error - vm.expectRevert(Errors.NativeTokenPaymentFailed.selector); - - // Run the test - invoiceModule.payInvoice{ value: invoice.payment.amount }({ id: invoiceId }); - } - - function test_PayInvoice_PaymentMethodTransfer_NativeToken_OneOff() - external - whenInvoiceNotNull - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodTransfer - givenPaymentAmountInNativeToken - whenPaymentAmountEqualToInvoiceValue - whenNativeTokenPaymentSucceeds - { - // Set the one-off ETH transfer invoice as current one - uint256 invoiceId = 2; - - // Make Bob the payer for the default invoice - vm.startPrank({ msgSender: users.bob }); - - // Store the ETH balances of Bob and recipient before paying the invoice - uint256 balanceOfBobBefore = address(users.bob).balance; - uint256 balanceOfRecipientBefore = address(space).balance; - - // Expect the {InvoicePaid} event to be emitted - vm.expectEmit(); - emit Events.InvoicePaid({ - id: invoiceId, - payer: users.bob, - status: Types.Status.Paid, - payment: Types.Payment({ - method: invoices[invoiceId].payment.method, - recurrence: invoices[invoiceId].payment.recurrence, - paymentsLeft: 0, - asset: invoices[invoiceId].payment.asset, - amount: invoices[invoiceId].payment.amount, - streamId: 0 - }) - }); - - // Run the test - invoiceModule.payInvoice{ value: invoices[invoiceId].payment.amount }({ id: invoiceId }); - - // Assert the actual and the expected state of the invoice - Types.Invoice memory invoice = invoiceModule.getInvoice({ id: invoiceId }); - assertEq(uint8(invoice.status), uint8(Types.Status.Paid)); - assertEq(invoice.payment.paymentsLeft, 0); - - // Assert the balances of payer and recipient - assertEq(address(users.bob).balance, balanceOfBobBefore - invoices[invoiceId].payment.amount); - assertEq(address(space).balance, balanceOfRecipientBefore + invoices[invoiceId].payment.amount); - } - - function test_PayInvoice_PaymentMethodTransfer_ERC20Token_Recurring() - external - whenInvoiceNotNull - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodTransfer - givenPaymentAmountInERC20Tokens - whenPaymentAmountEqualToInvoiceValue - { - // Set the recurring USDT transfer invoice as current one - uint256 invoiceId = 3; - - // Make Bob the payer for the default invoice - vm.startPrank({ msgSender: users.bob }); - - // Store the USDT balances of Bob and recipient before paying the invoice - uint256 balanceOfBobBefore = usdt.balanceOf(users.bob); - uint256 balanceOfRecipientBefore = usdt.balanceOf(address(space)); - - // Approve the {InvoiceModule} to transfer the ERC-20 tokens on Bob's behalf - usdt.approve({ spender: address(invoiceModule), amount: invoices[invoiceId].payment.amount }); - - // Expect the {InvoicePaid} event to be emitted - vm.expectEmit(); - emit Events.InvoicePaid({ - id: invoiceId, - payer: users.bob, - status: Types.Status.Ongoing, - payment: Types.Payment({ - method: invoices[invoiceId].payment.method, - recurrence: invoices[invoiceId].payment.recurrence, - paymentsLeft: 3, - asset: invoices[invoiceId].payment.asset, - amount: invoices[invoiceId].payment.amount, - streamId: 0 - }) - }); - - // Run the test - invoiceModule.payInvoice{ value: invoices[invoiceId].payment.amount }({ id: invoiceId }); - - // Assert the actual and the expected state of the invoice - Types.Invoice memory invoice = invoiceModule.getInvoice({ id: invoiceId }); - assertEq(uint8(invoice.status), uint8(Types.Status.Ongoing)); - assertEq(invoice.payment.paymentsLeft, 3); - - // Assert the balances of payer and recipient - assertEq(usdt.balanceOf(users.bob), balanceOfBobBefore - invoices[invoiceId].payment.amount); - assertEq(usdt.balanceOf(address(space)), balanceOfRecipientBefore + invoices[invoiceId].payment.amount); - } - - function test_PayInvoice_PaymentMethodLinearStream() - external - whenInvoiceNotNull - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodLinearStream - givenPaymentAmountInERC20Tokens - whenPaymentAmountEqualToInvoiceValue - { - // Set the linear USDT stream-based invoice as current one - uint256 invoiceId = 4; - - // Make Bob the payer for the default invoice - vm.startPrank({ msgSender: users.bob }); - - // Approve the {InvoiceModule} to transfer the ERC-20 tokens on Bob's behalf - usdt.approve({ spender: address(invoiceModule), amount: invoices[invoiceId].payment.amount }); - - // Expect the {InvoicePaid} event to be emitted - vm.expectEmit(); - emit Events.InvoicePaid({ - id: invoiceId, - payer: users.bob, - status: Types.Status.Ongoing, - payment: Types.Payment({ - method: invoices[invoiceId].payment.method, - recurrence: invoices[invoiceId].payment.recurrence, - paymentsLeft: 0, - asset: invoices[invoiceId].payment.asset, - amount: invoices[invoiceId].payment.amount, - streamId: 1 - }) - }); - - // Run the test - invoiceModule.payInvoice{ value: invoices[invoiceId].payment.amount }({ id: invoiceId }); - - // Assert the actual and the expected state of the invoice - Types.Invoice memory invoice = invoiceModule.getInvoice({ id: invoiceId }); - assertEq(uint8(invoice.status), uint8(Types.Status.Ongoing)); - assertEq(invoice.payment.streamId, 1); - assertEq(invoice.payment.paymentsLeft, 0); - - // Assert the actual and the expected state of the Sablier v2 linear stream - LockupLinear.StreamLL memory stream = invoiceModule.getLinearStream({ streamId: 1 }); - assertEq(stream.sender, address(invoiceModule)); - assertEq(stream.recipient, address(space)); - assertEq(address(stream.asset), address(usdt)); - assertEq(stream.startTime, invoice.startTime); - assertEq(stream.endTime, invoice.endTime); - } - - function test_PayInvoice_PaymentMethodTranchedStream() - external - whenInvoiceNotNull - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodTranchedStream - givenPaymentAmountInERC20Tokens - whenPaymentAmountEqualToInvoiceValue - { - // Set the tranched USDT stream-based invoice as current one - uint256 invoiceId = 5; - - // Make Bob the payer for the default invoice - vm.startPrank({ msgSender: users.bob }); - - // Approve the {InvoiceModule} to transfer the ERC-20 tokens on Bob's behalf - usdt.approve({ spender: address(invoiceModule), amount: invoices[invoiceId].payment.amount }); - - // Expect the {InvoicePaid} event to be emitted - vm.expectEmit(); - emit Events.InvoicePaid({ - id: invoiceId, - payer: users.bob, - status: Types.Status.Ongoing, - payment: Types.Payment({ - method: invoices[invoiceId].payment.method, - recurrence: invoices[invoiceId].payment.recurrence, - paymentsLeft: 0, - asset: invoices[invoiceId].payment.asset, - amount: invoices[invoiceId].payment.amount, - streamId: 1 - }) - }); - - // Run the test - invoiceModule.payInvoice{ value: invoices[invoiceId].payment.amount }({ id: invoiceId }); - - // Assert the actual and the expected state of the invoice - Types.Invoice memory invoice = invoiceModule.getInvoice({ id: invoiceId }); - assertEq(uint8(invoice.status), uint8(Types.Status.Ongoing)); - assertEq(invoice.payment.streamId, 1); - assertEq(invoice.payment.paymentsLeft, 0); - - // Assert the actual and the expected state of the Sablier v2 tranched stream - LockupTranched.StreamLT memory stream = invoiceModule.getTranchedStream({ streamId: 1 }); - assertEq(stream.sender, address(invoiceModule)); - assertEq(stream.recipient, address(space)); - assertEq(address(stream.asset), address(usdt)); - assertEq(stream.startTime, invoice.startTime); - assertEq(stream.endTime, invoice.endTime); - assertEq(stream.tranches.length, 4); - } -} diff --git a/test/integration/concrete/invoice-module/transfer-from/transferFrom.t.sol b/test/integration/concrete/invoice-module/transfer-from/transferFrom.t.sol deleted file mode 100644 index 8486d27..0000000 --- a/test/integration/concrete/invoice-module/transfer-from/transferFrom.t.sol +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.26; - -import { TransferFrom_Integration_Shared_Test } from "../../../shared/transferFrom.t.sol"; -import { Errors } from "../../../../utils/Errors.sol"; -import { Events } from "../../../../utils/Events.sol"; -import { Types } from "./../../../../../src/modules/invoice-module/libraries/Types.sol"; - -contract TransferFrom_Integration_Concret_Test is TransferFrom_Integration_Shared_Test { - function setUp() public virtual override { - TransferFrom_Integration_Shared_Test.setUp(); - } - - function test_RevertWhen_TokenDoesNotExist() external { - // Make Eve's space the caller which is the recipient of the invoice - vm.startPrank({ msgSender: address(space) }); - - // Expect the call to revert with the {ERC721NonexistentToken} error - vm.expectRevert(abi.encodeWithSelector(Errors.ERC721NonexistentToken.selector, 99)); - - // Run the test - invoiceModule.transferFrom({ from: address(space), to: users.eve, tokenId: 99 }); - } - - function test_TransferFrom_PaymentMethodStream() external whenTokenExists { - uint256 invoiceId = 4; - uint256 streamId = 1; - - // Make Bob the payer for the invoice - vm.startPrank({ msgSender: users.bob }); - - // Approve the {InvoiceModule} to transfer the USDT tokens on Bob's behalf - usdt.approve({ spender: address(invoiceModule), amount: invoices[invoiceId].payment.amount }); - - // Pay the invoice - invoiceModule.payInvoice{ value: invoices[invoiceId].payment.amount }({ id: invoiceId }); - - // Simulate the passage of time so that the maximum withdrawable amount is non-zero - vm.warp(block.timestamp + 5 weeks); - - // Store Eve's space balance before withdrawing the USDT tokens - uint256 balanceOfBefore = usdt.balanceOf(address(space)); - - // Get the maximum withdrawable amount from the stream before transferring the stream NFT - uint128 maxWithdrawableAmount = - invoiceModule.withdrawableAmountOf({ streamType: Types.Method.LinearStream, streamId: streamId }); - - // Make Eve's space the caller which is the recipient of the invoice - vm.startPrank({ msgSender: address(space) }); - - // Approve the {InvoiceModule} to transfer the `streamId` stream on behalf of the Eve's space - sablierV2LockupLinear.approve({ to: address(invoiceModule), tokenId: streamId }); - - // Run the test - invoiceModule.transferFrom({ from: address(space), to: users.eve, tokenId: invoiceId }); - - // Assert the current and expected Eve's space USDT balance - assertEq(balanceOfBefore + maxWithdrawableAmount, usdt.balanceOf(address(space))); - - // Assert the current and expected owner of the invoice NFT - assertEq(invoiceModule.ownerOf({ tokenId: invoiceId }), users.eve); - - // Assert the current and expected owner of the invoice stream NFT - assertEq(sablierV2LockupLinear.ownerOf({ tokenId: streamId }), users.eve); - } - - function test_TransferFrom_PaymentTransfer() external whenTokenExists { - uint256 invoiceId = 1; - - // Make Eve's space the caller which is the recipient of the invoice - vm.startPrank({ msgSender: address(space) }); - - // Run the test - invoiceModule.transferFrom({ from: address(space), to: users.eve, tokenId: invoiceId }); - - // Assert the current and expected owner of the invoice NFT - assertEq(invoiceModule.ownerOf({ tokenId: invoiceId }), users.eve); - } -} diff --git a/test/integration/concrete/invoice-module/transfer-from/transferFrom.tree b/test/integration/concrete/invoice-module/transfer-from/transferFrom.tree deleted file mode 100644 index 93f9779..0000000 --- a/test/integration/concrete/invoice-module/transfer-from/transferFrom.tree +++ /dev/null @@ -1,10 +0,0 @@ -transferFrom.t.sol -├── when the token does not exist -│ └── it should revert with the {ERC721NonexistentToken} error -└── when the token exist - ├── when the payment method is stream-based - │ ├── it should withdraw the maximum withdrawable amount of the Sablier stream - │ ├── it should transfer the Sablier stream NFT - │ └── it should transfer the invoice NFT - └── when the payment is transfer-based - └── it should transfer the invoice NFT \ No newline at end of file diff --git a/test/integration/concrete/invoice-module/withdraw-invoice-stream/withdrawStream.tree b/test/integration/concrete/invoice-module/withdraw-invoice-stream/withdrawStream.tree deleted file mode 100644 index e5dc7ad..0000000 --- a/test/integration/concrete/invoice-module/withdraw-invoice-stream/withdrawStream.tree +++ /dev/null @@ -1,7 +0,0 @@ -withdrawStream.t.sol -├── given the payment method is linear stream -│ └── given the invoice status is Ongoing -│ └── it should allow the invoice recipient to withdraw from the stream -└── given the payment method is tranched stream - └── given the invoice status is Ongoing - └── it should allow the invoice recipient to withdraw from the stream \ No newline at end of file diff --git a/test/integration/concrete/payment-module/cancel-request/cancelRequest.t.sol b/test/integration/concrete/payment-module/cancel-request/cancelRequest.t.sol new file mode 100644 index 0000000..986bc67 --- /dev/null +++ b/test/integration/concrete/payment-module/cancel-request/cancelRequest.t.sol @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import { CancelRequest_Integration_Shared_Test } from "../../../shared/cancelRequest.t.sol"; +import { Types } from "./../../../../../src/modules/payment-module/libraries/Types.sol"; +import { Events } from "../../../../utils/Events.sol"; +import { Errors } from "../../../../utils/Errors.sol"; + +contract CancelRequest_Integration_Concret_Test is CancelRequest_Integration_Shared_Test { + function setUp() public virtual override { + CancelRequest_Integration_Shared_Test.setUp(); + } + + function test_RevertWhen_PaymentIsPaid() external { + // Set the one-off ETH transfer payment request as current one + uint256 paymentRequestId = 2; + + // Make Bob the payer for the default paymentRequest + vm.startPrank({ msgSender: users.bob }); + + // Pay the payment request first + paymentModule.payRequest{ value: paymentRequests[paymentRequestId].config.amount }({ + requestId: paymentRequestId + }); + + // Make Eve the caller who is the recipient of the payment request + vm.startPrank({ msgSender: users.eve }); + + // Expect the call to revert with the {RequestPaid} error + vm.expectRevert(Errors.RequestPaid.selector); + + // Run the test + paymentModule.cancelRequest({ requestId: paymentRequestId }); + } + + function test_RevertWhen_RequestCanceled() external whenRequestNotAlreadyPaid { + // Set the one-off ETH transfer payment request as current one + uint256 paymentRequestId = 2; + + // Make Eve's space the caller which is the recipient of the payment request + vm.startPrank({ msgSender: address(space) }); + + // Cancel the payment request first + paymentModule.cancelRequest({ requestId: paymentRequestId }); + + // Expect the call to revert with the {RequestCanceled} error + vm.expectRevert(Errors.RequestCanceled.selector); + + // Run the test + paymentModule.cancelRequest({ requestId: paymentRequestId }); + } + + function test_RevertWhen_PaymentMethodTransfer_SenderNotPaymentRecipient() + external + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodTransfer + { + // Set the one-off ETH transfer payment request as current one + uint256 paymentRequestId = 2; + + // Make Bob the caller who IS NOT the recipient of the payment request + vm.startPrank({ msgSender: users.bob }); + + // Expect the call to revert with the {OnlyRequestRecipient} error + vm.expectRevert(Errors.OnlyRequestRecipient.selector); + + // Run the test + paymentModule.cancelRequest({ requestId: paymentRequestId }); + } + + function test_CancelRequest_PaymentMethodTransfer() + external + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodTransfer + whenRequestSenderRecipient + { + // Set the one-off ETH transfer payment request as current one + uint256 paymentRequestId = 2; + + // Make Eve's space the caller which is the recipient of the payment request + vm.startPrank({ msgSender: address(space) }); + + // Expect the {RequestCanceled} event to be emitted + vm.expectEmit(); + emit Events.RequestCanceled({ requestId: paymentRequestId }); + + // Run the test + paymentModule.cancelRequest({ requestId: paymentRequestId }); + + // Assert the actual and expected paymentRequest status + Types.Status paymentRequestStatus = paymentModule.statusOf({ requestId: paymentRequestId }); + assertEq(uint8(paymentRequestStatus), uint8(Types.Status.Canceled)); + } + + function test_RevertWhen_PaymentMethodLinearStream_StatusPending_SenderNotPaymentRecipient() + external + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodLinearStream + givenRequestStatusPending + { + // Set current paymentRequest as a linear stream-based one + uint256 paymentRequestId = 5; + + // Make Bob the caller who IS NOT the recipient of the payment request + vm.startPrank({ msgSender: users.bob }); + + // Expect the call to revert with the {OnlyRequestRecipient} error + vm.expectRevert(Errors.OnlyRequestRecipient.selector); + + // Run the test + paymentModule.cancelRequest({ requestId: paymentRequestId }); + } + + function test_CancelRequest_PaymentMethodLinearStream_StatusCanceled() + external + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodLinearStream + givenRequestStatusPending + whenRequestSenderRecipient + { + // Set current paymentRequest as a linear stream-based one + uint256 paymentRequestId = 5; + + // Make Eve's space the caller which is the recipient of the payment request + vm.startPrank({ msgSender: address(space) }); + + // Expect the {RequestCanceled} event to be emitted + vm.expectEmit(); + emit Events.RequestCanceled({ requestId: paymentRequestId }); + + // Run the test + paymentModule.cancelRequest({ requestId: paymentRequestId }); + + // Assert the actual and expected paymentRequest status + Types.Status paymentRequestStatus = paymentModule.statusOf({ requestId: paymentRequestId }); + assertEq(uint8(paymentRequestStatus), uint8(Types.Status.Canceled)); + } + + function test_RevertWhen_PaymentMethodLinearStream_StatusPending_SenderNoInitialtStreamSender() + external + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodLinearStream + givenRequestStatusPending + { + // Set current paymentRequest as a linear stream-based one + uint256 paymentRequestId = 5; + + // The payment request must be paid for its status to be updated to `Accepted` + // Make Bob the payer of the payment request (also Bob will be the stream sender) + vm.startPrank({ msgSender: users.bob }); + + // Approve the {PaymentModule} to transfer the USDT tokens on Bob's behalf + usdt.approve({ spender: address(paymentModule), amount: paymentRequests[paymentRequestId].config.amount }); + + // Pay the payment request first (status will be updated to `Accepted`) + paymentModule.payRequest{ value: paymentRequests[paymentRequestId].config.amount }({ + requestId: paymentRequestId + }); + + // Make Eve the caller who IS NOT the initial stream sender but rather the recipient + vm.startPrank({ msgSender: users.eve }); + + // Expect the call to revert with the {OnlyInitialStreamSender} error + vm.expectRevert(abi.encodeWithSelector(Errors.OnlyInitialStreamSender.selector, users.bob)); + + // Run the test + paymentModule.cancelRequest({ requestId: paymentRequestId }); + } + + function test_CancelRequest_PaymentMethodLinearStream_StatusPending() + external + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodLinearStream + givenRequestStatusPending + whenSenderInitialStreamSender + { + // Set current paymentRequest as a linear stream-based one + uint256 paymentRequestId = 5; + + // The payment request must be paid for its status to be updated to `Accepted` + // Make Bob the payer of the payment request (also Bob will be the initial stream sender) + vm.startPrank({ msgSender: users.bob }); + + // Approve the {PaymentModule} to transfer the USDT tokens on Bob's behalf + usdt.approve({ spender: address(paymentModule), amount: paymentRequests[paymentRequestId].config.amount }); + + // Pay the payment request first (status will be updated to `Accepted`) + paymentModule.payRequest{ value: paymentRequests[paymentRequestId].config.amount }({ + requestId: paymentRequestId + }); + + // Expect the {RequestCanceled} event to be emitted + vm.expectEmit(); + emit Events.RequestCanceled({ requestId: paymentRequestId }); + + // Make Bob the caller who is the sender of the payment request stream + vm.startPrank({ msgSender: users.bob }); + + // Run the test + paymentModule.cancelRequest({ requestId: paymentRequestId }); + + // Assert the actual and expected paymentRequest status + Types.Status paymentRequestStatus = paymentModule.statusOf({ requestId: paymentRequestId }); + assertEq(uint8(paymentRequestStatus), uint8(Types.Status.Canceled)); + } + + function test_RevertWhen_PaymentMethodTranchedStream_StatusPending_SenderNotPaymentRecipient() + external + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodTranchedStream + givenRequestStatusPending + { + // Set current paymentRequest as a tranched stream-based one + uint256 paymentRequestId = 5; + + // Make Bob the caller who IS NOT the recipient of the payment request + vm.startPrank({ msgSender: users.bob }); + + // Expect the call to revert with the {OnlyRequestRecipient} error + vm.expectRevert(Errors.OnlyRequestRecipient.selector); + + // Run the test + paymentModule.cancelRequest({ requestId: paymentRequestId }); + } + + function test_CancelRequest_PaymentMethodTranchedStream_StatusCanceled() + external + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodTranchedStream + givenRequestStatusPending + whenRequestSenderRecipient + { + // Set current paymentRequest as a tranched stream-based one + uint256 paymentRequestId = 5; + + // Make Eve's space the caller which is the recipient of the payment request + vm.startPrank({ msgSender: address(space) }); + + // Expect the {RequestCanceled} event to be emitted + vm.expectEmit(); + emit Events.RequestCanceled({ requestId: paymentRequestId }); + + // Run the test + paymentModule.cancelRequest({ requestId: paymentRequestId }); + + // Assert the actual and expected paymentRequest status + Types.Status paymentRequestStatus = paymentModule.statusOf({ requestId: paymentRequestId }); + assertEq(uint8(paymentRequestStatus), uint8(Types.Status.Canceled)); + } + + function test_RevertWhen_PaymentMethodTranchedStream_StatusPending_SenderNoInitialtStreamSender() + external + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodTranchedStream + givenRequestStatusPending + { + // Set current paymentRequest as a tranched stream-based one + uint256 paymentRequestId = 5; + + // The payment request must be paid for its status to be updated to `Accepted` + // Make Bob the payer of the payment request (also Bob will be the stream sender) + vm.startPrank({ msgSender: users.bob }); + + // Approve the {PaymentModule} to transfer the USDT tokens on Bob's behalf + usdt.approve({ spender: address(paymentModule), amount: paymentRequests[paymentRequestId].config.amount }); + + // Pay the payment request first (status will be updated to `Accepted`) + paymentModule.payRequest{ value: paymentRequests[paymentRequestId].config.amount }({ + requestId: paymentRequestId + }); + + // Make Eve the caller who IS NOT the initial stream sender but rather the recipient + vm.startPrank({ msgSender: users.eve }); + + // Expect the call to revert with the {OnlyInitialStreamSender} error + vm.expectRevert(abi.encodeWithSelector(Errors.OnlyInitialStreamSender.selector, users.bob)); + + // Run the test + paymentModule.cancelRequest({ requestId: paymentRequestId }); + } + + function test_CancelRequest_PaymentMethodTranchedStream_StatusPending() + external + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodTranchedStream + givenRequestStatusPending + whenSenderInitialStreamSender + { + // Set current paymentRequest as a tranched stream-based one + uint256 paymentRequestId = 5; + + // The payment request must be paid for its status to be updated to `Accepted` + // Make Bob the payer of the payment request (also Bob will be the initial stream sender) + vm.startPrank({ msgSender: users.bob }); + + // Approve the {PaymentModule} to transfer the USDT tokens on Bob's behalf + usdt.approve({ spender: address(paymentModule), amount: paymentRequests[paymentRequestId].config.amount }); + + // Pay the payment request first (status will be updated to `Accepted`) + paymentModule.payRequest{ value: paymentRequests[paymentRequestId].config.amount }({ + requestId: paymentRequestId + }); + + // Expect the {RequestCanceled} event to be emitted + vm.expectEmit(); + emit Events.RequestCanceled({ requestId: paymentRequestId }); + + // Run the test + paymentModule.cancelRequest({ requestId: paymentRequestId }); + + // Assert the actual and expected paymentRequest status + Types.Status paymentRequestStatus = paymentModule.statusOf({ requestId: paymentRequestId }); + assertEq(uint8(paymentRequestStatus), uint8(Types.Status.Canceled)); + } +} diff --git a/test/integration/concrete/payment-module/cancel-request/cancelRequest.tree b/test/integration/concrete/payment-module/cancel-request/cancelRequest.tree new file mode 100644 index 0000000..6e53955 --- /dev/null +++ b/test/integration/concrete/payment-module/cancel-request/cancelRequest.tree @@ -0,0 +1,40 @@ +cancelRequest.t.sol +├── when the request status IS Paid +│ └── it should revert with the {RequestPaid} error +└── when the request status IS NOT Paid + ├── when the request status IS Canceled + │ └── it should revert with the {RequestCanceled} error + └── when the request status IS NOT Canceled + ├── given the payment method is transfer + │ ├── when the sender IS NOT the request recipient + │ │ └── it should revert with the {OnlyRequestRecipient} + │ └── when the sender IS the request recipient + │ ├── it should mark the request as Canceled + │ └── it should emit a {RequestCanceled} event + ├── given the payment method is linear stream-based + │ ├── given the request status is Pending + │ │ ├── when the sender IS NOT the request recipient + │ │ │ └── it should revert with the {OnlyRequestRecipient} + │ │ └── when the sender IS the request recipient + │ │ ├── it should mark the request as Canceled + │ │ └── it should emit an {RequestCanceled} event + │ └── given the request status is Accepted + │ ├── when the sender IS NOT the initial stream sender + │ │ └── it should revert with the {OnlyInitialStreamSender} error + │ └── when the sender IS the initial stream sender + │ ├── it should mark the request as Canceled + │ └── it should emit an {RequestCanceled} event + └── given the payment method is tranched stream-based + ├── given the request status is Pending + │ ├── when the sender IS NOT the request recipient + │ │ └── it should revert with the {OnlyRequestRecipient} + │ └── when the sender IS the request recipient + │ ├── it should mark the request as Canceled + │ └── it should emit an {RequestCanceled} event + └── given the request status is Accepted + ├── when the sender IS NOT the initial stream sender + │ └──it should revert with the {OnlyInitialStreamSender} error + └── when the sender IS the initial stream sender + ├── it should mark the request as Canceled + └── it should emit an {RequestCanceled} event + diff --git a/test/integration/concrete/payment-module/create-request/createRequest.t.sol b/test/integration/concrete/payment-module/create-request/createRequest.t.sol new file mode 100644 index 0000000..83a8bbd --- /dev/null +++ b/test/integration/concrete/payment-module/create-request/createRequest.t.sol @@ -0,0 +1,519 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import { CreateRequest_Integration_Shared_Test } from "../../../shared/createRequest.t.sol"; +import { Types } from "./../../../../../src/modules/payment-module/libraries/Types.sol"; +import { Errors } from "../../../../utils/Errors.sol"; +import { Events } from "../../../../utils/Events.sol"; + +contract CreateRequest_Integration_Concret_Test is CreateRequest_Integration_Shared_Test { + Types.PaymentRequest paymentRequest; + + function setUp() public virtual override { + CreateRequest_Integration_Shared_Test.setUp(); + } + + function test_RevertWhen_CallerNotContract() external { + // Make Bob the caller in this test suite which is an EOA + vm.startPrank({ msgSender: users.bob }); + + // Expect the call to revert with the {SpaceZeroCodeSize} error + vm.expectRevert(Errors.SpaceZeroCodeSize.selector); + + // Create a one-off transfer payment request + paymentRequest = createPaymentRequestWithOneOffTransfer({ asset: address(usdt), recipient: users.bob }); + + // Run the test + paymentModule.createRequest(paymentRequest); + } + + function test_RevertWhen_NonCompliantSpace() external whenCallerContract { + // Make Eve the caller in this test suite as she's the owner of the {Space} contract + vm.startPrank({ msgSender: users.eve }); + + // Create a one-off transfer payment request + paymentRequest = createPaymentRequestWithOneOffTransfer({ asset: address(usdt), recipient: address(space) }); + + // Create the calldata for the Payment Module execution + bytes memory data = abi.encodeWithSignature( + "createRequest((bool,bool,uint40,uint40,address,(uint8,uint8,uint40,address,uint128,uint256)))", + paymentRequest + ); + + // Expect the call to revert with the {SpaceUnsupportedInterface} error + vm.expectRevert(Errors.SpaceUnsupportedInterface.selector); + + // Run the test + mockNonCompliantSpace.execute({ module: address(paymentModule), value: 0, data: data }); + } + + function test_RevertWhen_ZeroPaymentAmount() external whenCallerContract whenCompliantSpace { + // Make Eve the caller in this test suite as she's the owner of the {Space} contract + vm.startPrank({ msgSender: users.eve }); + + // Create a one-off transfer payment request + paymentRequest = createPaymentRequestWithOneOffTransfer({ asset: address(usdt), recipient: address(space) }); + + // Set the payment amount to zero to simulate the error + paymentRequest.config.amount = 0; + + // Create the calldata for the Payment Module execution + bytes memory data = abi.encodeWithSignature( + "createRequest((bool,bool,uint40,uint40,address,(uint8,uint8,uint40,address,uint128,uint256)))", + paymentRequest + ); + + // Expect the call to revert with the {ZeroPaymentAmount} error + vm.expectRevert(Errors.ZeroPaymentAmount.selector); + + // Run the test + space.execute({ module: address(paymentModule), value: 0, data: data }); + } + + function test_RevertWhen_StartTimeGreaterThanEndTime() + external + whenCallerContract + whenCompliantSpace + whenNonZeroPaymentAmount + { + // Make Eve the caller in this test suite as she's the owner of the {Space} contract + vm.startPrank({ msgSender: users.eve }); + + // Create a one-off transfer payment request + paymentRequest = createPaymentRequestWithOneOffTransfer({ asset: address(usdt), recipient: address(space) }); + + // Set the start time to be the current timestamp and the end time one second earlier + paymentRequest.startTime = uint40(block.timestamp); + paymentRequest.endTime = uint40(block.timestamp) - 1; + + // Create the calldata for the Payment Module execution + bytes memory data = abi.encodeWithSignature( + "createRequest((bool,bool,uint40,uint40,address,(uint8,uint8,uint40,address,uint128,uint256)))", + paymentRequest + ); + + // Expect the call to revert with the {StartTimeGreaterThanEndTime} error + vm.expectRevert(Errors.StartTimeGreaterThanEndTime.selector); + + // Run the test + space.execute({ module: address(paymentModule), value: 0, data: data }); + } + + function test_RevertWhen_EndTimeInThePast() + external + whenCallerContract + whenCompliantSpace + whenNonZeroPaymentAmount + whenStartTimeLowerThanEndTime + { + // Make Eve the caller in this test suite as she's the owner of the {Space} contract + vm.startPrank({ msgSender: users.eve }); + + // Create a one-off transfer payment request + paymentRequest = createPaymentRequestWithOneOffTransfer({ asset: address(usdt), recipient: address(space) }); + + // Set the block.timestamp to 1641070800 + vm.warp(1_641_070_800); + + // Set the start time to be the lower than the end time so the 'start time lower than end time' passes + // but set the end time in the past to get the {EndTimeInThePast} revert + paymentRequest.startTime = uint40(block.timestamp) - 2 days; + paymentRequest.endTime = uint40(block.timestamp) - 1 days; + + // Create the calldata for the Payment Module execution + bytes memory data = abi.encodeWithSignature( + "createRequest((bool,bool,uint40,uint40,address,(uint8,uint8,uint40,address,uint128,uint256)))", + paymentRequest + ); + + // Expect the call to revert with the {EndTimeInThePast} error + vm.expectRevert(Errors.EndTimeInThePast.selector); + + // Run the test + space.execute({ module: address(paymentModule), value: 0, data: data }); + } + + function test_CreateRequest_PaymentMethodOneOffTransfer() + external + whenCallerContract + whenCompliantSpace + whenNonZeroPaymentAmount + whenStartTimeLowerThanEndTime + whenEndTimeInTheFuture + givenPaymentMethodOneOffTransfer + { + // Make Eve the caller in this test suite as she's the owner of the {Space} contract + vm.startPrank({ msgSender: users.eve }); + + // Create a recurring transfer payment request that must be paid on a monthly basis + // Hence, the interval between the start and end time must be at least 1 month + paymentRequest = createPaymentRequestWithOneOffTransfer({ asset: address(usdt), recipient: address(space) }); + + // Create the calldata for the Payment Module execution + bytes memory data = abi.encodeWithSignature( + "createRequest((bool,bool,uint40,uint40,address,(uint8,uint8,uint40,address,uint128,uint256)))", + paymentRequest + ); + + // Expect the module call to emit an {RequestCreated} event + vm.expectEmit(); + emit Events.RequestCreated({ + requestId: 1, + recipient: address(space), + startTime: paymentRequest.startTime, + endTime: paymentRequest.endTime, + config: paymentRequest.config + }); + + // Expect the {Space} contract to emit a {ModuleExecutionSucceded} event + vm.expectEmit(); + emit Events.ModuleExecutionSucceded({ module: address(paymentModule), value: 0, data: data }); + + // Run the test + space.execute({ module: address(paymentModule), value: 0, data: data }); + + // Assert the actual and expected paymentRequest state + Types.PaymentRequest memory actualRequest = paymentModule.getRequest({ requestId: 1 }); + Types.Status paymentRequestStatus = paymentModule.statusOf({ requestId: 1 }); + + assertEq(actualRequest.recipient, address(space)); + assertEq(uint8(paymentRequestStatus), uint8(Types.Status.Pending)); + assertEq(actualRequest.startTime, paymentRequest.startTime); + assertEq(actualRequest.endTime, paymentRequest.endTime); + assertEq(uint8(actualRequest.config.method), uint8(Types.Method.Transfer)); + assertEq(uint8(actualRequest.config.recurrence), uint8(Types.Recurrence.OneOff)); + assertEq(actualRequest.config.paymentsLeft, 1); + assertEq(actualRequest.config.asset, paymentRequest.config.asset); + assertEq(actualRequest.config.amount, paymentRequest.config.amount); + assertEq(actualRequest.config.streamId, 0); + } + + function test_RevertWhen_PaymentMethodRecurringTransfer_PaymentIntervalTooShortForSelectedRecurrence() + external + whenCallerContract + whenCompliantSpace + whenNonZeroPaymentAmount + whenStartTimeLowerThanEndTime + whenEndTimeInTheFuture + givenPaymentMethodRecurringTransfer + { + // Make Eve the caller in this test suite as she's the owner of the {Space} contract + vm.startPrank({ msgSender: users.eve }); + + // Create a recurring transfer payment request that must be paid on a monthly basis + // Hence, the interval between the start and end time must be at least 1 month + paymentRequest = + createPaymentWithRecurringTransfer({ recurrence: Types.Recurrence.Monthly, recipient: address(space) }); + + // Alter the end time to be 3 weeks from now + paymentRequest.endTime = uint40(block.timestamp) + 3 weeks; + + // Create the calldata for the Payment Module execution + bytes memory data = abi.encodeWithSignature( + "createRequest((bool,bool,uint40,uint40,address,(uint8,uint8,uint40,address,uint128,uint256)))", + paymentRequest + ); + + // Expect the call to revert with the {PaymentIntervalTooShortForSelectedRecurrence} error + vm.expectRevert(Errors.PaymentIntervalTooShortForSelectedRecurrence.selector); + + // Run the test + space.execute({ module: address(paymentModule), value: 0, data: data }); + } + + function test_CreateRequest_RecurringTransfer() + external + whenCallerContract + whenCompliantSpace + whenNonZeroPaymentAmount + whenStartTimeLowerThanEndTime + whenEndTimeInTheFuture + givenPaymentMethodRecurringTransfer + whenPaymentIntervalLongEnough + { + // Make Eve the caller in this test suite as she's the owner of the {Space} contract + vm.startPrank({ msgSender: users.eve }); + + // Create a recurring transfer payment request that must be paid on weekly basis + paymentRequest = + createPaymentWithRecurringTransfer({ recurrence: Types.Recurrence.Weekly, recipient: address(space) }); + + // Create the calldata for the Payment Module execution + bytes memory data = abi.encodeWithSignature( + "createRequest((bool,bool,uint40,uint40,address,(uint8,uint8,uint40,address,uint128,uint256)))", + paymentRequest + ); + + // Expect the module call to emit an {RequestCreated} event + vm.expectEmit(); + emit Events.RequestCreated({ + requestId: 1, + recipient: address(space), + startTime: paymentRequest.startTime, + endTime: paymentRequest.endTime, + config: paymentRequest.config + }); + + // Expect the {Space} contract to emit a {ModuleExecutionSucceded} event + vm.expectEmit(); + emit Events.ModuleExecutionSucceded({ module: address(paymentModule), value: 0, data: data }); + + // Run the test + space.execute({ module: address(paymentModule), value: 0, data: data }); + + // Assert the actual and expected paymentRequest state + Types.PaymentRequest memory actualRequest = paymentModule.getRequest({ requestId: 1 }); + Types.Status paymentRequestStatus = paymentModule.statusOf({ requestId: 1 }); + + assertEq(actualRequest.recipient, address(space)); + assertEq(uint8(paymentRequestStatus), uint8(Types.Status.Pending)); + assertEq(actualRequest.startTime, paymentRequest.startTime); + assertEq(actualRequest.endTime, paymentRequest.endTime); + assertEq(uint8(actualRequest.config.method), uint8(Types.Method.Transfer)); + assertEq(uint8(actualRequest.config.recurrence), uint8(Types.Recurrence.Weekly)); + assertEq(actualRequest.config.paymentsLeft, 4); + assertEq(actualRequest.config.asset, paymentRequest.config.asset); + assertEq(actualRequest.config.amount, paymentRequest.config.amount); + assertEq(actualRequest.config.streamId, 0); + } + + function test_RevertWhen_PaymentMethodTranchedStream_RecurrenceSetToOneOff() + external + whenCallerContract + whenCompliantSpace + whenNonZeroPaymentAmount + whenStartTimeLowerThanEndTime + whenEndTimeInTheFuture + givenPaymentMethodTranchedStream + { + // Make Eve the caller in this test suite as she's the owner of the {Space} contract + vm.startPrank({ msgSender: users.eve }); + + // Create a new paymentRequest with a tranched stream payment + paymentRequest = + createPaymentRequestWithTranchedStream({ recurrence: Types.Recurrence.Weekly, recipient: address(space) }); + + // Alter the payment recurrence by setting it to one-off + paymentRequest.config.recurrence = Types.Recurrence.OneOff; + + // Expect the call to revert with the {TranchedStreamInvalidOneOffRecurence} error + vm.expectRevert(Errors.TranchedStreamInvalidOneOffRecurence.selector); + + // Create the calldata for the Payment Module execution + bytes memory data = abi.encodeWithSignature( + "createRequest((bool,bool,uint40,uint40,address,(uint8,uint8,uint40,address,uint128,uint256)))", + paymentRequest + ); + + // Run the test + space.execute({ module: address(paymentModule), value: 0, data: data }); + } + + function test_RevertWhen_PaymentMethodTranchedStream_PaymentIntervalTooShortForSelectedRecurrence() + external + whenCallerContract + whenCompliantSpace + whenNonZeroPaymentAmount + whenStartTimeLowerThanEndTime + whenEndTimeInTheFuture + givenPaymentMethodTranchedStream + whenTranchedStreamWithGoodRecurring + { + // Make Eve the caller in this test suite as she's the owner of the {Space} contract + vm.startPrank({ msgSender: users.eve }); + + // Create a new paymentRequest with a tranched stream payment + paymentRequest = + createPaymentRequestWithTranchedStream({ recurrence: Types.Recurrence.Monthly, recipient: address(space) }); + + // Alter the end time to be 3 weeks from now + paymentRequest.endTime = uint40(block.timestamp) + 3 weeks; + + // Expect the call to revert with the {PaymentIntervalTooShortForSelectedRecurrence} error + vm.expectRevert(Errors.PaymentIntervalTooShortForSelectedRecurrence.selector); + + // Create the calldata for the Payment Module execution + bytes memory data = abi.encodeWithSignature( + "createRequest((bool,bool,uint40,uint40,address,(uint8,uint8,uint40,address,uint128,uint256)))", + paymentRequest + ); + + // Run the test + space.execute({ module: address(paymentModule), value: 0, data: data }); + } + + function test_RevertWhen_PaymentMethodTranchedStream_PaymentAssetNativeToken() + external + whenCallerContract + whenCompliantSpace + whenNonZeroPaymentAmount + whenStartTimeLowerThanEndTime + whenEndTimeInTheFuture + givenPaymentMethodTranchedStream + whenTranchedStreamWithGoodRecurring + whenPaymentIntervalLongEnough + { + // Make Eve the caller in this test suite as she's the owner of the {Space} contract + vm.startPrank({ msgSender: users.eve }); + + // Create a new paymentRequest with a linear stream payment + paymentRequest = + createPaymentRequestWithTranchedStream({ recurrence: Types.Recurrence.Weekly, recipient: address(space) }); + + // Alter the payment asset by setting it to + paymentRequest.config.asset = address(0); + + // Expect the call to revert with the {OnlyERC20StreamsAllowed} error + vm.expectRevert(Errors.OnlyERC20StreamsAllowed.selector); + + // Create the calldata for the Payment Module execution + bytes memory data = abi.encodeWithSignature( + "createRequest((bool,bool,uint40,uint40,address,(uint8,uint8,uint40,address,uint128,uint256)))", + paymentRequest + ); + + // Run the test + space.execute({ module: address(paymentModule), value: 0, data: data }); + } + + function test_CreateRequest_Tranched() + external + whenCallerContract + whenCompliantSpace + whenNonZeroPaymentAmount + whenStartTimeLowerThanEndTime + whenEndTimeInTheFuture + givenPaymentMethodTranchedStream + whenPaymentAssetNotNativeToken + { + // Make Eve the caller in this test suite as she's the owner of the {Space} contract + vm.startPrank({ msgSender: users.eve }); + + // Create a new paymentRequest with a tranched stream payment + paymentRequest = + createPaymentRequestWithTranchedStream({ recurrence: Types.Recurrence.Weekly, recipient: address(space) }); + + // Create the calldata for the Payment Module execution + bytes memory data = abi.encodeWithSignature( + "createRequest((bool,bool,uint40,uint40,address,(uint8,uint8,uint40,address,uint128,uint256)))", + paymentRequest + ); + + // Expect the module call to emit an {RequestCreated} event + vm.expectEmit(); + emit Events.RequestCreated({ + requestId: 1, + recipient: address(space), + startTime: paymentRequest.startTime, + endTime: paymentRequest.endTime, + config: paymentRequest.config + }); + + // Expect the {Space} contract to emit a {ModuleExecutionSucceded} event + vm.expectEmit(); + emit Events.ModuleExecutionSucceded({ module: address(paymentModule), value: 0, data: data }); + + // Run the test + space.execute({ module: address(paymentModule), value: 0, data: data }); + + // Assert the actual and expected paymentRequest state + Types.PaymentRequest memory actualRequest = paymentModule.getRequest({ requestId: 1 }); + Types.Status paymentRequestStatus = paymentModule.statusOf({ requestId: 1 }); + + assertEq(actualRequest.recipient, address(space)); + assertEq(uint8(paymentRequestStatus), uint8(Types.Status.Pending)); + assertEq(actualRequest.startTime, paymentRequest.startTime); + assertEq(actualRequest.endTime, paymentRequest.endTime); + assertEq(uint8(actualRequest.config.method), uint8(Types.Method.TranchedStream)); + assertEq(uint8(actualRequest.config.recurrence), uint8(Types.Recurrence.Weekly)); + assertEq(actualRequest.config.paymentsLeft, 1); + assertEq(actualRequest.config.asset, paymentRequest.config.asset); + assertEq(actualRequest.config.amount, paymentRequest.config.amount); + assertEq(actualRequest.config.streamId, 0); + } + + function test_RevertWhen_PaymentMethodLinearStream_PaymentAssetNativeToken() + external + whenCallerContract + whenCompliantSpace + whenNonZeroPaymentAmount + whenStartTimeLowerThanEndTime + whenEndTimeInTheFuture + givenPaymentMethodLinearStream + { + // Make Eve the caller in this test suite as she's the owner of the {Space} contract + vm.startPrank({ msgSender: users.eve }); + + // Create a new paymentRequest with a linear stream payment + paymentRequest = createPaymentRequestWithLinearStream({ recipient: address(space) }); + + // Alter the payment asset by setting it to + paymentRequest.config.asset = address(0); + + // Expect the call to revert with the {OnlyERC20StreamsAllowed} error + vm.expectRevert(Errors.OnlyERC20StreamsAllowed.selector); + + // Create the calldata for the Payment Module execution + bytes memory data = abi.encodeWithSignature( + "createRequest((bool,bool,uint40,uint40,address,(uint8,uint8,uint40,address,uint128,uint256)))", + paymentRequest + ); + + // Run the test + space.execute({ module: address(paymentModule), value: 0, data: data }); + } + + function test_CreateRequest_LinearStream() + external + whenCallerContract + whenCompliantSpace + whenNonZeroPaymentAmount + whenStartTimeLowerThanEndTime + whenEndTimeInTheFuture + givenPaymentMethodLinearStream + whenPaymentAssetNotNativeToken + { + // Make Eve the caller in this test suite as she's the owner of the {Space} contract + vm.startPrank({ msgSender: users.eve }); + + // Create a new paymentRequest with a linear stream payment + paymentRequest = createPaymentRequestWithLinearStream({ recipient: address(space) }); + + // Create the calldata for the Payment Module execution + bytes memory data = abi.encodeWithSignature( + "createRequest((bool,bool,uint40,uint40,address,(uint8,uint8,uint40,address,uint128,uint256)))", + paymentRequest + ); + + // Expect the module call to emit an {RequestCreated} event + vm.expectEmit(); + emit Events.RequestCreated({ + requestId: 1, + recipient: address(space), + startTime: paymentRequest.startTime, + endTime: paymentRequest.endTime, + config: paymentRequest.config + }); + + // Expect the {Space} contract to emit a {ModuleExecutionSucceded} event + vm.expectEmit(); + emit Events.ModuleExecutionSucceded({ module: address(paymentModule), value: 0, data: data }); + + // Run the test + space.execute({ module: address(paymentModule), value: 0, data: data }); + + // Assert the actual and expected paymentRequest state + Types.PaymentRequest memory actualRequest = paymentModule.getRequest({ requestId: 1 }); + Types.Status paymentRequestStatus = paymentModule.statusOf({ requestId: 1 }); + + assertEq(actualRequest.recipient, address(space)); + assertEq(uint8(paymentRequestStatus), uint8(Types.Status.Pending)); + assertEq(actualRequest.startTime, paymentRequest.startTime); + assertEq(actualRequest.endTime, paymentRequest.endTime); + assertEq(uint8(actualRequest.config.method), uint8(Types.Method.LinearStream)); + assertEq(uint8(actualRequest.config.recurrence), uint8(Types.Recurrence.Weekly)); + assertEq(actualRequest.config.asset, paymentRequest.config.asset); + assertEq(actualRequest.config.amount, paymentRequest.config.amount); + assertEq(actualRequest.config.streamId, 0); + } +} diff --git a/test/integration/concrete/invoice-module/create-invoice/createInvoice.tree b/test/integration/concrete/payment-module/create-request/createRequest.tree similarity index 85% rename from test/integration/concrete/invoice-module/create-invoice/createInvoice.tree rename to test/integration/concrete/payment-module/create-request/createRequest.tree index 23a5184..1fe1007 100644 --- a/test/integration/concrete/invoice-module/create-invoice/createInvoice.tree +++ b/test/integration/concrete/payment-module/create-request/createRequest.tree @@ -1,4 +1,4 @@ -createInvoice.t.sol +createRequest.t.sol ├── when the caller IS NOT a contract │ └── it should revert with the {SpaceZeroCodeSize} error └── when the caller IS a contract @@ -15,14 +15,14 @@ createInvoice.t.sol │ └── it should revert with the {EndTimeInThePast} error └── when the end time IS NOT in the past ├── given the payment method is a regular transfer - │ ├── it should create the invoice - │ └── it should emit an {InvoiceCreated} event + │ ├── it should create the payment request + │ └── it should emit an {RequestCreated} event ├── given the payment method is a recurring transfer │ ├── when the payment interval is too short for the selected recurrence │ │ └── it should revert with the {PaymentIntervalTooShortForSelectedRecurrence} error │ └── when the payment interval is long enough for the selected recurrence - │ ├── it should create the invoice - │ └── it should emit an {InvoiceCreated} event + │ ├── it should create the payment request + │ └── it should emit an {RequestCreated} event ├── given the payment method is a tranched stream │ ├── when the recurrence IS set to one-off │ │ └── it should revert with the {TranchedStreamInvalidOneOffRecurence} error @@ -33,11 +33,11 @@ createInvoice.t.sol │ ├── when the payment asset IS the native token │ │ └── it should revert with the {OnlyERC20StreamsAllowed} error │ └── when the payment asset IS NOT the native token - │ ├── it should create the invoice - │ └── it should emit an {InvoiceCreated} event + │ ├── it should create the payment request + │ └── it should emit an {RequestCreated} event └── given the payment method is a linear stream ├── when the payment asset IS the native token │ └── it should revert with the {OnlyERC20StreamsAllowed} error └── when the payment asset IS NOT the native token - ├── it should create the invoice - └── it should emit an {InvoiceCreated} event + ├── it should create the payment request + └── it should emit an {RequestCreated} event diff --git a/test/integration/concrete/payment-module/pay-request/payRequest.t.sol b/test/integration/concrete/payment-module/pay-request/payRequest.t.sol new file mode 100644 index 0000000..c10e278 --- /dev/null +++ b/test/integration/concrete/payment-module/pay-request/payRequest.t.sol @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import { PayRequest_Integration_Shared_Test } from "../../../shared/payRequest.t.sol"; +import { Types } from "./../../../../../src/modules/payment-module/libraries/Types.sol"; +import { Events } from "../../../../utils/Events.sol"; +import { Errors } from "../../../../utils/Errors.sol"; + +import { LockupLinear, LockupTranched } from "@sablier/v2-core/src/types/DataTypes.sol"; + +contract PayPayment_Integration_Concret_Test is PayRequest_Integration_Shared_Test { + function setUp() public virtual override { + PayRequest_Integration_Shared_Test.setUp(); + } + + function test_RevertWhen_RequestNull() external { + // Expect the call to revert with the {NullRequest} error + vm.expectRevert(Errors.NullRequest.selector); + + // Run the test + paymentModule.payRequest({ requestId: 99 }); + } + + function test_RevertWhen_RequestAlreadyPaid() external whenRequestNotNull { + // Set the one-off USDT transfer payment request as current one + uint256 paymentRequestId = 1; + + // Make Bob the payer for the default paymentRequest + vm.startPrank({ msgSender: users.bob }); + + // Approve the {PaymentModule} to transfer the ERC-20 token on Bob's behalf + usdt.approve({ spender: address(paymentModule), amount: paymentRequests[paymentRequestId].config.amount }); + + // Pay first the payment request + paymentModule.payRequest({ requestId: paymentRequestId }); + + // Expect the call to be reverted with the {RequestPaid} error + vm.expectRevert(Errors.RequestPaid.selector); + + // Run the test + paymentModule.payRequest({ requestId: paymentRequestId }); + } + + function test_RevertWhen_RequestCanceled() external whenRequestNotNull whenRequestNotAlreadyPaid { + // Set the one-off USDT transfer payment request as current one + uint256 paymentRequestId = 1; + + // Make Eve's space the caller in this test suite as his space is the owner of the payment request + vm.startPrank({ msgSender: address(space) }); + + // Cancel the payment request first + paymentModule.cancelRequest({ requestId: paymentRequestId }); + + // Make Bob the payer of this paymentRequest + vm.startPrank({ msgSender: users.bob }); + + // Expect the call to be reverted with the {RequestCanceled} error + vm.expectRevert(Errors.RequestCanceled.selector); + + // Run the test + paymentModule.payRequest({ requestId: paymentRequestId }); + } + + function test_RevertWhen_PaymentMethodTransfer_PaymentAmountLessThanRequestedAmount() + external + whenRequestNotNull + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodTransfer + givenPaymentAmountInNativeToken + { + // Set the one-off ETH transfer payment request as current one + uint256 paymentRequestId = 2; + + // Make Bob the payer for the default paymentRequest + vm.startPrank({ msgSender: users.bob }); + + // Expect the call to be reverted with the {PaymentAmountLessThanRequestedAmount} error + vm.expectRevert( + abi.encodeWithSelector( + Errors.PaymentAmountLessThanRequestedAmount.selector, paymentRequests[paymentRequestId].config.amount + ) + ); + + // Run the test + paymentModule.payRequest{ value: paymentRequests[paymentRequestId].config.amount - 1 }({ + requestId: paymentRequestId + }); + } + + function test_RevertWhen_PaymentMethodTransfer_NativeTokenTransferFails() + external + whenRequestNotNull + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodTransfer + givenPaymentAmountInNativeToken + whenPaymentAmountEqualToPaymentValue + { + // Create a mock payment request with a one-off ETH transfer from the Eve's space + Types.PaymentRequest memory paymentRequest = + createPaymentRequestWithOneOffTransfer({ asset: address(0), recipient: address(mockBadReceiver) }); + executeCreatePaymentRequest({ paymentRequest: paymentRequest, user: users.eve }); + + uint256 paymentRequestId = _nextRequestId; + + // Make Eve's space the caller for the next call to approve & transfer the payment request NFT to a bad receiver + //vm.startPrank({ msgSender: address(space) }); + + // Approve the {PaymentModule} to transfer the token + //paymentModule.approve({ to: address(paymentModule), tokenrequestId: paymentRequestId }); + + // Transfer the payment request to a bad receiver so we can test against `NativeTokenPaymentFailed` + //paymentModule.transferFrom({ from: address(space), to: address(mockBadReceiver), tokenrequestId: paymentRequestId }); + + // Make Bob the payer for this paymentRequest + vm.startPrank({ msgSender: users.bob }); + + // Expect the call to be reverted with the {NativeTokenPaymentFailed} error + vm.expectRevert(Errors.NativeTokenPaymentFailed.selector); + + // Run the test + paymentModule.payRequest{ value: paymentRequest.config.amount }({ requestId: paymentRequestId }); + } + + function test_PayRequest_PaymentMethodTransfer_NativeToken_OneOff() + external + whenRequestNotNull + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodTransfer + givenPaymentAmountInNativeToken + whenPaymentAmountEqualToPaymentValue + whenNativeTokenPaymentSucceeds + { + // Set the one-off ETH transfer payment request as current one + uint256 paymentRequestId = 2; + + // Make Bob the payer for the default paymentRequest + vm.startPrank({ msgSender: users.bob }); + + // Store the ETH balances of Bob and recipient before paying the payment request + uint256 balanceOfBobBefore = address(users.bob).balance; + uint256 balanceOfRecipientBefore = address(space).balance; + + // Expect the {RequestPaid} event to be emitted + vm.expectEmit(); + emit Events.RequestPaid({ + requestId: paymentRequestId, + payer: users.bob, + config: Types.Config({ + method: paymentRequests[paymentRequestId].config.method, + recurrence: paymentRequests[paymentRequestId].config.recurrence, + paymentsLeft: 0, + asset: paymentRequests[paymentRequestId].config.asset, + amount: paymentRequests[paymentRequestId].config.amount, + streamId: 0 + }) + }); + + // Run the test + paymentModule.payRequest{ value: paymentRequests[paymentRequestId].config.amount }({ + requestId: paymentRequestId + }); + + // Assert the actual and the expected state of the payment request + Types.PaymentRequest memory paymentRequest = paymentModule.getRequest({ requestId: paymentRequestId }); + Types.Status paymentRequestStatus = paymentModule.statusOf({ requestId: paymentRequestId }); + + assertEq(uint8(paymentRequestStatus), uint8(Types.Status.Paid)); + assertEq(paymentRequest.config.paymentsLeft, 0); + + // Assert the balances of payer and recipient + assertEq(address(users.bob).balance, balanceOfBobBefore - paymentRequests[paymentRequestId].config.amount); + assertEq(address(space).balance, balanceOfRecipientBefore + paymentRequests[paymentRequestId].config.amount); + } + + function test_PayRequest_PaymentMethodTransfer_ERC20Token_Recurring() + external + whenRequestNotNull + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodTransfer + givenPaymentAmountInERC20Tokens + whenPaymentAmountEqualToPaymentValue + { + // Set the recurring USDT transfer payment request as current one + uint256 paymentRequestId = 3; + + // Make Bob the payer for the default paymentRequest + vm.startPrank({ msgSender: users.bob }); + + // Store the USDT balances of Bob and recipient before paying the payment request + uint256 balanceOfBobBefore = usdt.balanceOf(users.bob); + uint256 balanceOfRecipientBefore = usdt.balanceOf(address(space)); + + // Approve the {PaymentModule} to transfer the ERC-20 tokens on Bob's behalf + usdt.approve({ spender: address(paymentModule), amount: paymentRequests[paymentRequestId].config.amount }); + + // Expect the {RequestPaid} event to be emitted + vm.expectEmit(); + emit Events.RequestPaid({ + requestId: paymentRequestId, + payer: users.bob, + config: Types.Config({ + method: paymentRequests[paymentRequestId].config.method, + recurrence: paymentRequests[paymentRequestId].config.recurrence, + paymentsLeft: 3, + asset: paymentRequests[paymentRequestId].config.asset, + amount: paymentRequests[paymentRequestId].config.amount, + streamId: 0 + }) + }); + + // Run the test + paymentModule.payRequest{ value: paymentRequests[paymentRequestId].config.amount }({ + requestId: paymentRequestId + }); + + // Assert the actual and the expected state of the payment request + Types.PaymentRequest memory paymentRequest = paymentModule.getRequest({ requestId: paymentRequestId }); + Types.Status paymentRequestStatus = paymentModule.statusOf({ requestId: paymentRequestId }); + + assertEq(uint8(paymentRequestStatus), uint8(Types.Status.Accepted)); + assertEq(paymentRequest.config.paymentsLeft, 3); + + // Assert the balances of payer and recipient + assertEq(usdt.balanceOf(users.bob), balanceOfBobBefore - paymentRequests[paymentRequestId].config.amount); + assertEq( + usdt.balanceOf(address(space)), balanceOfRecipientBefore + paymentRequests[paymentRequestId].config.amount + ); + } + + function test_PayRequest_PaymentMethodLinearStream() + external + whenRequestNotNull + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodLinearStream + givenPaymentAmountInERC20Tokens + whenPaymentAmountEqualToPaymentValue + { + // Set the linear USDT stream-based paymentRequest as current one + uint256 paymentRequestId = 4; + + // Make Bob the payer for the default paymentRequest + vm.startPrank({ msgSender: users.bob }); + + // Approve the {PaymentModule} to transfer the ERC-20 tokens on Bob's behalf + usdt.approve({ spender: address(paymentModule), amount: paymentRequests[paymentRequestId].config.amount }); + + // Expect the {RequestPaid} event to be emitted + vm.expectEmit(); + emit Events.RequestPaid({ + requestId: paymentRequestId, + payer: users.bob, + config: Types.Config({ + method: paymentRequests[paymentRequestId].config.method, + recurrence: paymentRequests[paymentRequestId].config.recurrence, + paymentsLeft: 0, + asset: paymentRequests[paymentRequestId].config.asset, + amount: paymentRequests[paymentRequestId].config.amount, + streamId: 1 + }) + }); + + // Run the test + paymentModule.payRequest{ value: paymentRequests[paymentRequestId].config.amount }({ + requestId: paymentRequestId + }); + + // Assert the actual and the expected state of the payment request + Types.PaymentRequest memory paymentRequest = paymentModule.getRequest({ requestId: paymentRequestId }); + Types.Status paymentRequestStatus = paymentModule.statusOf({ requestId: paymentRequestId }); + + assertEq(uint8(paymentRequestStatus), uint8(Types.Status.Accepted)); + assertEq(paymentRequest.config.streamId, 1); + assertEq(paymentRequest.config.paymentsLeft, 0); + + // Assert the actual and the expected state of the Sablier v2 linear stream + LockupLinear.StreamLL memory stream = paymentModule.getLinearStream({ streamId: 1 }); + assertEq(stream.sender, address(paymentModule)); + assertEq(stream.recipient, address(space)); + assertEq(address(stream.asset), address(usdt)); + assertEq(stream.startTime, paymentRequest.startTime); + assertEq(stream.endTime, paymentRequest.endTime); + } + + function test_PayRequest_PaymentMethodTranchedStream() + external + whenRequestNotNull + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodTranchedStream + givenPaymentAmountInERC20Tokens + whenPaymentAmountEqualToPaymentValue + { + // Set the tranched USDT stream-based paymentRequest as current one + uint256 paymentRequestId = 5; + + // Make Bob the payer for the default paymentRequest + vm.startPrank({ msgSender: users.bob }); + + // Approve the {PaymentModule} to transfer the ERC-20 tokens on Bob's behalf + usdt.approve({ spender: address(paymentModule), amount: paymentRequests[paymentRequestId].config.amount }); + + // Expect the {RequestPaid} event to be emitted + vm.expectEmit(); + emit Events.RequestPaid({ + requestId: paymentRequestId, + payer: users.bob, + config: Types.Config({ + method: paymentRequests[paymentRequestId].config.method, + recurrence: paymentRequests[paymentRequestId].config.recurrence, + paymentsLeft: 0, + asset: paymentRequests[paymentRequestId].config.asset, + amount: paymentRequests[paymentRequestId].config.amount, + streamId: 1 + }) + }); + + // Run the test + paymentModule.payRequest{ value: paymentRequests[paymentRequestId].config.amount }({ + requestId: paymentRequestId + }); + + // Assert the actual and the expected state of the payment request + Types.PaymentRequest memory paymentRequest = paymentModule.getRequest({ requestId: paymentRequestId }); + Types.Status paymentRequestStatus = paymentModule.statusOf({ requestId: paymentRequestId }); + + assertEq(uint8(paymentRequestStatus), uint8(Types.Status.Accepted)); + assertEq(paymentRequest.config.streamId, 1); + assertEq(paymentRequest.config.paymentsLeft, 0); + + // Assert the actual and the expected state of the Sablier v2 tranched stream + LockupTranched.StreamLT memory stream = paymentModule.getTranchedStream({ streamId: 1 }); + assertEq(stream.sender, address(paymentModule)); + assertEq(stream.recipient, address(space)); + assertEq(address(stream.asset), address(usdt)); + assertEq(stream.startTime, paymentRequest.startTime); + assertEq(stream.endTime, paymentRequest.endTime); + assertEq(stream.tranches.length, 4); + } +} diff --git a/test/integration/concrete/invoice-module/pay-invoice/payInvoice.tree b/test/integration/concrete/payment-module/pay-request/payRequest.tree similarity index 53% rename from test/integration/concrete/invoice-module/pay-invoice/payInvoice.tree rename to test/integration/concrete/payment-module/pay-request/payRequest.tree index 3369cad..40c7ccc 100644 --- a/test/integration/concrete/invoice-module/pay-invoice/payInvoice.tree +++ b/test/integration/concrete/payment-module/pay-request/payRequest.tree @@ -1,40 +1,40 @@ -payInvoice.t.sol -├── when the invoice IS null (there is no ERC-721 token minted) -│ └── it should revert with the {ERC721NonexistentToken} error -└── when the invoice IS NOT null - ├── when the invoice IS already paid - │ └── it should revert with the {InvoiceAlreadyPaid} error - └── when the invoice IS NOT already paid - ├── when the invoice IS canceled - │ └── it should revert with the {InvoiceCanceled} error - └── when the invoice IS NOT canceled +payRequest.t.sol +├── when the payment request IS null +│ └── it should revert with the {NullRequest} error +└── when the payment request IS NOT null + ├── when the payment request IS already paid + │ └── it should revert with the {RequestPaid} error + └── when the payment request IS NOT already paid + ├── when the payment request IS canceled + │ └── it should revert with the {RequestCanceled} error + └── when the payment request IS NOT canceled ├── given the payment method is transfer │ ├── given the payment amount is in native token (ETH) - │ │ ├── when the payment amount is less than the invoice value - │ │ │ └── it should revert with the {PaymentAmountLessThanInvoiceValue} error - │ │ └── when the payment amount IS equal to the invoice value + │ │ ├── when the payment amount is less than the payment request value + │ │ │ └── it should revert with the {PaymentAmountLessThanRequestedAmount} error + │ │ └── when the payment amount IS equal to the payment request value │ │ ├── when the native token transfer fails │ │ │ └── it should revert with the {NativeTokenPaymentFailed} error │ │ └── when the native token transfer succeeds │ │ ├── given the payment method is a one-off transfer - │ │ │ ├── it should update the invoice status to Paid + │ │ │ ├── it should update the payment request status to Paid │ │ │ └── it should decrease the number of payments to zero │ │ ├── given the payment method is a recurring transfer - │ │ │ ├── it should update the invoice status to Ongoing + │ │ │ ├── it should update the payment request status to Ongoing │ │ │ └── it should decrease the number of payments - │ │ ├── it should transfer the payment amount to the invoice recipient - │ │ └── it should emit an {InvoicePaid} event + │ │ ├── it should transfer the payment amount to the payment request recipient + │ │ └── it should emit an {RequestPaid} event │ └── given the payment amount is in an ERC-20 token - │ ├── it should transfer the payment amount to the invoice recipient - │ └── it should emit an {InvoicePaid} event + │ ├── it should transfer the payment amount to the payment request recipient + │ └── it should emit an {RequestPaid} event ├── given the payment method is linear stream │ ├── it should create a Sablier v2 linear stream - │ ├── it should update the invoice status to Ongoing - │ ├── it should update the invoice stream ID - │ └── it should emit an {InvoicePaid} event + │ ├── it should update the payment request status to Ongoing + │ ├── it should update the payment request stream ID + │ └── it should emit an {RequestPaid} event └── given the payment method is tranched stream ├── it should create a Sablier v2 tranched stream - ├── it should update the invoice status to Ongoing - ├── it should update the invoice stream ID - └── it should emit an {InvoicePaid} event + ├── it should update the payment request status to Ongoing + ├── it should update the payment request stream ID + └── it should emit an {RequestPaid} event diff --git a/test/integration/concrete/invoice-module/withdraw-invoice-stream/withdrawStream.t.sol b/test/integration/concrete/payment-module/withdraw-request-stream/withdrawRequestStream.t.sol similarity index 50% rename from test/integration/concrete/invoice-module/withdraw-invoice-stream/withdrawStream.t.sol rename to test/integration/concrete/payment-module/withdraw-request-stream/withdrawRequestStream.t.sol index 5002a08..bcdb443 100644 --- a/test/integration/concrete/invoice-module/withdraw-invoice-stream/withdrawStream.t.sol +++ b/test/integration/concrete/payment-module/withdraw-request-stream/withdrawRequestStream.t.sol @@ -2,27 +2,29 @@ pragma solidity ^0.8.26; import { WithdrawLinearStream_Integration_Shared_Test } from "../../../shared/withdrawLinearStream.t.sol"; -import { Types } from "./../../../../../src/modules/invoice-module/libraries/Types.sol"; +import { Types } from "./../../../../../src/modules/payment-module/libraries/Types.sol"; -contract WithdrawLinearStream_Integration_Concret_Test is WithdrawLinearStream_Integration_Shared_Test { +contract WithdrawRequestStream_Integration_Concret_Test is WithdrawLinearStream_Integration_Shared_Test { function setUp() public virtual override { WithdrawLinearStream_Integration_Shared_Test.setUp(); } - function test_WithdrawStream_LinearStream() external givenPaymentMethodLinearStream givenInvoiceStatusOngoing { - // Set current invoice as a linear stream-based one - uint256 invoiceId = 4; + function test_WithdrawStream_LinearStream() external givenPaymentMethodLinearStream givenRequestStatusPending { + // Set current paymentRequest as a linear stream-based one + uint256 paymentRequestId = 4; uint256 streamId = 1; - // The invoice must be paid in order to update its status to `Ongoing` - // Make Bob the payer of the invoice (also Bob will be the initial stream sender) + // The payment request must be paid in order to update its status to `Accepted` + // Make Bob the payer of the payment request (also Bob will be the initial stream sender) vm.startPrank({ msgSender: users.bob }); - // Approve the {InvoiceModule} to transfer the USDT tokens on Bob's behalf - usdt.approve({ spender: address(invoiceModule), amount: invoices[invoiceId].payment.amount }); + // Approve the {PaymentModule} to transfer the USDT tokens on Bob's behalf + usdt.approve({ spender: address(paymentModule), amount: paymentRequests[paymentRequestId].config.amount }); - // Pay the invoice first (status will be updated to `Ongoing`) - invoiceModule.payInvoice{ value: invoices[invoiceId].payment.amount }({ id: invoiceId }); + // Pay the payment request first (status will be updated to `Accepted`) + paymentModule.payRequest{ value: paymentRequests[paymentRequestId].config.amount }({ + requestId: paymentRequestId + }); // Advance the timestamp by 5 weeks to simulate the withdrawal vm.warp(block.timestamp + 5 weeks); @@ -32,32 +34,34 @@ contract WithdrawLinearStream_Integration_Concret_Test is WithdrawLinearStream_I // Get the maximum withdrawable amount from the stream uint128 maxWithdrawableAmount = - invoiceModule.withdrawableAmountOf({ streamType: Types.Method.LinearStream, streamId: streamId }); + paymentModule.withdrawableAmountOf({ streamType: Types.Method.LinearStream, streamId: streamId }); - // Make Eve's space the caller in this test suite as his space is the recipient of the invoice + // Make Eve's space the caller in this test suite as his space is the recipient of the payment request vm.startPrank({ msgSender: address(space) }); // Run the test - invoiceModule.withdrawInvoiceStream(invoiceId); + paymentModule.withdrawRequestStream(paymentRequestId); // Assert the current and expected USDT balance of Eve assertEq(balanceOfBefore + maxWithdrawableAmount, usdt.balanceOf(address(space))); } - function test_WithdrawStream_TranchedStream() external givenPaymentMethodTranchedStream givenInvoiceStatusOngoing { - // Set current invoice as a tranched stream-based one - uint256 invoiceId = 5; + function test_WithdrawStream_TranchedStream() external givenPaymentMethodTranchedStream givenRequestStatusPending { + // Set current paymentRequest as a tranched stream-based one + uint256 paymentRequestId = 5; uint256 streamId = 1; - // The invoice must be paid for its status to be updated to `Ongoing` - // Make Bob the payer of the invoice (also Bob will be the initial stream sender) + // The payment request must be paid for its status to be updated to `Accepted` + // Make Bob the payer of the payment request (also Bob will be the initial stream sender) vm.startPrank({ msgSender: users.bob }); - // Approve the {InvoiceModule} to transfer the USDT tokens on Bob's behalf - usdt.approve({ spender: address(invoiceModule), amount: invoices[invoiceId].payment.amount }); + // Approve the {PaymentModule} to transfer the USDT tokens on Bob's behalf + usdt.approve({ spender: address(paymentModule), amount: paymentRequests[paymentRequestId].config.amount }); - // Pay the invoice first (status will be updated to `Ongoing`) - invoiceModule.payInvoice{ value: invoices[invoiceId].payment.amount }({ id: invoiceId }); + // Pay the payment request first (status will be updated to `Accepted`) + paymentModule.payRequest{ value: paymentRequests[paymentRequestId].config.amount }({ + requestId: paymentRequestId + }); // Advance the timestamp by 5 weeks to simulate the withdrawal vm.warp(block.timestamp + 5 weeks); @@ -67,13 +71,13 @@ contract WithdrawLinearStream_Integration_Concret_Test is WithdrawLinearStream_I // Get the maximum withdrawable amount from the stream uint128 maxWithdrawableAmount = - invoiceModule.withdrawableAmountOf({ streamType: Types.Method.TranchedStream, streamId: streamId }); + paymentModule.withdrawableAmountOf({ streamType: Types.Method.TranchedStream, streamId: streamId }); - // Make Eve's space the caller in this test suite as her space is the owner of the invoice + // Make Eve's space the caller in this test suite as her space is the owner of the payment request vm.startPrank({ msgSender: address(space) }); // Run the test - invoiceModule.withdrawInvoiceStream(invoiceId); + paymentModule.withdrawRequestStream(paymentRequestId); // Assert the current and expected USDT balance of Eve's space assertEq(balanceOfBefore + maxWithdrawableAmount, usdt.balanceOf(address(space))); diff --git a/test/integration/concrete/payment-module/withdraw-request-stream/withdrawRequestStream.tree b/test/integration/concrete/payment-module/withdraw-request-stream/withdrawRequestStream.tree new file mode 100644 index 0000000..d80b2f7 --- /dev/null +++ b/test/integration/concrete/payment-module/withdraw-request-stream/withdrawRequestStream.tree @@ -0,0 +1,7 @@ +withdrawRequestStream.t.sol +├── given the payment method is linear stream +│ └── given the payment request status is Ongoing +│ └── it should allow the payment request recipient to withdraw from the stream +└── given the payment method is tranched stream + └── given the payment request status is Ongoing + └── it should allow the payment request recipient to withdraw from the stream \ No newline at end of file diff --git a/test/integration/concrete/stream-manager/update-stream-broker-fee/updateStreamBrokerFee.t.sol b/test/integration/concrete/stream-manager/update-stream-broker-fee/updateStreamBrokerFee.t.sol index 7ef1d67..776087d 100644 --- a/test/integration/concrete/stream-manager/update-stream-broker-fee/updateStreamBrokerFee.t.sol +++ b/test/integration/concrete/stream-manager/update-stream-broker-fee/updateStreamBrokerFee.t.sol @@ -2,13 +2,13 @@ pragma solidity ^0.8.26; import { Integration_Test } from "../../../Integration.t.sol"; -import { Types } from "./../../../../../src/modules/invoice-module/libraries/Types.sol"; +import { Types } from "./../../../../../src/modules/payment-module/libraries/Types.sol"; import { Errors } from "../../../../utils/Errors.sol"; import { Events } from "../../../../utils/Events.sol"; import { ud, UD60x18 } from "@prb/math/src/UD60x18.sol"; contract UpdateStreamBrokerFee_Integration_Concret_Test is Integration_Test { - Types.Invoice invoice; + Types.PaymentRequest paymentRequest; function setUp() public virtual override { Integration_Test.setUp(); diff --git a/test/integration/fuzz/createInvoice.t.sol b/test/integration/fuzz/createInvoice.t.sol deleted file mode 100644 index d569232..0000000 --- a/test/integration/fuzz/createInvoice.t.sol +++ /dev/null @@ -1,100 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.26; - -import { CreateInvoice_Integration_Shared_Test } from "../shared/createInvoice.t.sol"; -import { Types } from "./../../../src/modules/invoice-module/libraries/Types.sol"; -import { Helpers } from "../../utils/Helpers.sol"; -import { Events } from "../../utils/Events.sol"; - -contract CreateInvoice_Integration_Fuzz_Test is CreateInvoice_Integration_Shared_Test { - Types.Invoice invoice; - - function setUp() public virtual override { - CreateInvoice_Integration_Shared_Test.setUp(); - - // Make Eve the caller in this test suite as she's the owner of the {Space} contract - vm.startPrank({ msgSender: users.eve }); - } - - function testFuzz_CreateInvoice( - uint8 recurrence, - uint8 paymentMethod, - uint40 startTime, - uint40 endTime, - uint128 amount - ) - external - whenCallerContract - whenCompliantSpace - whenNonZeroPaymentAmount - whenStartTimeLowerThanEndTime - whenEndTimeInTheFuture - whenPaymentAssetNotNativeToken - { - // Discard bad fuzz inputs - // Assume recurrence is within Types.Recurrence enum values (OneOff, Weekly, Monthly, Yearly) (0, 1, 2, 3) - vm.assume(recurrence < 4); - // Assume recurrence is within Types.Method enum values (Transfer, LinearStream, TranchedStream) (0, 1, 2) - vm.assume(paymentMethod < 3); - vm.assume(startTime >= uint40(block.timestamp) && startTime < endTime); - vm.assume(amount > 0); - - // Calculate the number of payments if this is a transfer-based invoice - (bool valid, uint40 numberOfPayments) = - Helpers.checkFuzzedPaymentMethod(paymentMethod, recurrence, startTime, endTime); - if (!valid) return; - - // Create a new invoice with a transfer-based payment - invoice = Types.Invoice({ - status: Types.Status.Pending, - startTime: startTime, - endTime: endTime, - payment: Types.Payment({ - recurrence: Types.Recurrence(recurrence), - method: Types.Method(paymentMethod), - paymentsLeft: numberOfPayments, - amount: amount, - asset: address(usdt), - streamId: 0 - }) - }); - - // Create the calldata for the {InvoiceModule} execution - bytes memory data = abi.encodeWithSignature( - "createInvoice((uint8,uint40,uint40,(uint8,uint8,uint40,address,uint128,uint256)))", invoice - ); - - // Expect the module call to emit an {InvoiceCreated} event - vm.expectEmit(); - emit Events.InvoiceCreated({ - id: 1, - recipient: address(space), - status: Types.Status.Pending, - startTime: invoice.startTime, - endTime: invoice.endTime, - payment: invoice.payment - }); - - // Expect the {Space} contract to emit a {ModuleExecutionSucceded} event - vm.expectEmit(); - emit Events.ModuleExecutionSucceded({ module: address(invoiceModule), value: 0, data: data }); - - // Run the test - space.execute({ module: address(invoiceModule), value: 0, data: data }); - - // Assert the actual and expected invoice state - Types.Invoice memory actualInvoice = invoiceModule.getInvoice({ id: 1 }); - address actualRecipient = invoiceModule.ownerOf(1); - - assertEq(actualRecipient, address(space)); - assertEq(uint8(actualInvoice.status), uint8(Types.Status.Pending)); - assertEq(actualInvoice.startTime, invoice.startTime); - assertEq(actualInvoice.endTime, invoice.endTime); - assertEq(uint8(actualInvoice.payment.method), uint8(invoice.payment.method)); - assertEq(uint8(actualInvoice.payment.recurrence), uint8(invoice.payment.recurrence)); - assertEq(actualInvoice.payment.asset, invoice.payment.asset); - assertEq(actualInvoice.payment.amount, invoice.payment.amount); - assertEq(actualInvoice.payment.streamId, 0); - assertEq(actualInvoice.payment.paymentsLeft, invoice.payment.paymentsLeft); - } -} diff --git a/test/integration/fuzz/createRequest.t.sol b/test/integration/fuzz/createRequest.t.sol new file mode 100644 index 0000000..8872b7e --- /dev/null +++ b/test/integration/fuzz/createRequest.t.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import { CreateRequest_Integration_Shared_Test } from "../shared/createRequest.t.sol"; +import { Types } from "./../../../src/modules/payment-module/libraries/Types.sol"; +import { Helpers } from "../../utils/Helpers.sol"; +import { Events } from "../../utils/Events.sol"; + +contract CreateRequest_Integration_Fuzz_Test is CreateRequest_Integration_Shared_Test { + Types.PaymentRequest paymentRequest; + + function setUp() public virtual override { + CreateRequest_Integration_Shared_Test.setUp(); + + // Make Eve the caller in this test suite as she's the owner of the {Space} contract + vm.startPrank({ msgSender: users.eve }); + } + + function testFuzz_CreateRequest( + uint8 recurrence, + uint8 paymentMethod, + address recipient, + uint40 startTime, + uint40 endTime, + uint128 amount + ) + external + whenCallerContract + whenCompliantSpace + whenNonZeroPaymentAmount + whenStartTimeLowerThanEndTime + whenEndTimeInTheFuture + whenPaymentAssetNotNativeToken + { + // Discard bad fuzz inputs + // Assume recurrence is within Types.Recurrence enum values (OneOff, Weekly, Monthly, Yearly) (0, 1, 2, 3) + vm.assume(recurrence < 4); + // Assume recurrence is within Types.Method enum values (Transfer, LinearStream, TranchedStream) (0, 1, 2) + vm.assume(paymentMethod < 3); + vm.assume(recipient != address(0) && recipient != address(this)); + vm.assume(startTime >= uint40(block.timestamp) && startTime < endTime); + vm.assume(amount > 0); + + // Calculate the number of payments if this is a transfer-based payment request + (bool valid, uint40 numberOfPayments) = + Helpers.checkFuzzedPaymentMethod(paymentMethod, recurrence, startTime, endTime); + if (!valid) return; + + // Create a new payment request with a transfer-based payment + paymentRequest = Types.PaymentRequest({ + wasCanceled: false, + wasAccepted: false, + startTime: startTime, + endTime: endTime, + recipient: recipient, + config: Types.Config({ + recurrence: Types.Recurrence(recurrence), + method: Types.Method(paymentMethod), + paymentsLeft: numberOfPayments, + amount: amount, + asset: address(usdt), + streamId: 0 + }) + }); + + // Create the calldata for the {PaymentModule} execution + bytes memory data = abi.encodeWithSignature( + "createRequest((bool,bool,uint40,uint40,address,(uint8,uint8,uint40,address,uint128,uint256)))", + paymentRequest + ); + + // Expect the module call to emit an {RequestCreated} event + vm.expectEmit(); + emit Events.RequestCreated({ + requestId: 1, + recipient: paymentRequest.recipient, + startTime: paymentRequest.startTime, + endTime: paymentRequest.endTime, + config: paymentRequest.config + }); + + // Expect the {Space} contract to emit a {ModuleExecutionSucceded} event + vm.expectEmit(); + emit Events.ModuleExecutionSucceded({ module: address(paymentModule), value: 0, data: data }); + + // Run the test + space.execute({ module: address(paymentModule), value: 0, data: data }); + + // Assert the actual and expected paymentRequest state + Types.PaymentRequest memory actualRequest = paymentModule.getRequest({ requestId: 1 }); + Types.Status paymentRequestStatus = paymentModule.statusOf({ requestId: 1 }); + + assertEq(actualRequest.recipient, paymentRequest.recipient); + assertEq(uint8(paymentRequestStatus), uint8(Types.Status.Pending)); + assertEq(actualRequest.startTime, paymentRequest.startTime); + assertEq(actualRequest.endTime, paymentRequest.endTime); + assertEq(uint8(actualRequest.config.method), uint8(paymentRequest.config.method)); + assertEq(uint8(actualRequest.config.recurrence), uint8(paymentRequest.config.recurrence)); + assertEq(actualRequest.config.asset, paymentRequest.config.asset); + assertEq(actualRequest.config.amount, paymentRequest.config.amount); + assertEq(actualRequest.config.streamId, 0); + assertEq(actualRequest.config.paymentsLeft, paymentRequest.config.paymentsLeft); + } +} diff --git a/test/integration/fuzz/payInvoice.t.sol b/test/integration/fuzz/payInvoice.t.sol deleted file mode 100644 index 2b23124..0000000 --- a/test/integration/fuzz/payInvoice.t.sol +++ /dev/null @@ -1,125 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.26; - -import { PayInvoice_Integration_Shared_Test } from "../shared/payInvoice.t.sol"; -import { Types } from "./../../../src/modules/invoice-module/libraries/Types.sol"; -import { Events } from "../../utils/Events.sol"; -import { Helpers } from "../../utils/Helpers.sol"; - -contract PayInvoice_Integration_Fuzz_Test is PayInvoice_Integration_Shared_Test { - Types.Invoice invoice; - - function setUp() public virtual override { - PayInvoice_Integration_Shared_Test.setUp(); - } - - function testFuzz_PayInvoice( - uint8 recurrence, - uint8 paymentMethod, - uint40 startTime, - uint40 endTime, - uint128 amount - ) - external - whenInvoiceNotNull - whenInvoiceNotAlreadyPaid - whenInvoiceNotCanceled - givenPaymentMethodTransfer - givenPaymentAmountInNativeToken - whenPaymentAmountEqualToInvoiceValue - whenNativeTokenPaymentSucceeds - { - // Discard bad fuzz inputs - // Assume recurrence is within Types.Recurrence enum values (OneOff, Weekly, Monthly, Yearly) (0, 1, 2, 3) - vm.assume(recurrence < 4); - // Assume recurrence is within Types.Method enum values (Transfer, LinearStream, TranchedStream) (0, 1, 2) - vm.assume(paymentMethod < 3); - vm.assume(startTime >= uint40(block.timestamp) && startTime < endTime); - vm.assume(amount > 0); - - // Calculate the number of payments if this is a transfer-based invoice - (bool valid, uint40 numberOfPayments) = - Helpers.checkFuzzedPaymentMethod(paymentMethod, recurrence, startTime, endTime); - if (!valid) return; - - // Create a new invoice with the fuzzed payment method - invoice = Types.Invoice({ - status: Types.Status.Pending, - startTime: startTime, - endTime: endTime, - payment: Types.Payment({ - recurrence: Types.Recurrence(recurrence), - method: Types.Method(paymentMethod), - paymentsLeft: numberOfPayments, - amount: amount, - asset: address(usdt), - streamId: 0 - }) - }); - - // Create the calldata for the {InvoiceModule} execution - bytes memory data = abi.encodeWithSignature( - "createInvoice((uint8,uint40,uint40,(uint8,uint8,uint40,address,uint128,uint256)))", invoice - ); - - uint256 invoiceId = _nextInvoiceId; - - // Make Eve the caller to create the fuzzed invoice - vm.startPrank({ msgSender: users.eve }); - - // Create the fuzzed invoice - space.execute({ module: address(invoiceModule), value: 0, data: data }); - - // Mint enough USDT to the payer's address to be able to pay the invoice - deal({ token: address(usdt), to: users.bob, give: invoice.payment.amount }); - - // Make payer the caller to pay for the fuzzed invoice - vm.startPrank({ msgSender: users.bob }); - - // Approve the {InvoiceModule} to transfer the USDT tokens on payer's behalf - usdt.approve({ spender: address(invoiceModule), amount: invoice.payment.amount }); - - // Store the USDT balances of the payer and recipient before paying the invoice - uint256 balanceOfPayerBefore = usdt.balanceOf(users.bob); - uint256 balanceOfRecipientBefore = usdt.balanceOf(address(space)); - - uint256 streamId = paymentMethod == 0 ? 0 : 1; - numberOfPayments = numberOfPayments > 0 ? numberOfPayments - 1 : 0; - - Types.Status expectedInvoiceStatus = numberOfPayments == 0 && invoice.payment.method == Types.Method.Transfer - ? Types.Status.Paid - : Types.Status.Ongoing; - - // Expect the {InvoicePaid} event to be emitted - vm.expectEmit(); - emit Events.InvoicePaid({ - id: invoiceId, - payer: users.bob, - status: expectedInvoiceStatus, - payment: Types.Payment({ - method: invoice.payment.method, - recurrence: invoice.payment.recurrence, - paymentsLeft: numberOfPayments, - asset: invoice.payment.asset, - amount: invoice.payment.amount, - streamId: streamId - }) - }); - - // Run the test - invoiceModule.payInvoice({ id: invoiceId }); - - // Assert the actual and the expected state of the invoice - Types.Invoice memory actualInvoice = invoiceModule.getInvoice({ id: invoiceId }); - assertEq(uint8(actualInvoice.status), uint8(expectedInvoiceStatus)); - assertEq(actualInvoice.payment.paymentsLeft, numberOfPayments); - - // Assert the actual and expected balances of the payer and recipient - assertEq(usdt.balanceOf(users.bob), balanceOfPayerBefore - invoice.payment.amount); - if (invoice.payment.method == Types.Method.Transfer) { - assertEq(usdt.balanceOf(address(space)), balanceOfRecipientBefore + invoice.payment.amount); - } else { - assertEq(usdt.balanceOf(address(space)), balanceOfRecipientBefore); - } - } -} diff --git a/test/integration/fuzz/payRequest.t.sol b/test/integration/fuzz/payRequest.t.sol new file mode 100644 index 0000000..2e1652f --- /dev/null +++ b/test/integration/fuzz/payRequest.t.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import { PayRequest_Integration_Shared_Test } from "../shared/payRequest.t.sol"; +import { Types } from "./../../../src/modules/payment-module/libraries/Types.sol"; +import { Events } from "../../utils/Events.sol"; +import { Helpers } from "../../utils/Helpers.sol"; + +contract PayRequest_Integration_Fuzz_Test is PayRequest_Integration_Shared_Test { + Types.PaymentRequest paymentRequest; + + function setUp() public virtual override { + PayRequest_Integration_Shared_Test.setUp(); + } + + function testFuzz_PayRequest( + uint8 recurrence, + uint8 paymentMethod, + uint40 startTime, + uint40 endTime, + uint128 amount + ) + external + whenRequestNotNull + whenRequestNotAlreadyPaid + whenRequestNotCanceled + givenPaymentMethodTransfer + givenPaymentAmountInNativeToken + whenPaymentAmountEqualToPaymentValue + whenNativeTokenPaymentSucceeds + { + // Discard bad fuzz inputs + // Assume recurrence is within Types.Recurrence enum values (OneOff, Weekly, Monthly, Yearly) (0, 1, 2, 3) + vm.assume(recurrence < 4); + // Assume recurrence is within Types.Method enum values (Transfer, LinearStream, TranchedStream) (0, 1, 2) + vm.assume(paymentMethod < 3); + vm.assume(startTime >= uint40(block.timestamp) && startTime < endTime); + vm.assume(amount > 0); + + // Calculate the number of payments if this is a transfer-based payment request + (bool valid, uint40 expectedNumberOfPayments) = + Helpers.checkFuzzedPaymentMethod(paymentMethod, recurrence, startTime, endTime); + if (!valid) return; + + // Create a new payment request with the fuzzed payment method + paymentRequest = Types.PaymentRequest({ + wasCanceled: false, + wasAccepted: false, + startTime: startTime, + endTime: endTime, + recipient: address(space), + config: Types.Config({ + recurrence: Types.Recurrence(recurrence), + method: Types.Method(paymentMethod), + paymentsLeft: expectedNumberOfPayments, + amount: amount, + asset: address(usdt), + streamId: 0 + }) + }); + + // Create the calldata for the {PaymentModule} execution + bytes memory data = abi.encodeWithSignature( + "createRequest((bool,bool,uint40,uint40,address,(uint8,uint8,uint40,address,uint128,uint256)))", + paymentRequest + ); + + uint256 paymentRequestId = _nextRequestId; + + // Make Eve the caller to create the fuzzed paymentRequest + vm.startPrank({ msgSender: users.eve }); + + // Create the fuzzed paymentRequest + space.execute({ module: address(paymentModule), value: 0, data: data }); + + // Mint enough USDT to the payer's address to be able to pay the payment request + deal({ token: address(usdt), to: users.bob, give: paymentRequest.config.amount }); + + // Make payer the caller to pay for the fuzzed paymentRequest + vm.startPrank({ msgSender: users.bob }); + + // Approve the {PaymentModule} to transfer the USDT tokens on payer's behalf + usdt.approve({ spender: address(paymentModule), amount: paymentRequest.config.amount }); + + // Store the USDT balances of the payer and recipient before paying the payment request + uint256 balanceOfPayerBefore = usdt.balanceOf(users.bob); + uint256 balanceOfRecipientBefore = usdt.balanceOf(address(space)); + + uint256 streamId = paymentMethod == 0 ? 0 : 1; + uint40 expectedNumberOfPaymentsLeft = expectedNumberOfPayments > 0 ? expectedNumberOfPayments - 1 : 0; + + Types.Status expectedRequestStatus = expectedNumberOfPaymentsLeft == 0 + && paymentRequest.config.method == Types.Method.Transfer ? Types.Status.Paid : Types.Status.Accepted; + + // Expect the {RequestPaid} event to be emitted + vm.expectEmit(); + emit Events.RequestPaid({ + requestId: paymentRequestId, + payer: users.bob, + config: Types.Config({ + method: paymentRequest.config.method, + recurrence: paymentRequest.config.recurrence, + paymentsLeft: expectedNumberOfPaymentsLeft, + asset: paymentRequest.config.asset, + amount: paymentRequest.config.amount, + streamId: streamId + }) + }); + + // Run the test + paymentModule.payRequest({ requestId: paymentRequestId }); + + // Assert the actual and the expected state of the payment request + Types.PaymentRequest memory actualRequest = paymentModule.getRequest({ requestId: paymentRequestId }); + Types.Status actualRequestStatus = paymentModule.statusOf({ requestId: paymentRequestId }); + + assertEq(uint8(actualRequestStatus), uint8(expectedRequestStatus)); + assertEq(actualRequest.config.paymentsLeft, expectedNumberOfPaymentsLeft); + + // Assert the actual and expected balances of the payer and recipient + assertEq(usdt.balanceOf(users.bob), balanceOfPayerBefore - paymentRequest.config.amount); + if (paymentRequest.config.method == Types.Method.Transfer) { + assertEq(usdt.balanceOf(address(space)), balanceOfRecipientBefore + paymentRequest.config.amount); + } else { + assertEq(usdt.balanceOf(address(space)), balanceOfRecipientBefore); + } + } +} diff --git a/test/integration/shared/cancelInvoice.t.sol b/test/integration/shared/cancelInvoice.t.sol deleted file mode 100644 index 600bda2..0000000 --- a/test/integration/shared/cancelInvoice.t.sol +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.26; - -import { Integration_Test } from "../Integration.t.sol"; -import { PayInvoice_Integration_Shared_Test } from "./payInvoice.t.sol"; - -abstract contract CancelInvoice_Integration_Shared_Test is Integration_Test, PayInvoice_Integration_Shared_Test { - function setUp() public virtual override(Integration_Test, PayInvoice_Integration_Shared_Test) { - PayInvoice_Integration_Shared_Test.setUp(); - createMockInvoices(); - } - - modifier whenInvoiceStatusNotPaid() { - _; - } - - modifier whenInvoiceStatusNotCanceled() { - _; - } - - modifier whenSenderInvoiceRecipient() { - _; - } - - modifier givenInvoiceStatusPending() { - _; - } - - modifier givenInvoiceStatusOngoing() { - _; - } - - modifier whenSenderInitialStreamSender() { - _; - } -} diff --git a/test/integration/shared/cancelRequest.t.sol b/test/integration/shared/cancelRequest.t.sol new file mode 100644 index 0000000..458ef8e --- /dev/null +++ b/test/integration/shared/cancelRequest.t.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import { Integration_Test } from "../Integration.t.sol"; +import { PayRequest_Integration_Shared_Test } from "./payRequest.t.sol"; + +abstract contract CancelRequest_Integration_Shared_Test is Integration_Test, PayRequest_Integration_Shared_Test { + function setUp() public virtual override(Integration_Test, PayRequest_Integration_Shared_Test) { + PayRequest_Integration_Shared_Test.setUp(); + } + + modifier whenRequestSenderRecipient() { + _; + } + + modifier givenRequestStatusPending() { + _; + } + + modifier whenSenderInitialStreamSender() { + _; + } +} diff --git a/test/integration/shared/createInvoice.t.sol b/test/integration/shared/createInvoice.t.sol index db17468..a91bb2b 100644 --- a/test/integration/shared/createInvoice.t.sol +++ b/test/integration/shared/createInvoice.t.sol @@ -2,47 +2,12 @@ pragma solidity ^0.8.26; import { Integration_Test } from "../Integration.t.sol"; -import { Types } from "./../../../src/modules/invoice-module/libraries/Types.sol"; -import { Space } from "./../../../src/Space.sol"; -import { MockBadSpace } from "../../mocks/MockBadSpace.sol"; abstract contract CreateInvoice_Integration_Shared_Test is Integration_Test { - mapping(uint256 invoiceId => Types.Invoice) invoices; - uint256 public _nextInvoiceId; - function setUp() public virtual override { Integration_Test.setUp(); } - function createMockInvoices() internal { - // Create a mock invoice with a one-off USDT transfer - Types.Invoice memory invoice = createInvoiceWithOneOffTransfer({ asset: address(usdt) }); - invoices[1] = invoice; - executeCreateInvoice({ invoice: invoice, user: users.eve }); - - // Create a mock invoice with a one-off ETH transfer - invoice = createInvoiceWithOneOffTransfer({ asset: address(0) }); - invoices[2] = invoice; - executeCreateInvoice({ invoice: invoice, user: users.eve }); - - // Create a mock invoice with a recurring USDT transfer - invoice = createInvoiceWithRecurringTransfer({ recurrence: Types.Recurrence.Weekly }); - invoices[3] = invoice; - executeCreateInvoice({ invoice: invoice, user: users.eve }); - - // Create a mock invoice with a linear stream payment - invoice = createInvoiceWithLinearStream(); - invoices[4] = invoice; - executeCreateInvoice({ invoice: invoice, user: users.eve }); - - // Create a mock invoice with a tranched stream payment - invoice = createInvoiceWithTranchedStream({ recurrence: Types.Recurrence.Weekly }); - invoices[5] = invoice; - executeCreateInvoice({ invoice: invoice, user: users.eve }); - - _nextInvoiceId = 6; - } - modifier whenCallerContract() { _; } @@ -50,155 +15,4 @@ abstract contract CreateInvoice_Integration_Shared_Test is Integration_Test { modifier whenCompliantSpace() { _; } - - modifier whenNonZeroPaymentAmount() { - _; - } - - modifier whenStartTimeLowerThanEndTime() { - _; - } - - modifier whenEndTimeInTheFuture() { - _; - } - - modifier whenPaymentIntervalLongEnough() { - _; - } - - modifier whenTranchedStreamWithGoodRecurring() { - _; - } - - modifier whenPaymentAssetNotNativeToken() { - _; - } - - modifier givenPaymentMethodOneOffTransfer() { - _; - } - - modifier givenPaymentMethodRecurringTransfer() { - _; - } - - modifier givenPaymentMethodTranchedStream() { - _; - } - - modifier givenPaymentMethodLinearStream() { - _; - } - - /// @dev Creates an invoice with a one-off transfer payment - function createInvoiceWithOneOffTransfer(address asset) internal view returns (Types.Invoice memory invoice) { - invoice = _createInvoice(uint40(block.timestamp), uint40(block.timestamp) + 4 weeks); - - invoice.payment = Types.Payment({ - method: Types.Method.Transfer, - recurrence: Types.Recurrence.OneOff, - paymentsLeft: 1, - asset: asset, - amount: 100e18, - streamId: 0 - }); - } - - /// @dev Creates an invoice with a recurring transfer payment - function createInvoiceWithRecurringTransfer(Types.Recurrence recurrence) - internal - view - returns (Types.Invoice memory invoice) - { - invoice = _createInvoice(uint40(block.timestamp), uint40(block.timestamp) + 4 weeks); - - invoice.payment = Types.Payment({ - method: Types.Method.Transfer, - recurrence: recurrence, - paymentsLeft: 0, - asset: address(usdt), - amount: 100e18, - streamId: 0 - }); - } - - /// @dev Creates an invoice with a linear stream-based payment - function createInvoiceWithLinearStream() internal view returns (Types.Invoice memory invoice) { - invoice = _createInvoice(uint40(block.timestamp), uint40(block.timestamp) + 4 weeks); - - invoice.payment = Types.Payment({ - method: Types.Method.LinearStream, - recurrence: Types.Recurrence.Weekly, // doesn't matter - paymentsLeft: 0, - asset: address(usdt), - amount: 100e18, - streamId: 0 - }); - } - - /// @dev Creates an invoice with a tranched stream-based payment - function createInvoiceWithTranchedStream(Types.Recurrence recurrence) - internal - view - returns (Types.Invoice memory invoice) - { - invoice = _createInvoice(uint40(block.timestamp), uint40(block.timestamp) + 4 weeks); - - invoice.payment = Types.Payment({ - method: Types.Method.TranchedStream, - recurrence: recurrence, - paymentsLeft: 0, - asset: address(usdt), - amount: 100e18, - streamId: 0 - }); - } - - /// @dev Creates an invoice with fuzzed parameters - function createFuzzedInvoice( - Types.Method method, - Types.Recurrence recurrence, - uint40 startTime, - uint40 endTime, - uint128 amount - ) internal view returns (Types.Invoice memory invoice) { - invoice = _createInvoice(startTime, endTime); - - invoice.payment = Types.Payment({ - method: method, - recurrence: recurrence, - paymentsLeft: 0, - asset: address(usdt), - amount: amount, - streamId: 0 - }); - } - - function executeCreateInvoice(Types.Invoice memory invoice, address user) public { - // Make the `user` account the caller who must be the owner of the {Space} contract - vm.startPrank({ msgSender: user }); - - // Create the invoice - bytes memory data = abi.encodeWithSignature( - "createInvoice((uint8,uint40,uint40,(uint8,uint8,uint40,address,uint128,uint256)))", invoice - ); - - // Select the according {Space} of the user - - if (user == users.eve) { - Space(space).execute({ module: address(invoiceModule), value: 0, data: data }); - } else { - MockBadSpace(badSpace).execute({ module: address(invoiceModule), value: 0, data: data }); - } - - // Stop the active prank - vm.stopPrank(); - } - - function _createInvoice(uint40 startTime, uint40 endTime) internal pure returns (Types.Invoice memory invoice) { - invoice.status = Types.Status.Pending; - invoice.startTime = startTime; - invoice.endTime = endTime; - } } diff --git a/test/integration/shared/createRequest.t.sol b/test/integration/shared/createRequest.t.sol new file mode 100644 index 0000000..d3492aa --- /dev/null +++ b/test/integration/shared/createRequest.t.sol @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import { Integration_Test } from "../Integration.t.sol"; +import { Types } from "./../../../src/modules/payment-module/libraries/Types.sol"; +import { Space } from "./../../../src/Space.sol"; +import { MockBadSpace } from "../../mocks/MockBadSpace.sol"; + +abstract contract CreateRequest_Integration_Shared_Test is Integration_Test { + mapping(uint256 paymentRequestId => Types.PaymentRequest) paymentRequests; + uint256 public _nextRequestId; + + function setUp() public virtual override { + Integration_Test.setUp(); + } + + function createMockPaymentRequests() internal { + // Create a mock payment request with a one-off USDT transfer + Types.PaymentRequest memory paymentRequest = + createPaymentRequestWithOneOffTransfer({ asset: address(usdt), recipient: address(space) }); + paymentRequests[1] = paymentRequest; + executeCreatePaymentRequest({ paymentRequest: paymentRequest, user: users.eve }); + + // Create a mock payment request with a one-off ETH transfer + paymentRequest = createPaymentRequestWithOneOffTransfer({ asset: address(0), recipient: address(space) }); + paymentRequests[2] = paymentRequest; + executeCreatePaymentRequest({ paymentRequest: paymentRequest, user: users.eve }); + + // Create a mock payment request with a recurring USDT transfer + paymentRequest = + createPaymentWithRecurringTransfer({ recurrence: Types.Recurrence.Weekly, recipient: address(space) }); + paymentRequests[3] = paymentRequest; + executeCreatePaymentRequest({ paymentRequest: paymentRequest, user: users.eve }); + + // Create a mock payment request with a linear stream payment + paymentRequest = createPaymentRequestWithLinearStream({ recipient: address(space) }); + paymentRequests[4] = paymentRequest; + executeCreatePaymentRequest({ paymentRequest: paymentRequest, user: users.eve }); + + // Create a mock payment request with a tranched stream payment + paymentRequest = + createPaymentRequestWithTranchedStream({ recurrence: Types.Recurrence.Weekly, recipient: address(space) }); + paymentRequests[5] = paymentRequest; + executeCreatePaymentRequest({ paymentRequest: paymentRequest, user: users.eve }); + + _nextRequestId = 6; + } + + modifier whenCallerContract() { + _; + } + + modifier whenCompliantSpace() { + _; + } + + modifier whenNonZeroPaymentAmount() { + _; + } + + modifier whenStartTimeLowerThanEndTime() { + _; + } + + modifier whenEndTimeInTheFuture() { + _; + } + + modifier whenPaymentIntervalLongEnough() { + _; + } + + modifier whenTranchedStreamWithGoodRecurring() { + _; + } + + modifier whenPaymentAssetNotNativeToken() { + _; + } + + modifier givenPaymentMethodOneOffTransfer() { + _; + } + + modifier givenPaymentMethodRecurringTransfer() { + _; + } + + modifier givenPaymentMethodTranchedStream() { + _; + } + + modifier givenPaymentMethodLinearStream() { + _; + } + + /// @dev Creates a payment request with a one-off transfer payment + function createPaymentRequestWithOneOffTransfer( + address asset, + address recipient + ) + internal + view + returns (Types.PaymentRequest memory paymentRequest) + { + paymentRequest = + _createBasePaymentRequest(recipient, uint40(block.timestamp), uint40(block.timestamp) + 4 weeks); + + paymentRequest.config = Types.Config({ + method: Types.Method.Transfer, + recurrence: Types.Recurrence.OneOff, + paymentsLeft: 1, + asset: asset, + amount: 100e18, + streamId: 0 + }); + } + + /// @dev Creates a payment request with a recurring transfer payment + function createPaymentWithRecurringTransfer( + Types.Recurrence recurrence, + address recipient + ) + internal + view + returns (Types.PaymentRequest memory paymentRequest) + { + paymentRequest = + _createBasePaymentRequest(recipient, uint40(block.timestamp), uint40(block.timestamp) + 4 weeks); + + paymentRequest.config = Types.Config({ + method: Types.Method.Transfer, + recurrence: recurrence, + paymentsLeft: 0, + asset: address(usdt), + amount: 100e18, + streamId: 0 + }); + } + + /// @dev Creates a payment request with a linear stream-based payment + function createPaymentRequestWithLinearStream(address recipient) + internal + view + returns (Types.PaymentRequest memory paymentRequest) + { + paymentRequest = + _createBasePaymentRequest(recipient, uint40(block.timestamp), uint40(block.timestamp) + 4 weeks); + + paymentRequest.config = Types.Config({ + method: Types.Method.LinearStream, + recurrence: Types.Recurrence.Weekly, // doesn't matter + paymentsLeft: 0, + asset: address(usdt), + amount: 100e18, + streamId: 0 + }); + } + + /// @dev Creates a payment request with a tranched stream-based payment + function createPaymentRequestWithTranchedStream( + Types.Recurrence recurrence, + address recipient + ) + internal + view + returns (Types.PaymentRequest memory paymentRequest) + { + paymentRequest = + _createBasePaymentRequest(recipient, uint40(block.timestamp), uint40(block.timestamp) + 4 weeks); + + paymentRequest.config = Types.Config({ + method: Types.Method.TranchedStream, + recurrence: recurrence, + paymentsLeft: 0, + asset: address(usdt), + amount: 100e18, + streamId: 0 + }); + } + + function executeCreatePaymentRequest(Types.PaymentRequest memory paymentRequest, address user) public { + // Make the `user` account the caller who must be the owner of the {Space} contract + vm.startPrank({ msgSender: user }); + + // Create the payment request + bytes memory data = abi.encodeWithSignature( + "createRequest((bool,bool,uint40,uint40,address,(uint8,uint8,uint40,address,uint128,uint256)))", + paymentRequest + ); + + // Select the according {Space} of the user + if (user == users.eve) { + Space(space).execute({ module: address(paymentModule), value: 0, data: data }); + } else { + MockBadSpace(badSpace).execute({ module: address(paymentModule), value: 0, data: data }); + } + + // Stop the active prank + vm.stopPrank(); + } + + function _createBasePaymentRequest( + address recipient, + uint40 startTime, + uint40 endTime + ) + internal + pure + returns (Types.PaymentRequest memory paymentRequest) + { + paymentRequest.recipient = recipient; + paymentRequest.startTime = startTime; + paymentRequest.endTime = endTime; + } +} diff --git a/test/integration/shared/payInvoice.t.sol b/test/integration/shared/payRequest.t.sol similarity index 51% rename from test/integration/shared/payInvoice.t.sol rename to test/integration/shared/payRequest.t.sol index f13997d..1eab082 100644 --- a/test/integration/shared/payInvoice.t.sol +++ b/test/integration/shared/payRequest.t.sol @@ -2,23 +2,23 @@ pragma solidity ^0.8.26; import { Integration_Test } from "../Integration.t.sol"; -import { CreateInvoice_Integration_Shared_Test } from "./createInvoice.t.sol"; +import { CreateRequest_Integration_Shared_Test } from "./createRequest.t.sol"; -abstract contract PayInvoice_Integration_Shared_Test is Integration_Test, CreateInvoice_Integration_Shared_Test { - function setUp() public virtual override(Integration_Test, CreateInvoice_Integration_Shared_Test) { - CreateInvoice_Integration_Shared_Test.setUp(); - createMockInvoices(); +abstract contract PayRequest_Integration_Shared_Test is Integration_Test, CreateRequest_Integration_Shared_Test { + function setUp() public virtual override(Integration_Test, CreateRequest_Integration_Shared_Test) { + CreateRequest_Integration_Shared_Test.setUp(); + createMockPaymentRequests(); } - modifier whenInvoiceNotNull() { + modifier whenRequestNotNull() { _; } - modifier whenInvoiceNotAlreadyPaid() { + modifier whenRequestNotAlreadyPaid() { _; } - modifier whenInvoiceNotCanceled() { + modifier whenRequestNotCanceled() { _; } @@ -34,7 +34,7 @@ abstract contract PayInvoice_Integration_Shared_Test is Integration_Test, Create _; } - modifier whenPaymentAmountEqualToInvoiceValue() { + modifier whenPaymentAmountEqualToPaymentValue() { _; } diff --git a/test/integration/shared/transferFrom.t.sol b/test/integration/shared/transferFrom.t.sol index 16d2a05..8ca9819 100644 --- a/test/integration/shared/transferFrom.t.sol +++ b/test/integration/shared/transferFrom.t.sol @@ -2,11 +2,11 @@ pragma solidity ^0.8.26; import { Integration_Test } from "../Integration.t.sol"; -import { PayInvoice_Integration_Shared_Test } from "./payInvoice.t.sol"; +import { PayRequest_Integration_Shared_Test } from "./payRequest.t.sol"; -abstract contract TransferFrom_Integration_Shared_Test is Integration_Test, PayInvoice_Integration_Shared_Test { - function setUp() public virtual override(Integration_Test, PayInvoice_Integration_Shared_Test) { - PayInvoice_Integration_Shared_Test.setUp(); +abstract contract TransferFrom_Integration_Shared_Test is Integration_Test, PayRequest_Integration_Shared_Test { + function setUp() public virtual override(Integration_Test, PayRequest_Integration_Shared_Test) { + PayRequest_Integration_Shared_Test.setUp(); } modifier whenTokenExists() { diff --git a/test/integration/shared/withdrawLinearStream.t.sol b/test/integration/shared/withdrawLinearStream.t.sol index f78fa6d..a3b44da 100644 --- a/test/integration/shared/withdrawLinearStream.t.sol +++ b/test/integration/shared/withdrawLinearStream.t.sol @@ -2,18 +2,18 @@ pragma solidity ^0.8.26; import { Integration_Test } from "../Integration.t.sol"; -import { PayInvoice_Integration_Shared_Test } from "./payInvoice.t.sol"; +import { PayRequest_Integration_Shared_Test } from "./payRequest.t.sol"; abstract contract WithdrawLinearStream_Integration_Shared_Test is Integration_Test, - PayInvoice_Integration_Shared_Test + PayRequest_Integration_Shared_Test { - function setUp() public virtual override(Integration_Test, PayInvoice_Integration_Shared_Test) { - PayInvoice_Integration_Shared_Test.setUp(); - createMockInvoices(); + function setUp() public virtual override(Integration_Test, PayRequest_Integration_Shared_Test) { + PayRequest_Integration_Shared_Test.setUp(); + createMockPaymentRequests(); } - modifier givenInvoiceStatusOngoing() { + modifier givenRequestStatusPending() { _; } } diff --git a/test/integration/shared/withdrawTranchedStream.t.sol b/test/integration/shared/withdrawTranchedStream.t.sol index 7204670..b82b707 100644 --- a/test/integration/shared/withdrawTranchedStream.t.sol +++ b/test/integration/shared/withdrawTranchedStream.t.sol @@ -2,18 +2,18 @@ pragma solidity ^0.8.26; import { Integration_Test } from "../Integration.t.sol"; -import { PayInvoice_Integration_Shared_Test } from "./payInvoice.t.sol"; +import { PayRequest_Integration_Shared_Test } from "./payRequest.t.sol"; abstract contract WithdrawTranchedStream_Integration_Shared_Test is Integration_Test, - PayInvoice_Integration_Shared_Test + PayRequest_Integration_Shared_Test { - function setUp() public virtual override(Integration_Test, PayInvoice_Integration_Shared_Test) { - PayInvoice_Integration_Shared_Test.setUp(); - createMockInvoices(); + function setUp() public virtual override(Integration_Test, PayRequest_Integration_Shared_Test) { + PayRequest_Integration_Shared_Test.setUp(); + createMockPaymentRequests(); } - modifier givenInvoiceStatusOngoing() { + modifier givenRequestStatusPending() { _; } } diff --git a/test/mocks/MockBadSpace.sol b/test/mocks/MockBadSpace.sol index 34c1c70..c0d3973 100644 --- a/test/mocks/MockBadSpace.sol +++ b/test/mocks/MockBadSpace.sol @@ -37,7 +37,7 @@ contract MockBadSpace is ISpace, AccountCore, ERC1271, ModuleManager { //////////////////////////////////////////////////////////////////////////*/ /// @dev Initializes the address of the {Space} owner, {ModuleKeeper} and enables the initial module(s) - constructor(IEntryPoint _entrypoint, address _factory) AccountCore(_entrypoint, _factory) { } + constructor(IEntryPoint _entrypoint, address _factory) AccountCore(_entrypoint, _factory) {} /*////////////////////////////////////////////////////////////////////////// RECEIVE & FALLBACK @@ -145,9 +145,21 @@ contract MockBadSpace is ISpace, AccountCore, ERC1271, ModuleManager { // therefore the `onERC1155Received` hook must be implemented // - depending on the length of the `ids` array, we're using `safeBatchTransferFrom` or `safeTransferFrom` if (ids.length > 1) { - collection.safeBatchTransferFrom({ from: address(this), to: msg.sender, ids: ids, values: amounts, data: "" }); + collection.safeBatchTransferFrom({ + from: address(this), + to: msg.sender, + ids: ids, + values: amounts, + data: "" + }); } else { - collection.safeTransferFrom({ from: address(this), to: msg.sender, id: ids[0], value: amounts[0], data: "" }); + collection.safeTransferFrom({ + from: address(this), + to: msg.sender, + id: ids[0], + value: amounts[0], + data: "" + }); } // Log the successful ERC-1155 token withdrawal @@ -160,7 +172,7 @@ contract MockBadSpace is ISpace, AccountCore, ERC1271, ModuleManager { if (amount > address(this).balance) revert Errors.InsufficientNativeToWithdraw(); // Interactions: withdraw by transferring the amount to the sender - (bool success,) = msg.sender.call{ value: amount }(""); + (bool success, ) = msg.sender.call{ value: amount }(""); // Revert if the call failed if (!success) revert Errors.NativeWithdrawFailed(); @@ -185,10 +197,7 @@ contract MockBadSpace is ISpace, AccountCore, ERC1271, ModuleManager { //////////////////////////////////////////////////////////////////////////*/ /// @inheritdoc ERC1271 - function isValidSignature( - bytes32 _hash, - bytes memory _signature - ) public view override returns (bytes4 magicValue) { + function isValidSignature(bytes32 _hash, bytes memory _signature) public view override returns (bytes4 magicValue) { // Compute the hash of message the should be signed bytes32 targetDigest = getMessageHash(_hash); @@ -221,8 +230,11 @@ contract MockBadSpace is ISpace, AccountCore, ERC1271, ModuleManager { /// @inheritdoc IERC165 function supportsInterface(bytes4 interfaceId) public pure returns (bool) { - return interfaceId == type(ISpace).interfaceId || interfaceId == type(IERC1155Receiver).interfaceId - || interfaceId == type(IERC721Receiver).interfaceId || interfaceId == type(IERC165).interfaceId; + return + interfaceId == type(ISpace).interfaceId || + interfaceId == type(IERC1155Receiver).interfaceId || + interfaceId == type(IERC721Receiver).interfaceId || + interfaceId == type(IERC165).interfaceId; } /// @inheritdoc IERC721Receiver diff --git a/test/mocks/MockERC20NoReturn.sol b/test/mocks/MockERC20NoReturn.sol index 373f0f1..1216491 100644 --- a/test/mocks/MockERC20NoReturn.sol +++ b/test/mocks/MockERC20NoReturn.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.0; +pragma solidity ^0.8.22; /// @notice An implementation of ERC-20 standard forked from the OpenZeppelin v4 library that do not return a boolean upon calling {transferFrom} or {transfer} /// @dev Reference https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.0/contracts/token/ERC20/ERC20.sol @@ -158,7 +158,8 @@ contract MockERC20NoReturn { emit Transfer(sender, recipient, amount); } - /** @dev Creates `amount` tokens and assigns them to `account`, increasing + /** + * @dev Creates `amount` tokens and assigns them to `account`, increasing * the total supply. * * Emits a {Transfer} event with `from` set to the zero address. diff --git a/test/mocks/MockModule.sol b/test/mocks/MockModule.sol index 78e22cf..6fdab83 100644 --- a/test/mocks/MockModule.sol +++ b/test/mocks/MockModule.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.26; import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import { ISpace } from "./../../src/interfaces/ISpace.sol"; -import { Errors } from "./../../src/modules/invoice-module/libraries/Errors.sol"; +import { Errors } from "./../../src/modules/payment-module/libraries/Errors.sol"; /// @notice A mock implementation of a boilerplate module that creates multiple items and /// associates them with the corresponding {Space} contract diff --git a/test/mocks/MockOwnable.sol b/test/mocks/MockOwnable.sol index e9db589..53e8dc6 100644 --- a/test/mocks/MockOwnable.sol +++ b/test/mocks/MockOwnable.sol @@ -6,5 +6,5 @@ import { Ownable } from "./../../src/abstracts/Ownable.sol"; /// @title MockOwnable /// @notice A mock implementation that uses the `onlyOwner` auth mechanism contract MockOwnable is Ownable { - constructor(address _owner) Ownable(_owner) { } + constructor(address _owner) Ownable(_owner) {} } diff --git a/test/mocks/MockStreamManager.sol b/test/mocks/MockStreamManager.sol index 3acbb46..839eaff 100644 --- a/test/mocks/MockStreamManager.sol +++ b/test/mocks/MockStreamManager.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.26; -import { StreamManager } from "./../../src/modules/invoice-module/sablier-v2/StreamManager.sol"; +import { StreamManager } from "./../../src/modules/payment-module/sablier-v2/StreamManager.sol"; import { ISablierV2LockupLinear } from "@sablier/v2-core/src/interfaces/ISablierV2LockupLinear.sol"; import { ISablierV2LockupTranched } from "@sablier/v2-core/src/interfaces/ISablierV2LockupTranched.sol"; @@ -12,5 +12,7 @@ contract MockStreamManager is StreamManager { ISablierV2LockupLinear _sablierLockupLinear, ISablierV2LockupTranched _sablierLockupTranched, address _brokerAdmin - ) StreamManager(_sablierLockupLinear, _sablierLockupTranched, _brokerAdmin) { } + ) + StreamManager(_sablierLockupLinear, _sablierLockupTranched, _brokerAdmin) + { } } diff --git a/test/unit/concrete/helpers/computeNumberOfPayments.t.sol b/test/unit/concrete/helpers/computeNumberOfPayments.t.sol index 61f437b..fba9221 100644 --- a/test/unit/concrete/helpers/computeNumberOfPayments.t.sol +++ b/test/unit/concrete/helpers/computeNumberOfPayments.t.sol @@ -2,8 +2,8 @@ pragma solidity ^0.8.26; import { Base_Test } from "../../../Base.t.sol"; -import { Helpers } from "./../../../../src/modules/invoice-module/libraries/Helpers.sol"; -import { Types } from "./../../../../src/modules/invoice-module/libraries/Types.sol"; +import { Helpers } from "./../../../../src/modules/payment-module/libraries/Helpers.sol"; +import { Types } from "./../../../../src/modules/payment-module/libraries/Types.sol"; contract ComputeNumberOfPayments_Helpers_Test is Base_Test { function setUp() public virtual override { diff --git a/test/unit/concrete/space/Space.t.sol b/test/unit/concrete/space/Space.t.sol index 131e5fc..658db2f 100644 --- a/test/unit/concrete/space/Space.t.sol +++ b/test/unit/concrete/space/Space.t.sol @@ -10,6 +10,6 @@ contract Space_Unit_Concrete_Test is Base_Test { address[] memory modules = new address[](1); modules[0] = address(mockModule); - space = deploySpace({ _owner: users.eve, _spaceId: 0, _initialModules: modules }); + space = deploySpace({ _owner: users.eve, _stationId: 0, _initialModules: modules }); } } diff --git a/test/unit/concrete/space/fallback/fallback.t.sol b/test/unit/concrete/space/fallback/fallback.t.sol index 49d321f..2b676c8 100644 --- a/test/unit/concrete/space/fallback/fallback.t.sol +++ b/test/unit/concrete/space/fallback/fallback.t.sol @@ -18,7 +18,7 @@ contract Fallback_Unit_Concrete_Test is Space_Unit_Concrete_Test { emit Events.NativeReceived({ from: users.bob, amount: 1 ether }); // Run the test - (bool success,) = address(space).call{ value: 1 ether }("test"); + (bool success, ) = address(space).call{ value: 1 ether }("test"); if (!success) revert(); // Assert the {Space} contract balance diff --git a/test/unit/concrete/space/receive/receive.t.sol b/test/unit/concrete/space/receive/receive.t.sol index 9d74f39..7a73912 100644 --- a/test/unit/concrete/space/receive/receive.t.sol +++ b/test/unit/concrete/space/receive/receive.t.sol @@ -18,7 +18,7 @@ contract Receive_Unit_Concrete_Test is Space_Unit_Concrete_Test { emit Events.NativeReceived({ from: users.bob, amount: 1 ether }); // Run the test - (bool success,) = address(space).call{ value: 1 ether }(""); + (bool success, ) = address(space).call{ value: 1 ether }(""); if (!success) revert(); // Assert the {Space} contract balance diff --git a/test/unit/concrete/space/withdraw-native/withdrawNative.t.sol b/test/unit/concrete/space/withdraw-native/withdrawNative.t.sol index d92f006..d1eff62 100644 --- a/test/unit/concrete/space/withdraw-native/withdrawNative.t.sol +++ b/test/unit/concrete/space/withdraw-native/withdrawNative.t.sol @@ -21,7 +21,7 @@ contract WithdrawNative_Unit_Concrete_Test is Space_Unit_Concrete_Test { // Deploy the `badSpace` space address[] memory modules = new address[](1); modules[0] = address(mockModule); - badSpace = deploySpace({ _owner: address(badReceiver), _spaceId: 0, _initialModules: modules }); + badSpace = deploySpace({ _owner: address(badReceiver), _stationId: 0, _initialModules: modules }); } function test_RevertWhen_CallerNotOwner() external { diff --git a/test/unit/concrete/station-registry/create-account/createAccount.t.sol b/test/unit/concrete/station-registry/create-account/createAccount.t.sol index e3a0dc9..d77d218 100644 --- a/test/unit/concrete/station-registry/create-account/createAccount.t.sol +++ b/test/unit/concrete/station-registry/create-account/createAccount.t.sol @@ -55,7 +55,7 @@ contract CreateAccount_Unit_Concrete_Test is StationRegistry_Unit_Concrete_Test modifier whenStationIdNonZero() { // Create & deploy a new space with Eve as the owner - space = deploySpace({ _owner: users.bob, _spaceId: 0, _initialModules: mockModules }); + space = deploySpace({ _owner: users.bob, _stationId: 0, _initialModules: mockModules }); _; } diff --git a/test/unit/concrete/station-registry/transfer-station-ownership/transferDockOwnership.t.sol b/test/unit/concrete/station-registry/transfer-station-ownership/transferDockOwnership.t.sol index 6b09da1..e1b7bb4 100644 --- a/test/unit/concrete/station-registry/transfer-station-ownership/transferDockOwnership.t.sol +++ b/test/unit/concrete/station-registry/transfer-station-ownership/transferDockOwnership.t.sol @@ -17,7 +17,7 @@ contract TransferStationOwnership_Unit_Concrete_Test is StationRegistry_Unit_Con address[] memory modules = new address[](1); modules[0] = address(mockModule); - space = deploySpace({ _owner: users.eve, _spaceId: 0, _initialModules: modules }); + space = deploySpace({ _owner: users.eve, _stationId: 0, _initialModules: modules }); _; } diff --git a/test/unit/concrete/station-registry/update-module-keeper/updateModuleKeeper.t.sol b/test/unit/concrete/station-registry/update-module-keeper/updateModuleKeeper.t.sol index 75ab7f5..8bcd281 100644 --- a/test/unit/concrete/station-registry/update-module-keeper/updateModuleKeeper.t.sol +++ b/test/unit/concrete/station-registry/update-module-keeper/updateModuleKeeper.t.sol @@ -19,7 +19,9 @@ contract UpdateModuleKeeper_Unit_Concrete_Test is StationRegistry_Unit_Concrete_ // Expect the next call to revert with the {PermissionsUnauthorizedAccount} error vm.expectRevert( abi.encodeWithSelector( - Errors.PermissionsUnauthorizedAccount.selector, users.bob, Constants.DEFAULT_ADMIN_ROLE + Errors.PermissionsUnauthorizedAccount.selector, + users.bob, + Constants.DEFAULT_ADMIN_ROLE ) ); diff --git a/test/utils/Constants.sol b/test/utils/Constants.sol index 5d67554..c7b456b 100644 --- a/test/utils/Constants.sol +++ b/test/utils/Constants.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; +pragma solidity ^0.8.22; library Constants { /// @dev Role identifier for addresses with the default admin role diff --git a/test/utils/Errors.sol b/test/utils/Errors.sol index 3d9865f..bef3eec 100644 --- a/test/utils/Errors.sol +++ b/test/utils/Errors.sol @@ -60,7 +60,7 @@ library Errors { error InvalidZeroCodeModule(); /*////////////////////////////////////////////////////////////////////////// - INVOICE-MODULE + PAYMENT-MODULE //////////////////////////////////////////////////////////////////////////*/ /// @notice Thrown when the caller is an invalid zero code contract or EOA @@ -69,17 +69,17 @@ library Errors { /// @notice Thrown when the caller is a contract that does not implement the {ISpace} interface error SpaceUnsupportedInterface(); - /// @notice Thrown when the end time of an invoice is in the past + /// @notice Thrown when the end time of a payment request is in the past error EndTimeInThePast(); /// @notice Thrown when the start time is later than the end time error StartTimeGreaterThanEndTime(); - /// @notice Thrown when the payment amount set for a new invoice is zero + /// @notice Thrown when the payment amount set for a new paymentRequest is zero error ZeroPaymentAmount(); - /// @notice Thrown when the payment amount is less than the invoice value - error PaymentAmountLessThanInvoiceValue(uint256 amount); + /// @notice Thrown when the payment amount is less than the payment request value + error PaymentAmountLessThanRequestedAmount(uint256 amount); /// @notice Thrown when a payment in the native token (ETH) fails error NativeTokenPaymentFailed(); @@ -87,17 +87,14 @@ library Errors { /// @notice Thrown when a linear or tranched stream is created with the native token as the payment asset error OnlyERC20StreamsAllowed(); - /// @notice Thrown when a payer attempts to pay an invoice that has already been paid - error InvoiceAlreadyPaid(); + /// @notice Thrown when a payer attempts to pay a canceled payment request + error RequestCanceled(); - /// @notice Thrown when a payer attempts to pay a canceled invoice - error InvoiceCanceled(); + /// @notice Thrown when a payer attempts to pay a completed payment request + error RequestPaid(); - /// @notice Thrown when the invoice ID references a null invoice - error InvoiceNull(); - - /// @notice Thrown when `msg.sender` is not the creator (recipient) of the invoice - error OnlyInvoiceRecipient(); + /// @notice Thrown when `msg.sender` is not the payment request recipient + error OnlyRequestRecipient(); /// @notice Thrown when the payment interval (endTime - startTime) is too short for the selected recurrence /// i.e. recurrence is set to weekly but interval is shorter than 1 week @@ -106,15 +103,15 @@ library Errors { /// @notice Thrown when a tranched stream has a one-off recurrence type error TranchedStreamInvalidOneOffRecurence(); - /// @notice Thrown when an attempt is made to cancel an already paid invoice - error CannotCancelPaidInvoice(); - - /// @notice Thrown when an attempt is made to cancel an already canceled invoice - error InvoiceAlreadyCanceled(); - /// @notice Thrown when the caller is not the initial stream sender error OnlyInitialStreamSender(address initialSender); + /// @notice Thrown when the payment request is null + error NullRequest(); + + /// @notice Thrown when the recipient address is the zero address + error InvalidZeroAddressRecipient(); + /*////////////////////////////////////////////////////////////////////////// STREAM-MANAGER //////////////////////////////////////////////////////////////////////////*/ diff --git a/test/utils/Events.sol b/test/utils/Events.sol index 4626337..fcea57a 100644 --- a/test/utils/Events.sol +++ b/test/utils/Events.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.26; -import { Types } from "./../../src/modules/invoice-module/libraries/Types.sol"; +import { Types } from "./../../src/modules/payment-module/libraries/Types.sol"; import { Space } from "./../../src/Space.sol"; import { ModuleKeeper } from "./../../src/ModuleKeeper.sol"; import { UD60x18 } from "@prb/math/src/UD60x18.sol"; @@ -92,35 +92,28 @@ abstract contract Events { event ModuleDisabled(address indexed module, address indexed owner); /*////////////////////////////////////////////////////////////////////////// - INVOICE + PAYMENT-MODULE //////////////////////////////////////////////////////////////////////////*/ - /// @notice Emitted when a regular or recurring invoice is created - /// @param id The ID of the invoice + /// @notice Emitted when a payment request is created + /// @param requestId The ID of the payment request /// @param recipient The address receiving the payment - /// @param status The status of the invoice - /// @param startTime The timestamp when the invoice takes effect - /// @param endTime The timestamp by which the invoice must be paid - /// @param payment Struct representing the payment details associated with the invoice - event InvoiceCreated( - uint256 id, - address indexed recipient, - Types.Status status, - uint40 startTime, - uint40 endTime, - Types.Payment payment + /// @param startTime The timestamp when the payment request takes effect + /// @param endTime The timestamp by which the payment request must be paid + /// @param config Struct representing the payment details associated with the payment request + event RequestCreated( + uint256 requestId, address indexed recipient, uint40 startTime, uint40 endTime, Types.Config config ); - /// @notice Emitted when an invoice is paid - /// @param id The ID of the invoice + /// @notice Emitted when a payment is made for a payment request + /// @param requestId The ID of the payment request /// @param payer The address of the payer - /// @param status The status of the invoice - /// @param payment Struct representing the payment details associated with the invoice - event InvoicePaid(uint256 indexed id, address indexed payer, Types.Status status, Types.Payment payment); + /// @param config Struct representing the payment details + event RequestPaid(uint256 indexed requestId, address indexed payer, Types.Config config); - /// @notice Emitted when an invoice is canceled - /// @param id The ID of the invoice - event InvoiceCanceled(uint256 indexed id); + /// @notice Emitted when a payment request is canceled + /// @param requestId The ID of the payment request + event RequestCanceled(uint256 indexed requestId); /// @notice Emitted when the broker fee is updated /// @param oldFee The old broker fee @@ -149,4 +142,14 @@ abstract contract Events { /// @param owner The address of the {ModuleKeeper} owner /// @param module The address of the module to be removed event ModuleRemovedFromAllowlist(address indexed owner, address indexed module); + + /*////////////////////////////////////////////////////////////////////////// + INVOICE-COLLECTION + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Emitted when an invoice is created + /// @param to The address of the payment recipient of the invoice + /// @param tokenId The ID of the NFT representing the invoice + /// @param paymentRequestId The ID of the payment request associated with the invoice + event InvoiceMinted(address to, uint256 tokenId, string paymentRequestId); } diff --git a/test/utils/Helpers.sol b/test/utils/Helpers.sol index 4fd0042..1216968 100644 --- a/test/utils/Helpers.sol +++ b/test/utils/Helpers.sol @@ -1,31 +1,19 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.26; -import { Types } from "./../../src/modules/invoice-module/libraries/Types.sol"; -import { Helpers as InvoiceHelpers } from "./../../src/modules/invoice-module/libraries/Helpers.sol"; +import { Types } from "./../../src/modules/payment-module/libraries/Types.sol"; +import { Helpers as PaymentHelpers } from "./../../src/modules/payment-module/libraries/Helpers.sol"; library Helpers { - function createInvoiceDataType() public view returns (Types.Invoice memory) { - return Types.Invoice({ - 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 + /// @dev Calculates the number of payments that must be done based on a Recurring paymentRequest function computeNumberOfRecurringPayments( Types.Recurrence recurrence, uint40 interval - ) internal pure returns (uint40 numberOfPayments) { + ) + internal + pure + returns (uint40 numberOfPayments) + { if (recurrence == Types.Recurrence.Weekly) { numberOfPayments = interval / 1 weeks; } else if (recurrence == Types.Recurrence.Monthly) { @@ -36,17 +24,30 @@ library Helpers { } /// @dev Checks if the fuzzed recurrence and payment method are valid; - /// Check {IInvoiceModule-createInvoice} for reference + /// Check {IPaymentModule-createRequest} for reference function checkFuzzedPaymentMethod( uint8 paymentMethod, uint8 recurrence, uint40 startTime, uint40 endTime - ) internal pure returns (bool valid, uint40 numberOfPayments) { + ) + internal + pure + returns (bool valid, uint40 numberOfPayments) + { if (paymentMethod == uint8(Types.Method.Transfer) && recurrence == uint8(Types.Recurrence.OneOff)) { numberOfPayments = 1; - } else if (paymentMethod != uint8(Types.Method.LinearStream)) { - numberOfPayments = InvoiceHelpers.computeNumberOfPayments({ + } else if ( + paymentMethod == uint8(Types.Method.TranchedStream) + || (paymentMethod == uint8(Types.Method.Transfer) && recurrence != uint8(Types.Recurrence.OneOff)) + ) { + // Break fuzz test if payment method is tranched stream and recurrence set to one-off + // as a tranched stream recurrence must be Weekly, Monthly or Yearly + if (recurrence == uint8(Types.Recurrence.OneOff)) { + return (false, 0); + } + + numberOfPayments = PaymentHelpers.computeNumberOfPayments({ recurrence: Types.Recurrence(recurrence), interval: endTime - startTime }); @@ -59,16 +60,10 @@ library Helpers { // Check for the maximum number of tranched steps in a Tranched Stream if (numberOfPayments > 500) return (false, 0); - numberOfPayments = 0; - } - } - - // Break fuzz test if payment method is tranched stream and recurrence set to one-off - // as a tranched stream recurrence must be Weekly, Monthly or Yearly - if (paymentMethod == uint8(Types.Method.TranchedStream)) { - if (recurrence == uint8(Types.Recurrence.OneOff)) { - return (false, 0); + numberOfPayments = 1; } + } else if (paymentMethod == uint8(Types.Method.LinearStream)) { + numberOfPayments = 1; } return (true, numberOfPayments);