From b51d46ab5bfe49bbc10927379f9afd2a76258132 Mon Sep 17 00:00:00 2001 From: Vagabond Date: Wed, 17 Jun 2026 03:13:17 +0700 Subject: [PATCH 1/3] add: cli foundation --- cli/project-template/.gitignore | 5 + cli/project-template/README.md | 92 + cli/project-template/bases/accesscontrol.json | 42 + cli/project-template/bases/diamond.json | 15 + cli/project-template/bases/erc1155.json | 27 + cli/project-template/bases/erc20.json | 30 + cli/project-template/bases/erc6969.json | 27 + cli/project-template/bases/erc721.json | 55 + cli/project-template/bases/examples.json | 17 + cli/project-template/bases/libraries.json | 11 + cli/project-template/bases/owner.json | 27 + cli/project-template/package-lock.json | 1758 +++++++++++++++++ cli/project-template/package.json | 25 + .../skills/compose-cli-architecture/SKILL.md | 179 ++ .../examples/pipeline-module-adapter.ts | 82 + .../references/checklist.md | 25 + .../references/doctrine.md | 92 + cli/project-template/spec/M1/Init.md | 101 + cli/project-template/spec/M1/M1Goal.md | 248 +++ cli/project-template/spec/M1/Validation.md | 89 + cli/project-template/spec/M1/compose.json | 39 + cli/project-template/spec/M1/compose.lock | 22 + cli/project-template/spec/Metric.md | 10 + .../spec/internal-architecture.md | 689 +++++++ .../src/adapters/hashingAdapter.ts | 12 + cli/project-template/src/context/context.ts | 18 + cli/project-template/src/context/types.ts | 31 + cli/project-template/src/index.ts | 20 + .../src/modules/configModule.ts | 102 + .../src/modules/entryModule.ts | 434 ++++ .../src/modules/foundryProjectModule.ts | 33 + .../src/modules/pipelineBuilderModule.ts | 55 + .../src/modules/scaffoldingModule.ts | 516 +++++ .../src/modules/validationModule.ts | 297 +++ .../src/pipelines/entryPipeline.ts | 48 + .../src/pipelines/foundryInitPipeline.ts | 58 + .../src/resolver/dependencyKey.ts | 3 + .../src/resolver/dependencyRegistry.ts | 22 + .../src/resolver/dependencyResolver.ts | 32 + .../Admin/AccessControlAdminFacet.sol | 78 + .../Admin/AccessControlAdminMod.sol | 68 + .../Grant/AccessControlGrantBatchFacet.sol | 86 + .../Grant/AccessControlGrantBatchMod.sol | 73 + .../Revoke/AccessControlRevokeBatchFacet.sol | 86 + .../Revoke/AccessControlRevokeBatchMod.sol | 73 + .../Data/AccessControlDataFacet.sol | 82 + .../Data/AccessControlDataMod.sol | 67 + .../Grant/AccessControlGrantFacet.sol | 82 + .../Grant/AccessControlGrantMod.sol | 71 + .../Pausable/AccessControlPausableFacet.sol | 193 ++ .../Pausable/AccessControlPausableMod.sol | 139 ++ .../Renounce/AccessControlRenounceFacet.sol | 79 + .../Renounce/AccessControlRenounceMod.sol | 66 + .../Revoke/AccessControlRevokeFacet.sol | 82 + .../Revoke/AccessControlRevokeMod.sol | 74 + .../Data/AccessControlTemporalDataFacet.sol | 160 ++ .../Data/AccessControlTemporalDataMod.sol | 150 ++ .../Grant/AccessControlTemporalGrantFacet.sol | 134 ++ .../Grant/AccessControlTemporalGrantMod.sol | 124 ++ .../AccessControlTemporalRevokeFacet.sol | 124 ++ .../Revoke/AccessControlTemporalRevokeMod.sol | 114 ++ .../access/Owner/Data/OwnerDataFacet.sol | 51 + .../access/Owner/Data/OwnerDataMod.sol | 75 + .../Owner/Renounce/OwnerRenounceFacet.sol | 65 + .../Owner/Renounce/OwnerRenounceMod.sol | 57 + .../Owner/Transfer/OwnerTransferFacet.sol | 66 + .../Owner/Transfer/OwnerTransferMod.sol | 57 + .../TwoSteps/Data/OwnerTwoStepDataFacet.sol | 49 + .../TwoSteps/Data/OwnerTwoStepDataMod.sol | 40 + .../Renounce/OwnerTwoStepRenounceFacet.sol | 88 + .../Renounce/OwnerTwoStepRenounceMod.sol | 81 + .../Transfer/OwnerTwoStepTransferFacet.sol | 106 + .../Transfer/OwnerTwoStepTransferMod.sol | 98 + .../templates/diamond/DiamondInspectFacet.sol | 177 ++ .../src/templates/diamond/DiamondMod.sol | 380 ++++ .../templates/diamond/DiamondUpgradeFacet.sol | 725 +++++++ .../templates/diamond/DiamondUpgradeMod.sol | 685 +++++++ .../diamond/example/ExampleDiamond.sol | 52 + .../ERC20IdentifierCollisionError.sol | 51 + .../examples/ERC20MissingExportSelector.sol | 68 + .../examples/ERC20SelectorCollisionError.sol | 145 ++ .../interfaceDetection/ERC165/ERC165Facet.sol | 90 + .../interfaceDetection/ERC165/ERC165Mod.sol | 52 + .../src/templates/interfaces/IERC1155.sol | 166 ++ .../templates/interfaces/IERC1155Receiver.sol | 54 + .../src/templates/interfaces/IERC173.sol | 41 + .../src/templates/interfaces/IERC20.sol | 195 ++ .../src/templates/interfaces/IERC2981.sol | 47 + .../src/templates/interfaces/IERC6909.sol | 120 ++ .../src/templates/interfaces/IERC721.sol | 137 ++ .../interfaces/IERC721Enumerable.sol | 167 ++ .../templates/interfaces/IERC721Metadata.sol | 31 + .../templates/interfaces/IERC721Receiver.sol | 25 + .../src/templates/interfaces/IFacet.sol | 18 + .../templates/libraries/NonReentrancyMod.sol | 68 + .../src/templates/libraries/Utils.sol | 12 + .../ERC1155/Approve/ERC1155ApproveFacet.sol | 75 + .../ERC1155/Approve/ERC1155ApproveMod.sol | 66 + .../token/ERC1155/Burn/ERC1155BurnFacet.sol | 171 ++ .../token/ERC1155/Burn/ERC1155BurnMod.sol | 147 ++ .../token/ERC1155/Data/ERC1155DataFacet.sol | 96 + .../ERC1155/Metadata/ERC1155MetadataFacet.sol | 71 + .../ERC1155/Metadata/ERC1155MetadataMod.sol | 81 + .../token/ERC1155/Mint/ERC1155MintMod.sol | 204 ++ .../ERC1155/Transfer/ERC1155TransferFacet.sol | 283 +++ .../ERC1155/Transfer/ERC1155TransferMod.sol | 275 +++ .../token/ERC20/Approve/ERC20ApproveFacet.sol | 75 + .../token/ERC20/Approve/ERC20ApproveMod.sol | 64 + .../ERC20/Bridgeable/ERC20BridgeableFacet.sol | 224 +++ .../ERC20/Bridgeable/ERC20BridgeableMod.sol | 215 ++ .../token/ERC20/Burn/ERC20BurnFacet.sol | 113 ++ .../token/ERC20/Burn/ERC20BurnMod.sol | 83 + .../token/ERC20/Data/ERC20DataFacet.sol | 71 + .../ERC20/Metadata/ERC20MetadataFacet.sol | 68 + .../token/ERC20/Metadata/ERC20MetadataMod.sol | 47 + .../token/ERC20/Mint/ERC20MintMod.sol | 68 + .../token/ERC20/Permit/ERC20PermitFacet.sol | 194 ++ .../token/ERC20/Permit/ERC20PermitMod.sol | 171 ++ .../ERC20/Transfer/ERC20TransferFacet.sol | 145 ++ .../token/ERC20/Transfer/ERC20TransferMod.sol | 149 ++ .../ERC6909/Approve/ERC6909ApproveFacet.sol | 73 + .../ERC6909/Approve/ERC6909ApproveMod.sol | 62 + .../token/ERC6909/Burn/ERC6909BurnFacet.sol | 121 ++ .../token/ERC6909/Burn/ERC6909BurnMod.sol | 108 + .../token/ERC6909/Data/ERC6909DataFacet.sol | 74 + .../token/ERC6909/Mint/ERC6909MintMod.sol | 62 + .../ERC6909/Operator/ERC6909OperatorFacet.sol | 77 + .../ERC6909/Operator/ERC6909OperatorMod.sol | 67 + .../ERC6909/Transfer/ERC6909TransferFacet.sol | 146 ++ .../ERC6909/Transfer/ERC6909TransferMod.sol | 141 ++ .../ERC721/Approve/ERC721ApproveFacet.sol | 100 + .../token/ERC721/Approve/ERC721ApproveMod.sol | 82 + .../token/ERC721/Burn/ERC721BurnFacet.sol | 110 ++ .../token/ERC721/Burn/ERC721BurnMod.sol | 69 + .../token/ERC721/Data/ERC721DataFacet.sol | 104 + .../Burn/ERC721EnumerableBurnFacet.sol | 134 ++ .../Burn/ERC721EnumerableBurnMod.sol | 109 + .../Data/ERC721EnumerableDataFacet.sol | 103 + .../Mint/ERC721EnumerableMintMod.sol | 99 + .../ERC721EnumerableTransferFacet.sol | 217 ++ .../Transfer/ERC721EnumerableTransferMod.sol | 180 ++ .../ERC721/Metadata/ERC721MetadataFacet.sol | 132 ++ .../ERC721/Metadata/ERC721MetadataMod.sol | 42 + .../token/ERC721/Mint/ERC721MintMod.sol | 77 + .../ERC721/Transfer/ERC721TransferFacet.sol | 183 ++ .../ERC721/Transfer/ERC721TransferMod.sol | 96 + .../templates/token/Royalty/RoyaltyFacet.sol | 83 + .../templates/token/Royalty/RoyaltyMod.sol | 160 ++ cli/project-template/src/utils/files.ts | 62 + cli/project-template/src/utils/regex.ts | 4 + .../src/utils/solidityText.ts | 88 + cli/project-template/src/utils/terminal.ts | 14 + cli/project-template/tsconfig.json | 15 + 153 files changed, 18282 insertions(+) create mode 100644 cli/project-template/.gitignore create mode 100644 cli/project-template/README.md create mode 100644 cli/project-template/bases/accesscontrol.json create mode 100644 cli/project-template/bases/diamond.json create mode 100644 cli/project-template/bases/erc1155.json create mode 100644 cli/project-template/bases/erc20.json create mode 100644 cli/project-template/bases/erc6969.json create mode 100644 cli/project-template/bases/erc721.json create mode 100644 cli/project-template/bases/examples.json create mode 100644 cli/project-template/bases/libraries.json create mode 100644 cli/project-template/bases/owner.json create mode 100644 cli/project-template/package-lock.json create mode 100644 cli/project-template/package.json create mode 100644 cli/project-template/skills/compose-cli-architecture/SKILL.md create mode 100644 cli/project-template/skills/compose-cli-architecture/examples/pipeline-module-adapter.ts create mode 100644 cli/project-template/skills/compose-cli-architecture/references/checklist.md create mode 100644 cli/project-template/skills/compose-cli-architecture/references/doctrine.md create mode 100644 cli/project-template/spec/M1/Init.md create mode 100644 cli/project-template/spec/M1/M1Goal.md create mode 100644 cli/project-template/spec/M1/Validation.md create mode 100644 cli/project-template/spec/M1/compose.json create mode 100644 cli/project-template/spec/M1/compose.lock create mode 100644 cli/project-template/spec/Metric.md create mode 100644 cli/project-template/spec/internal-architecture.md create mode 100644 cli/project-template/src/adapters/hashingAdapter.ts create mode 100644 cli/project-template/src/context/context.ts create mode 100644 cli/project-template/src/context/types.ts create mode 100644 cli/project-template/src/index.ts create mode 100644 cli/project-template/src/modules/configModule.ts create mode 100644 cli/project-template/src/modules/entryModule.ts create mode 100644 cli/project-template/src/modules/foundryProjectModule.ts create mode 100644 cli/project-template/src/modules/pipelineBuilderModule.ts create mode 100644 cli/project-template/src/modules/scaffoldingModule.ts create mode 100644 cli/project-template/src/modules/validationModule.ts create mode 100644 cli/project-template/src/pipelines/entryPipeline.ts create mode 100644 cli/project-template/src/pipelines/foundryInitPipeline.ts create mode 100644 cli/project-template/src/resolver/dependencyKey.ts create mode 100644 cli/project-template/src/resolver/dependencyRegistry.ts create mode 100644 cli/project-template/src/resolver/dependencyResolver.ts create mode 100644 cli/project-template/src/templates/access/AccessControl/Admin/AccessControlAdminFacet.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Admin/AccessControlAdminMod.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Batch/Grant/AccessControlGrantBatchFacet.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Batch/Grant/AccessControlGrantBatchMod.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Batch/Revoke/AccessControlRevokeBatchFacet.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Batch/Revoke/AccessControlRevokeBatchMod.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Data/AccessControlDataFacet.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Data/AccessControlDataMod.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Grant/AccessControlGrantFacet.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Grant/AccessControlGrantMod.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Pausable/AccessControlPausableFacet.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Pausable/AccessControlPausableMod.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Renounce/AccessControlRenounceFacet.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Renounce/AccessControlRenounceMod.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Revoke/AccessControlRevokeFacet.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Revoke/AccessControlRevokeMod.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Temporal/Data/AccessControlTemporalDataFacet.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Temporal/Data/AccessControlTemporalDataMod.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Temporal/Grant/AccessControlTemporalGrantFacet.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Temporal/Grant/AccessControlTemporalGrantMod.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Temporal/Revoke/AccessControlTemporalRevokeFacet.sol create mode 100644 cli/project-template/src/templates/access/AccessControl/Temporal/Revoke/AccessControlTemporalRevokeMod.sol create mode 100644 cli/project-template/src/templates/access/Owner/Data/OwnerDataFacet.sol create mode 100644 cli/project-template/src/templates/access/Owner/Data/OwnerDataMod.sol create mode 100644 cli/project-template/src/templates/access/Owner/Renounce/OwnerRenounceFacet.sol create mode 100644 cli/project-template/src/templates/access/Owner/Renounce/OwnerRenounceMod.sol create mode 100644 cli/project-template/src/templates/access/Owner/Transfer/OwnerTransferFacet.sol create mode 100644 cli/project-template/src/templates/access/Owner/Transfer/OwnerTransferMod.sol create mode 100644 cli/project-template/src/templates/access/Owner/TwoSteps/Data/OwnerTwoStepDataFacet.sol create mode 100644 cli/project-template/src/templates/access/Owner/TwoSteps/Data/OwnerTwoStepDataMod.sol create mode 100644 cli/project-template/src/templates/access/Owner/TwoSteps/Renounce/OwnerTwoStepRenounceFacet.sol create mode 100644 cli/project-template/src/templates/access/Owner/TwoSteps/Renounce/OwnerTwoStepRenounceMod.sol create mode 100644 cli/project-template/src/templates/access/Owner/TwoSteps/Transfer/OwnerTwoStepTransferFacet.sol create mode 100644 cli/project-template/src/templates/access/Owner/TwoSteps/Transfer/OwnerTwoStepTransferMod.sol create mode 100644 cli/project-template/src/templates/diamond/DiamondInspectFacet.sol create mode 100644 cli/project-template/src/templates/diamond/DiamondMod.sol create mode 100644 cli/project-template/src/templates/diamond/DiamondUpgradeFacet.sol create mode 100644 cli/project-template/src/templates/diamond/DiamondUpgradeMod.sol create mode 100644 cli/project-template/src/templates/diamond/example/ExampleDiamond.sol create mode 100644 cli/project-template/src/templates/examples/ERC20IdentifierCollisionError.sol create mode 100644 cli/project-template/src/templates/examples/ERC20MissingExportSelector.sol create mode 100644 cli/project-template/src/templates/examples/ERC20SelectorCollisionError.sol create mode 100644 cli/project-template/src/templates/interfaceDetection/ERC165/ERC165Facet.sol create mode 100644 cli/project-template/src/templates/interfaceDetection/ERC165/ERC165Mod.sol create mode 100644 cli/project-template/src/templates/interfaces/IERC1155.sol create mode 100644 cli/project-template/src/templates/interfaces/IERC1155Receiver.sol create mode 100644 cli/project-template/src/templates/interfaces/IERC173.sol create mode 100644 cli/project-template/src/templates/interfaces/IERC20.sol create mode 100644 cli/project-template/src/templates/interfaces/IERC2981.sol create mode 100644 cli/project-template/src/templates/interfaces/IERC6909.sol create mode 100644 cli/project-template/src/templates/interfaces/IERC721.sol create mode 100644 cli/project-template/src/templates/interfaces/IERC721Enumerable.sol create mode 100644 cli/project-template/src/templates/interfaces/IERC721Metadata.sol create mode 100644 cli/project-template/src/templates/interfaces/IERC721Receiver.sol create mode 100644 cli/project-template/src/templates/interfaces/IFacet.sol create mode 100644 cli/project-template/src/templates/libraries/NonReentrancyMod.sol create mode 100644 cli/project-template/src/templates/libraries/Utils.sol create mode 100644 cli/project-template/src/templates/token/ERC1155/Approve/ERC1155ApproveFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC1155/Approve/ERC1155ApproveMod.sol create mode 100644 cli/project-template/src/templates/token/ERC1155/Burn/ERC1155BurnFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC1155/Burn/ERC1155BurnMod.sol create mode 100644 cli/project-template/src/templates/token/ERC1155/Data/ERC1155DataFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC1155/Metadata/ERC1155MetadataFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC1155/Metadata/ERC1155MetadataMod.sol create mode 100644 cli/project-template/src/templates/token/ERC1155/Mint/ERC1155MintMod.sol create mode 100644 cli/project-template/src/templates/token/ERC1155/Transfer/ERC1155TransferFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC1155/Transfer/ERC1155TransferMod.sol create mode 100644 cli/project-template/src/templates/token/ERC20/Approve/ERC20ApproveFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC20/Approve/ERC20ApproveMod.sol create mode 100644 cli/project-template/src/templates/token/ERC20/Bridgeable/ERC20BridgeableFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC20/Bridgeable/ERC20BridgeableMod.sol create mode 100644 cli/project-template/src/templates/token/ERC20/Burn/ERC20BurnFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC20/Burn/ERC20BurnMod.sol create mode 100644 cli/project-template/src/templates/token/ERC20/Data/ERC20DataFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC20/Metadata/ERC20MetadataFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC20/Metadata/ERC20MetadataMod.sol create mode 100644 cli/project-template/src/templates/token/ERC20/Mint/ERC20MintMod.sol create mode 100644 cli/project-template/src/templates/token/ERC20/Permit/ERC20PermitFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC20/Permit/ERC20PermitMod.sol create mode 100644 cli/project-template/src/templates/token/ERC20/Transfer/ERC20TransferFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC20/Transfer/ERC20TransferMod.sol create mode 100644 cli/project-template/src/templates/token/ERC6909/Approve/ERC6909ApproveFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC6909/Approve/ERC6909ApproveMod.sol create mode 100644 cli/project-template/src/templates/token/ERC6909/Burn/ERC6909BurnFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC6909/Burn/ERC6909BurnMod.sol create mode 100644 cli/project-template/src/templates/token/ERC6909/Data/ERC6909DataFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC6909/Mint/ERC6909MintMod.sol create mode 100644 cli/project-template/src/templates/token/ERC6909/Operator/ERC6909OperatorFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC6909/Operator/ERC6909OperatorMod.sol create mode 100644 cli/project-template/src/templates/token/ERC6909/Transfer/ERC6909TransferFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC6909/Transfer/ERC6909TransferMod.sol create mode 100644 cli/project-template/src/templates/token/ERC721/Approve/ERC721ApproveFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC721/Approve/ERC721ApproveMod.sol create mode 100644 cli/project-template/src/templates/token/ERC721/Burn/ERC721BurnFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC721/Burn/ERC721BurnMod.sol create mode 100644 cli/project-template/src/templates/token/ERC721/Data/ERC721DataFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC721/Enumerable/Burn/ERC721EnumerableBurnFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC721/Enumerable/Burn/ERC721EnumerableBurnMod.sol create mode 100644 cli/project-template/src/templates/token/ERC721/Enumerable/Data/ERC721EnumerableDataFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC721/Enumerable/Mint/ERC721EnumerableMintMod.sol create mode 100644 cli/project-template/src/templates/token/ERC721/Enumerable/Transfer/ERC721EnumerableTransferFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC721/Enumerable/Transfer/ERC721EnumerableTransferMod.sol create mode 100644 cli/project-template/src/templates/token/ERC721/Metadata/ERC721MetadataFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC721/Metadata/ERC721MetadataMod.sol create mode 100644 cli/project-template/src/templates/token/ERC721/Mint/ERC721MintMod.sol create mode 100644 cli/project-template/src/templates/token/ERC721/Transfer/ERC721TransferFacet.sol create mode 100644 cli/project-template/src/templates/token/ERC721/Transfer/ERC721TransferMod.sol create mode 100644 cli/project-template/src/templates/token/Royalty/RoyaltyFacet.sol create mode 100644 cli/project-template/src/templates/token/Royalty/RoyaltyMod.sol create mode 100644 cli/project-template/src/utils/files.ts create mode 100644 cli/project-template/src/utils/regex.ts create mode 100644 cli/project-template/src/utils/solidityText.ts create mode 100644 cli/project-template/src/utils/terminal.ts create mode 100644 cli/project-template/tsconfig.json diff --git a/cli/project-template/.gitignore b/cli/project-template/.gitignore new file mode 100644 index 00000000..06aabcf8 --- /dev/null +++ b/cli/project-template/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +my-diamond/ +*.tsbuildinfo +.vscode/ diff --git a/cli/project-template/README.md b/cli/project-template/README.md new file mode 100644 index 00000000..a3f89f1e --- /dev/null +++ b/cli/project-template/README.md @@ -0,0 +1,92 @@ +# Compose CLI Project Template + +This directory is the current Compose CLI implementation template. It is a Node.js/TypeScript CLI that scaffolds a Diamond-based Foundry project from Compose-owned metadata under `bases/`. + +## Current Behavior + +- `compose` with no command prints help. +- `compose init` starts the interactive init flow. +- Interactive init asks for framework, base, Compose library facets, extension facets, and local example facets. +- Foundry init validates selected facets before writing files. +- Generated Foundry files are written into the current Foundry project when `foundry.toml` exists, otherwise into `my-diamond/`. +- `compose validate` and `compose inspect` are routed placeholders only. + +## Commands + +From this directory: + +```sh +npm install +npm run build +node dist/index.js +node dist/index.js init +``` + +For development: + +```sh +npm run dev -- init +``` + +## Structure + +- `bases/`: Compose-owned metadata for standards, diamond facets, library facets, and local examples. +- `src/index.ts`: CLI entrypoint. +- `src/context`: Shared context types and factory. +- `src/pipelines`: Top-level and command pipelines. +- `src/modules`: Feature logic for entry UI, config loading, validation, scaffolding, routing, and Foundry project detection. +- `src/adapters`: External library adapters, currently hashing through `viem`. +- `src/resolver`: Dependency keys, registry, and resolver. +- `src/utils`: Generic reusable helpers for files, regex, terminal output, and Solidity text parsing. +- `src/templates`: Solidity template assets copied into generated projects. +- `spec/`: M1 design notes and architecture documentation. +- `skills/`: Local Codex architecture skill used while developing this template. + +## Metadata + +`bases/` is the source of truth for interactive init options. + +- `diamond.json`: Diamond facets. +- `libraries.json`: Compose library facets. +- `examples.json`: Local example facets used for validation testing. +- Other JSON files define selectable bases such as ERC-20, ERC-721, ERC-1155, ERC-6909, AccessControl, and Owner. + +Each base has: + +- `required`: Facets always included for that base. +- `optional`: Extension facets shown after the base is selected. + +## Validation + +Foundry init currently validates: + +- Selector exports: every selected facet must export all intended external/public functions. +- Selector collisions: selected facets must not introduce duplicate function selectors. +- Identifier collisions: selected facets must not use incompatible storage layouts for the same storage identifier. + +Validation is fail-fast. `EntryModule.showReport` prints the error report and stops the flow before scaffolding continues. + +## Generated Output + +Generated files are organized by meaning: + +```txt +src/ + diamond/ + libraries/ + facets/ +``` + +The CLI also writes a generated `compose.json` into the target project. `compose.lock` is not part of the current M1 implementation. + +## Local Artifacts + +The local `.gitignore` excludes generated or machine-local files: + +```gitignore +node_modules/ +dist/ +my-diamond/ +*.tsbuildinfo +.vscode/ +``` diff --git a/cli/project-template/bases/accesscontrol.json b/cli/project-template/bases/accesscontrol.json new file mode 100644 index 00000000..d43e5440 --- /dev/null +++ b/cli/project-template/bases/accesscontrol.json @@ -0,0 +1,42 @@ +{ + "access-control": { + "label": "AccessControl", + "required": { + "AccessControlDataFacet": { + "path": "./src/templates/access/AccessControl/Data/AccessControlDataFacet.sol" + }, + "AccessControlGrantFacet": { + "path": "./src/templates/access/AccessControl/Grant/AccessControlGrantFacet.sol" + }, + "AccessControlRevokeFacet": { + "path": "./src/templates/access/AccessControl/Revoke/AccessControlRevokeFacet.sol" + } + }, + "optional": { + "AccessControlAdminFacet": { + "path": "./src/templates/access/AccessControl/Admin/AccessControlAdminFacet.sol" + }, + "AccessControlGrantBatchFacet": { + "path": "./src/templates/access/AccessControl/Batch/Grant/AccessControlGrantBatchFacet.sol" + }, + "AccessControlRevokeBatchFacet": { + "path": "./src/templates/access/AccessControl/Batch/Revoke/AccessControlRevokeBatchFacet.sol" + }, + "AccessControlPausableFacet": { + "path": "./src/templates/access/AccessControl/Pausable/AccessControlPausableFacet.sol" + }, + "AccessControlRenounceFacet": { + "path": "./src/templates/access/AccessControl/Renounce/AccessControlRenounceFacet.sol" + }, + "AccessControlTemporalDataFacet": { + "path": "./src/templates/access/AccessControl/Temporal/Data/AccessControlTemporalDataFacet.sol" + }, + "AccessControlTemporalGrantFacet": { + "path": "./src/templates/access/AccessControl/Temporal/Grant/AccessControlTemporalGrantFacet.sol" + }, + "AccessControlTemporalRevokeFacet": { + "path": "./src/templates/access/AccessControl/Temporal/Revoke/AccessControlTemporalRevokeFacet.sol" + } + } + } +} diff --git a/cli/project-template/bases/diamond.json b/cli/project-template/bases/diamond.json new file mode 100644 index 00000000..3ab7ea8e --- /dev/null +++ b/cli/project-template/bases/diamond.json @@ -0,0 +1,15 @@ +{ + "diamond": { + "label": "Diamond", + "required": { + "DiamondInspectFacet": { + "path": "./src/templates/diamond/DiamondInspectFacet.sol" + } + }, + "optional": { + "DiamondUpgradeFacet": { + "path": "./src/templates/diamond/DiamondUpgradeFacet.sol" + } + } + } +} diff --git a/cli/project-template/bases/erc1155.json b/cli/project-template/bases/erc1155.json new file mode 100644 index 00000000..dc6ed464 --- /dev/null +++ b/cli/project-template/bases/erc1155.json @@ -0,0 +1,27 @@ +{ + "erc-1155": { + "label": "ERC-1155", + "required": { + "ERC1155DataFacet": { + "path": "./src/templates/token/ERC1155/Data/ERC1155DataFacet.sol" + }, + "ERC1155ApproveFacet": { + "path": "./src/templates/token/ERC1155/Approve/ERC1155ApproveFacet.sol" + }, + "ERC1155TransferFacet": { + "path": "./src/templates/token/ERC1155/Transfer/ERC1155TransferFacet.sol" + } + }, + "optional": { + "ERC1155BurnFacet": { + "path": "./src/templates/token/ERC1155/Burn/ERC1155BurnFacet.sol" + }, + "ERC1155MetadataFacet": { + "path": "./src/templates/token/ERC1155/Metadata/ERC1155MetadataFacet.sol" + }, + "RoyaltyFacet": { + "path": "./src/templates/token/Royalty/RoyaltyFacet.sol" + } + } + } +} diff --git a/cli/project-template/bases/erc20.json b/cli/project-template/bases/erc20.json new file mode 100644 index 00000000..33c2dbce --- /dev/null +++ b/cli/project-template/bases/erc20.json @@ -0,0 +1,30 @@ +{ + "erc-20": { + "label": "ERC-20", + "required": { + "ERC20DataFacet": { + "path": "./src/templates/token/ERC20/Data/ERC20DataFacet.sol" + }, + "ERC20ApproveFacet": { + "path": "./src/templates/token/ERC20/Approve/ERC20ApproveFacet.sol" + }, + "ERC20TransferFacet": { + "path": "./src/templates/token/ERC20/Transfer/ERC20TransferFacet.sol" + } + }, + "optional": { + "ERC20BurnFacet": { + "path": "./src/templates/token/ERC20/Burn/ERC20BurnFacet.sol" + }, + "ERC20MetadataFacet": { + "path": "./src/templates/token/ERC20/Metadata/ERC20MetadataFacet.sol" + }, + "ERC20PermitFacet": { + "path": "./src/templates/token/ERC20/Permit/ERC20PermitFacet.sol" + }, + "ERC20BridgeableFacet": { + "path": "./src/templates/token/ERC20/Bridgeable/ERC20BridgeableFacet.sol" + } + } + } +} diff --git a/cli/project-template/bases/erc6969.json b/cli/project-template/bases/erc6969.json new file mode 100644 index 00000000..b21f7121 --- /dev/null +++ b/cli/project-template/bases/erc6969.json @@ -0,0 +1,27 @@ +{ + "erc-6909": { + "label": "ERC-6909", + "required": { + "ERC6909DataFacet": { + "path": "./src/templates/token/ERC6909/Data/ERC6909DataFacet.sol" + }, + "ERC6909TransferFacet": { + "path": "./src/templates/token/ERC6909/Transfer/ERC6909TransferFacet.sol" + }, + "ERC6909ApproveFacet": { + "path": "./src/templates/token/ERC6909/Approve/ERC6909ApproveFacet.sol" + }, + "ERC6909OperatorFacet": { + "path": "./src/templates/token/ERC6909/Operator/ERC6909OperatorFacet.sol" + } + }, + "optional": { + "ERC6909BurnFacet": { + "path": "./src/templates/token/ERC6909/Burn/ERC6909BurnFacet.sol" + }, + "RoyaltyFacet": { + "path": "./src/templates/token/Royalty/RoyaltyFacet.sol" + } + } + } +} diff --git a/cli/project-template/bases/erc721.json b/cli/project-template/bases/erc721.json new file mode 100644 index 00000000..177d3fd9 --- /dev/null +++ b/cli/project-template/bases/erc721.json @@ -0,0 +1,55 @@ +{ + "erc-721": { + "label": "ERC-721", + "required": { + "ERC721ApproveFacet": { + "path": "./src/templates/token/ERC721/Approve/ERC721ApproveFacet.sol" + }, + "ERC721DataFacet": { + "path": "./src/templates/token/ERC721/Data/ERC721DataFacet.sol" + }, + "ERC721TransferFacet": { + "path": "./src/templates/token/ERC721/Transfer/ERC721TransferFacet.sol" + } + }, + "optional": { + "ERC721BurnFacet": { + "path": "./src/templates/token/ERC721/Burn/ERC721BurnFacet.sol" + }, + "ERC721MetadataFacet": { + "path": "./src/templates/token/ERC721/Metadata/ERC721MetadataFacet.sol" + }, + "RoyaltyFacet": { + "path": "./src/templates/token/Royalty/RoyaltyFacet.sol" + } + } + }, + "erc-721-enumerable": { + "label": "ERC-721 Enumerable", + "required": { + "ERC721ApproveFacet": { + "path": "./src/templates/token/ERC721/Approve/ERC721ApproveFacet.sol" + }, + "ERC721DataFacet": { + "path": "./src/templates/token/ERC721/Data/ERC721DataFacet.sol" + }, + "ERC721EnumerableDataFacet": { + "path": "./src/templates/token/ERC721/Enumerable/Data/ERC721EnumerableDataFacet.sol" + }, + "ERC721EnumerableTransferFacet": { + "path": "./src/templates/token/ERC721/Enumerable/Transfer/ERC721EnumerableTransferFacet.sol" + } + }, + "optional": { + "ERC721EnumerableBurnFacet": { + "path": "./src/templates/token/ERC721/Enumerable/Burn/ERC721EnumerableBurnFacet.sol" + }, + "ERC721MetadataFacet": { + "path": "./src/templates/token/ERC721/Metadata/ERC721MetadataFacet.sol" + }, + "RoyaltyFacet": { + "path": "./src/templates/token/Royalty/RoyaltyFacet.sol" + } + } + } +} diff --git a/cli/project-template/bases/examples.json b/cli/project-template/bases/examples.json new file mode 100644 index 00000000..32b56017 --- /dev/null +++ b/cli/project-template/bases/examples.json @@ -0,0 +1,17 @@ +{ + "examples": { + "label": "Local Examples", + "required": {}, + "optional": { + "ERC20MissingExportSelector": { + "path": "./src/templates/examples/ERC20MissingExportSelector.sol" + }, + "ERC20SelectorCollisionError": { + "path": "./src/templates/examples/ERC20SelectorCollisionError.sol" + }, + "ERC20IdentifierCollisionError": { + "path": "./src/templates/examples/ERC20IdentifierCollisionError.sol" + } + } + } +} diff --git a/cli/project-template/bases/libraries.json b/cli/project-template/bases/libraries.json new file mode 100644 index 00000000..1a06a772 --- /dev/null +++ b/cli/project-template/bases/libraries.json @@ -0,0 +1,11 @@ +{ + "libraries": { + "label": "Libraries", + "required": {}, + "optional": { + "ERC165Facet": { + "path": "./src/templates/interfaceDetection/ERC165/ERC165Facet.sol" + } + } + } +} diff --git a/cli/project-template/bases/owner.json b/cli/project-template/bases/owner.json new file mode 100644 index 00000000..1478f5f0 --- /dev/null +++ b/cli/project-template/bases/owner.json @@ -0,0 +1,27 @@ +{ + "owner": { + "label": "Owner", + "required": { + "OwnerDataFacet": { + "path": "./src/templates/access/Owner/Data/OwnerDataFacet.sol" + }, + "OwnerTransferFacet": { + "path": "./src/templates/access/Owner/Transfer/OwnerTransferFacet.sol" + } + }, + "optional": { + "OwnerRenounceFacet": { + "path": "./src/templates/access/Owner/Renounce/OwnerRenounceFacet.sol" + }, + "OwnerTwoStepDataFacet": { + "path": "./src/templates/access/Owner/TwoSteps/Data/OwnerTwoStepDataFacet.sol" + }, + "OwnerTwoStepTransferFacet": { + "path": "./src/templates/access/Owner/TwoSteps/Transfer/OwnerTwoStepTransferFacet.sol" + }, + "OwnerTwoStepRenounceFacet": { + "path": "./src/templates/access/Owner/TwoSteps/Renounce/OwnerTwoStepRenounceFacet.sol" + } + } + } +} diff --git a/cli/project-template/package-lock.json b/cli/project-template/package-lock.json new file mode 100644 index 00000000..0416d731 --- /dev/null +++ b/cli/project-template/package-lock.json @@ -0,0 +1,1758 @@ +{ + "name": "compose-cli-architecture-template", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "compose-cli-architecture-template", + "version": "0.1.0", + "dependencies": { + "@inquirer/prompts": "^8.5.2", + "inquirer": "^8.2.7", + "viem": "^2.52.2" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsx": "^4.19.1", + "typescript": "^5.6.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@inquirer/core/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/editor": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz", + "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/external-editor": "^3.0.3", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor/node_modules/@inquirer/external-editor": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz", + "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz", + "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz", + "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz", + "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", + "@inquirer/editor": "^5.2.2", + "@inquirer/expand": "^5.1.1", + "@inquirer/input": "^5.1.2", + "@inquirer/number": "^4.1.1", + "@inquirer/password": "^5.1.1", + "@inquirer/rawlist": "^5.3.1", + "@inquirer/search": "^4.2.1", + "@inquirer/select": "^5.2.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz", + "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz", + "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inquirer": { + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", + "license": "MIT", + "dependencies": { + "@inquirer/external-editor": "^1.0.0", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "license": "ISC" + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ox": { + "version": "0.14.29", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.29.tgz", + "integrity": "sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/viem": { + "version": "2.52.2", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.52.2.tgz", + "integrity": "sha512-HSU12p5aD/kAPZfrlbCUqdiP4P/c6hQ9AhfTS51VbLUQIjkWd1d5EjrCx/SCxZ0zhZVRn4Iv5X5WDqXPG8Ubew==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.29", + "ws": "8.20.1" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/cli/project-template/package.json b/cli/project-template/package.json new file mode 100644 index 00000000..197736fe --- /dev/null +++ b/cli/project-template/package.json @@ -0,0 +1,25 @@ +{ + "name": "compose-cli-architecture-template", + "version": "0.1.0", + "private": true, + "description": "Minimal internal-architecture scaffold for Compose CLI", + "type": "commonjs", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "dev": "tsx src/index.ts" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsx": "^4.19.1", + "typescript": "^5.6.3" + }, + "dependencies": { + "@inquirer/prompts": "^8.5.2", + "inquirer": "^8.2.7", + "viem": "^2.52.2" + } +} diff --git a/cli/project-template/skills/compose-cli-architecture/SKILL.md b/cli/project-template/skills/compose-cli-architecture/SKILL.md new file mode 100644 index 00000000..b5053306 --- /dev/null +++ b/cli/project-template/skills/compose-cli-architecture/SKILL.md @@ -0,0 +1,179 @@ +--- +name: compose-cli-architecture +description: Generate Compose CLI code, design workflows, review boundaries, or refactor using the pipeline-oriented modular architecture. Use for commands, context, pipelines, child pipelines, modules, adapters, dependency resolver, registry, and boundary decisions. +--- + +# Compose CLI Architecture + +## Core idea + +Compose CLI is a one-shot Node.js CLI. + +Use this architecture: + +```txt +Command parses input. +Context carries execution state. +Pipeline orchestrates workflow. +Module owns feature logic. +Adapter talks to external systems. +Resolver creates requested dependencies. +Registry maps dependency keys to factories. +``` + +## Use this skill when + +- Designing a command workflow. +- Generating pipeline/module/adapter code. +- Reviewing architecture boundaries. +- Deciding where logic belongs. +- Refactoring external library usage behind adapters. +- Adding dependency resolver or registry entries. + +## Do not use this skill for + +- Generic TypeScript help. +- Generic Node.js CLI setup. +- UI/frontend code. +- Long-running service or daemon architecture. +- Smart contract code unless Compose CLI is reading, validating, comparing, or reporting it. + +## Rules + +### Command entrypoint + +Only parse input, create context, call the main pipeline, and handle final output. + +### Context + +Context carries `param`, `config`, `state`, and `status`. + +Context should not own behavior. + +Modules may write only their own `ctx.state.`. + +### Pipeline + +Pipeline owns orchestration. + +It may call modules, child pipelines, and dependency resolver. + +It should not contain business logic. + +It should not call external libraries directly. + +### Child pipeline + +Use child pipelines for isolated groups of module steps or concurrency boundaries. + +Keep nesting shallow: + +```txt +Main Pipeline + -> Child Pipeline +``` + +Avoid deeper nesting unless strongly justified. + +### Module + +Module owns feature logic. + +A module function receives `ctx`, optionally receives `deps`, writes its own state, and returns `ctx`. + +Modules should not create adapters directly. + +Files that contain helper functions and exported modules should be structured in this order: + +```ts +// ===================== +// Helper +// ===================== + +// ===================== +// Modules +// ===================== +``` + +Keep private helper functions above the exported module object. Keep the exported module object under the `// Modules` section. + +Add a short 1-2 line comment above each function to explain what it does. + +### Adapter + +Adapter owns external communication. + +Use adapters for RPC, external APIs, external tools, runtime-specific concerns, or libraries likely to change. + +Adapter should not own domain validation. + +### Resolver and registry + +Resolver is lightweight dependency resolution, not a full DI container. + +Pipeline requests dependencies. + +Registry maps keys to adapter factories. + +Resolver creates only requested dependencies. + +Modules receive dependencies through function parameters. + +## Boundary guide + +Put code in: + +```txt +Command => CLI parsing and pipeline selection +Context => shared execution state +Pipeline => workflow order and dependency resolution +ChildPipeline=> isolated module group / concurrency boundary +Module => feature logic and validation +Adapter => external communication +Resolver => create requested dependencies +Registry => map dependency key to factory +``` + +## Generation workflow + +When generating code: + +1. Identify command workflow. +2. Define context input. +3. Choose main pipeline or child pipeline. +4. List module steps. +5. Identify external dependencies. +6. Define adapter interface. +7. Add dependency key and registry factory. +8. Resolve deps in pipeline. +9. Pass deps into module. +10. Ensure each module writes only its own state. + +## Review workflow + +When reviewing code, check: + +1. Is command entrypoint thin? +2. Is pipeline only orchestration? +3. Is business logic inside modules? +4. Are external calls behind adapters? +5. Are deps resolved at pipeline level? +6. Do modules receive deps instead of creating adapters? +7. Is child pipeline nesting shallow? +8. Does context carry state without owning behavior? + +## Output format + +For generation: + +1. File tree +2. Code +3. Short explanation + +For review: + +1. Verdict +2. Violations +3. Fixes +4. Patch or corrected code +5. Remaining risks diff --git a/cli/project-template/skills/compose-cli-architecture/examples/pipeline-module-adapter.ts b/cli/project-template/skills/compose-cli-architecture/examples/pipeline-module-adapter.ts new file mode 100644 index 00000000..0a4cb670 --- /dev/null +++ b/cli/project-template/skills/compose-cli-architecture/examples/pipeline-module-adapter.ts @@ -0,0 +1,82 @@ +type ComposeError = { + code: string; + message: string; + nativeError: unknown | null; +}; + +type ModuleState = { + success: boolean; + result: T | null; + error: ComposeError | null; +}; + +type ExecutionStatus = { + success: boolean; + stopped: boolean; + failedAt: string | null; + error: ComposeError | null; +}; + +type ComposeContext = { + param: Record; + config: Record; + state: Record; + status: ExecutionStatus; +}; + +interface DiamondInspectAdapter { + facetAddresses(address: string): Promise; +} + +type DiamondInspectDeps = { + diamondInspect: DiamondInspectAdapter; +}; + +const DiamondInspect = { + async readFacetAddresses( + ctx: ComposeContext, + { diamondInspect }: DiamondInspectDeps + ): Promise { + const address = ctx.param.address as string; + const facetAddresses = await diamondInspect.facetAddresses(address); + + ctx.state.diamondInspect = { + success: true, + result: { + facetAddresses, + }, + error: null, + }; + + return ctx; + }, +}; + +enum DependencyKey { + DiamondInspect = "diamondInspect", +} + +const DependencyResolver = { + async resolve(requests: { key: DependencyKey; params?: unknown }[]) { + return { + diamondInspect: {} as DiamondInspectAdapter, + }; + }, +}; + +async function validatePipeline( + ctx: ComposeContext +): Promise { + const deps = (await DependencyResolver.resolve([ + { + key: DependencyKey.DiamondInspect, + params: { + chain: ctx.param.chain, + }, + }, + ])) as DiamondInspectDeps; + + ctx = await DiamondInspect.readFacetAddresses(ctx, deps); + + return ctx; +} \ No newline at end of file diff --git a/cli/project-template/skills/compose-cli-architecture/references/checklist.md b/cli/project-template/skills/compose-cli-architecture/references/checklist.md new file mode 100644 index 00000000..5c62a238 --- /dev/null +++ b/cli/project-template/skills/compose-cli-architecture/references/checklist.md @@ -0,0 +1,25 @@ +# Review Checklist + +## Clean + +- Command entrypoint is thin. +- Pipeline reads like workflow table of contents. +- Modules own business logic. +- Files with helpers place private helpers under the `Helper` banner before exported modules under the `Modules` banner. +- Functions have a short 1-2 line comment explaining what they do. +- Adapters own external communication. +- Resolver is lightweight. +- Registry only maps keys to factories. +- Context carries state. +- Child pipeline nesting is shallow. + +## Red flags + +- Pipeline calls `viem`, `ethers`, APIs, or subprocess directly. +- Module creates adapter directly. +- Helper functions are mixed below exported module objects. +- Adapter validates domain rules. +- Resolver becomes full DI container. +- Context becomes service locator. +- Child pipeline nests too deep. +- One function does command + pipeline + module + adapter work. diff --git a/cli/project-template/skills/compose-cli-architecture/references/doctrine.md b/cli/project-template/skills/compose-cli-architecture/references/doctrine.md new file mode 100644 index 00000000..f5a424c6 --- /dev/null +++ b/cli/project-template/skills/compose-cli-architecture/references/doctrine.md @@ -0,0 +1,92 @@ +# Compose CLI Doctrine + +## Architecture + +Compose CLI uses Pipeline-Oriented Modular Architecture. + +The CLI is one-shot, Node.js-based, and should not hold, store, or transmit private keys. + +## Context shape + +```ts +type ComposeError = { + code: string; + message: string; + nativeError: unknown | null; +}; + +type ModuleState = { + success: boolean; + result: T | null; + error: ComposeError | null; +}; + +type ExecutionStatus = { + success: boolean; + stopped: boolean; + failedAt: string | null; + error: ComposeError | null; +}; + +type ChildPipelineState = { + success: boolean; + state: Record; + status: ExecutionStatus; +}; + +type ComposeContext = { + param: Record; + config: Record; + state: Record; + status: ExecutionStatus; +}; +``` + +## Dependency resolver shape + +```ts +enum DependencyKey { + DiamondInspect = "diamondInspect", +} + +type DependencyParams = Record; + +type DependencyFactory = ( + params?: DependencyParams +) => Promise | T; + +type DependencyRequest = { + key: DependencyKey; + params?: DependencyParams; +}; + +const DependencyResolver = { + async resolve( + requests: DependencyRequest[] + ): Promise> { + const deps: Record = {}; + + for (const request of requests) { + const factory = DependencyRegistry[request.key]; + + if (!factory) { + throw new Error(`Dependency factory not found: ${request.key}`); + } + + deps[request.key] = await factory(request.params); + } + + return deps; + }, +}; +``` + +## Main boundary sentence + +```txt +Pipeline decides order. +Module decides meaning. +Adapter handles outside world. +Resolver creates dependencies. +Context records the trace. +``` \ No newline at end of file diff --git a/cli/project-template/spec/M1/Init.md b/cli/project-template/spec/M1/Init.md new file mode 100644 index 00000000..076a3bc4 --- /dev/null +++ b/cli/project-template/spec/M1/Init.md @@ -0,0 +1,101 @@ +# compose init + +## Constraints + +- For M1, `compose init` should primarily use modules. Adapters are not required for the normal `init` flow, but they may be introduced for reusable RPC-related validation work such as keccak or selector computation. +- Foundry and Hardhat are primary business concerns of the CLI, so their handling should be expressed explicitly through modules and pipelines. +- Adapters may be introduced later when a boundary needs flexibility, such as RPC clients or external-language integration. +- `compose.lock` may be mentioned for planning, but it is not implemented in M1. + +## Modules + +A module can contain multiple functions or methods. +Each function is a small unit of logic. + +- A module can read everything from context. +- A module can mutate only its own section in context. + +### entryModule + +Handles user-facing CLI work and libraries such as `commander`, `inquirer`, and `picocolors`. + +- Parse command input. +- Ask interactive questions when needed. +- Render terminal output. + +### configModule + +Handles config and metadata-related file work for M1. + +- Read Compose-owned metadata from `bases`. +- Build generated `compose.json`. +- Write generated `compose.json` into the user project directory. + +`compose.lock` is future scope and is not written by M1 `init`. + +### pipelineBuilderModule + +Determines which command was requested and routes to the correct pipeline. + +### storageValidationModule + +Contains the logic needed for storage collision checks. + +- Reused from `validate`. +- May introduce an RPC-oriented adapter in M1 if that helps keep keccak-related logic reusable and isolated. + +### selectorValidationModule + +Contains the logic needed for selector collision checks. + +- Reused from `validate`. +- May introduce an RPC-oriented adapter in M1 if that helps keep selector computation reusable and isolated. +- For Compose facets, `exportSelectors()` is the source used for cross-facet collision checking after per-facet completeness is verified. + +### scaffoldingModule + +Works with the filesystem and copies template files from Compose into the user's working directory. + +## Pipelines + +A pipeline should not implement business features directly. +It should call modules to do the business work. +A pipeline may use conditions, loops, and context parsing as part of orchestration. + +Except for the command entrypoint, each pipeline takes context as input and returns context as output. + +If a main pipeline calls a child pipeline: + +- At the beginning, the child pipeline should take only the data it needs and create a separate child context. +- The main pipeline can read the child pipeline result and append it back into the main context. + +### entryPipeline.ts + +Root pipeline for command execution. + +- Call `entryModule` to handle CLI-facing work. +- Determine which mode to use. +- Determine which framework to use. +- Route the request to the correct pipeline. + +### foundryInitPipeline + +Handles the `init` command for Foundry. + +### hardhatInitPipeline + +Handles the `init` command for Hardhat. + +### storageValidatePipeline + +Reused from `validate`. + +### selectorValidatePipeline + +Reused from `validate`. + +### scaffoldingPipeline + +Handles file-copy orchestration by calling `scaffoldingModule`. + +If a filesystem exception occurs during scaffolding, the pipeline should support rollback behavior. diff --git a/cli/project-template/spec/M1/M1Goal.md b/cli/project-template/spec/M1/M1Goal.md new file mode 100644 index 00000000..aeb0c1ae --- /dev/null +++ b/cli/project-template/spec/M1/M1Goal.md @@ -0,0 +1,248 @@ +# Development Commands + +## `compose init` + +Scaffold a new Compose diamond project. + +`compose init` creates a new local project, generates the initial `compose.json`, and prepares the project file structure. + +`compose init` should build a diamond configuration from available Compose library facets, starter patterns, local example facets, and framework-specific project setup. + +- Supports interactive prompts or flag-driven usage. +- Generates `compose.json` during scaffolding. +- Supports Foundry and Hardhat project setup. +- Supports starter presets such as bare diamond, Counter, ERC-20, ERC-721, and future examples. +- Allows the developer to select, add, or remove available Compose library facets before generation. +- Optionally generates local starter facets and tests. +- Recomputes the diamond surface as facets are added or removed. +- Resolves selected facets into a selector ownership table before writing the project. +- Detects selector collisions during project building. +- Shows collision details during interactive facet selection. +- Allows the developer to resolve collisions by choosing an owner, excluding selectors, or removing one of the colliding facets. +- Writes explicit selector export rules when a collision is resolved intentionally. +- Fails safely in non-interactive mode if required choices or selector collisions cannot be resolved automatically. + +Interactive flow: + +```shell +Select project framework: +> Foundry + Hardhat + +# A base is a standard that consists of multiple facets. +Select base: + Bare diamond +> ERC-20 + ERC-721 + +# Libraries are facets for cross-cutting concerns. +Select Compose library facets: +[x] DiamondInspectFacet +[x] ERC165Facet +[ ] OwnershipFacet +... + +# Extensions are facets that support optional features of a standard. +Select extension facets: +[ ] ERC20BurnFacet +[ ] ERC20MintFacet +[ ] ERC20MetadataFacet +[ ] ERC20PermitFacet + +Select local example facets: +[x] CounterFacet +[ ] None + +# Run internal validation here. + +> Yes +``` + +**Example non-interactive usage:** + +```shell +compose init my-diamond \ + --framework foundry \ + --base ERC20 \ + --library AccessControlAdminFacet,AccessControlGrantFacet \ + --extension ERC20MetadataFacet \ + --yes +``` + +Non-interactive mode must only succeed when the selected preset fully resolves the project shape, including selector ownership. If unresolved selector collisions are found, the command must fail with a clear error instead of guessing. + +The builder must never rely on facet order, implicit priority, or last-write-wins behavior to resolve selector ownership. Every imported selector in the generated diamond must have exactly one owner. + +### Metadata + +This metadata is owned by Compose CLI and lives under the Compose root directory, not inside the user project directory. +It acts as an internal catalog for `compose init` to decide which diamond facets, library facets, base facets, and extension facets are available for selection. + +The source of truth is the Compose-owned `bases` directory. `diamond.json` and `libraries.json` are special global catalogs. Every other JSON file is a selectable base. Each base has two parts: + +- `required`: facets that are always included when that base is selected. +- `optional`: extension facets shown after the user selects that base. + +For `diamond.json` and `libraries.json`, required facets are included for every generated diamond. Optional facets are shown in the Compose library selection unless they are already required by the selected base. + +Example: + +```json +{ + "diamond.json": { + "diamond": { + "label": "Diamond", + "required": { + "DiamondInspectFacet": { + "path": "./src/templates/diamond/DiamondInspectFacet.sol" + } + }, + "optional": { + "DiamondUpgradeFacet": { + "path": "./src/templates/diamond/DiamondUpgradeFacet.sol" + } + } + } + }, + "libraries.json": { + "libraries": { + "label": "Libraries", + "required": {}, + "optional": { + "ERC165Facet": { + "path": "./src/templates/interfaceDetection/ERC165/ERC165Facet.sol" + } + } + } + }, + "erc20.json": { + "erc-20": { + "label": "ERC-20", + "required": { + "ERC20DataFacet": { + "path": "./src/templates/token/ERC20/Data/ERC20DataFacet.sol" + }, + "ERC20ApproveFacet": { + "path": "./src/templates/token/ERC20/Approve/ERC20ApproveFacet.sol" + }, + "ERC20TransferFacet": { + "path": "./src/templates/token/ERC20/Transfer/ERC20TransferFacet.sol" + } + }, + "optional": { + "ERC20BurnFacet": { + "path": "./src/templates/token/ERC20/Burn/ERC20BurnFacet.sol" + }, + "ERC20MetadataFacet": { + "path": "./src/templates/token/ERC20/Metadata/ERC20MetadataFacet.sol" + }, + "ERC20PermitFacet": { + "path": "./src/templates/token/ERC20/Permit/ERC20PermitFacet.sol" + }, + "ERC20BridgeableFacet": { + "path": "./src/templates/token/ERC20/Bridgeable/ERC20BridgeableFacet.sol" + } + } + } + }, + "erc721.json": "...", + "erc1155.json": "..." +} +``` + +#### Target project layout + +```txt +root +`-- src + |-- diamond + | `-- diamond facets + |-- libraries + | `-- shared project libraries + `-- facets + |-- base + | `-- base facets + `-- extensions + `-- extension facets +``` + +## `compose validate` + +### Definition + +Static analysis on the local codebase before deployment. + +- **Auto-compilation**: Detects the project framework (Foundry or Hardhat) and runs `forge build` or `hardhat compile` under the hood if compiled artifacts are stale or missing. Skips compilation if artifacts are already fresh. +- **Storage layout validation**: When multiple facets share a namespaced storage slot, validates that structs are safely extended. New fields may be appended (e.g. `{a, b}` to `{a, b, c}`), but fields must not be reordered or inserted (e.g. `{a, b}` to `{a, c, b}` is invalid). One struct must be a prefix of the other. Storage slot detection uses a fallback chain: + 1. **ERC-8042 annotation** (`@custom:storage-location erc8042:`): preferred, parsed directly from source. + 2. **`.slot :=` pattern**: if the annotation is missing, the CLI scans the source for any assembly block containing a `.slot :=` assignment, regardless of whether it occurs in a dedicated accessor function or inline. From each match, it traces backward to resolve: (a) the `keccak256("namespace")` constant that feeds the slot, and (b) the struct type of the storage variable being assigned. This produces the same namespace-to-struct mapping as the annotation. A warning is raised: *"Missing `@custom:storage-location` annotation -- storage slot inferred from `.slot :=` pattern. Add the annotation for reliable detection."* + 3. **Neither detected**: the storage check is skipped for that facet with a warning: *"Cannot determine storage layout -- no ERC-8042 annotation or recognized storage pattern found."* +- **Selector clash detection**: Flags two different functions that hash to the same 4-byte selector across all facets in the diamond. +- **`exportSelectors()` consistency**: Verifies every facet's `exportSelectors()` return value matches its actual external functions. +- **Missing facet registration**: Warns about facets in the codebase not referenced in `compose.json`. +- Exit code non-zero on failure (CI-friendly). + +### Validation types + +#### Selector collision + +Compute each selected function's 4-byte selector and detect duplicates. +This can be handled using `exportSelectors()` on each facet. + +#### Storage collision + +Storage collision happens when two different layouts point to the same storage slot. + +There are three main kinds of storage collision: + +- Two different storage layouts point to the same identifier. +- Variables are placed outside the diamond storage struct. +- Storage changes during an update, such as a new variable placed in the middle of a struct. + +Across this project, some storage layouts may have a different number of elements while still preserving the same field order. +Those implementations are valid. + +#### Example + +```solidity +// Valid +// ERC20Mint +bytes32 constant STORAGE_POSITION = keccak256("erc20"); +struct ERC20Storage { + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; +} + +// ERC20Transfer +bytes32 constant STORAGE_POSITION = keccak256("erc20"); +struct ERC20Storage { + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; + mapping(address owner => mapping(address spender => uint256 allowance)) allowance; +} + +// ============================ +// Invalid +// ERC20Mint +bytes32 constant STORAGE_POSITION = keccak256("erc20"); +struct ERC20Storage { + uint256 totalSupply; + mapping(address owner => uint256 balance) balanceOf; +} + +// ERC20Transfer +bytes32 constant STORAGE_POSITION = keccak256("erc20"); +struct ERC20Storage { + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; +} +``` + +## Libraries + +```shell +commander # parse commands such as: compose init --framework foundry +inquirer # ask users to choose options, checkboxes, and confirmations +picocolors # color terminal output +fs-extra # copy templates, write files, and ensure directories exist +``` diff --git a/cli/project-template/spec/M1/Validation.md b/cli/project-template/spec/M1/Validation.md new file mode 100644 index 00000000..0cc70022 --- /dev/null +++ b/cli/project-template/spec/M1/Validation.md @@ -0,0 +1,89 @@ +# compose validate + +## Summary + +Validation checks the selected diamond surface before files are written or before a deployed project is trusted. + +- Selector validation checks function selector ownership. +- Identifier validation checks storage layout compatibility. +- Both checks should fail fast when the diamond shape is unsafe. + +## Constraints + +- Validation is static analysis on the selected diamond surface. +- Validation must fail fast when a required check fails. +- Selector collision detection must run after selector export validation. +- Identifier collision validation is separate from selector collision validation. + +## Selector Validation + +Selector validation works on selected facets from `init` or facets referenced by `compose.json`. + +Order: + +1. Scan selected facets. +2. Validate selector exports. +3. Detect selector collisions. + +### scanSelectedFacets + +Owned by `scaffoldingModule` during `init`. + +- Read selected package facets. +- Read selected local example facets. +- Parse external and public functions. +- Parse `exportSelectors()`. +- Parse storage layout identifiers for later storage validation. + +### validateSelectorExports + +Owned by `validationModule`. + +- Verify every intended external or public function is exported by `exportSelectors()`. +- Verify every function exported by `exportSelectors()` exists in the facet. +- Fail before selector collision detection if this check fails. + +### detectSelectorCollisions + +Owned by `validationModule`. + +- Use `exportSelectors()` as the selector source after export validation passes. +- Compute each exported function's 4-byte selector from its signature. +- Fail if more than one selected facet exports the same selector. +- This is the last selector validation step before project files are written. + +## Identifier Validation + +Identifier validation checks storage layout collisions for selected facets. + +- Prefer ERC-8042 storage annotations. +- Fall back to detected storage slot assignment patterns. +- Skip the storage check for a facet when no supported storage pattern is found. +- Fail when different storage layouts point to the same namespace in an unsafe way. + +### Detection Order + +1. Parse `@custom:storage-location erc8042:` annotations. +2. If no annotation is found, infer storage from `.slot :=` assignments. +3. If neither is found, warn and skip storage validation for that facet. + +### Layout Rule + +Each detected namespace maps to a normalized storage type sequence. + +- Types are normalized before comparison. +- `uint` and `int` are normalized to `uint256` and `int256`. +- Mapping parameter names are ignored. +- `mapping(address owner => uint256 balance)` is compared as `mapping(address=>uint256)`. +- Inline structs are flattened in storage order. + +### Compare Rule + +Group storage records by namespace. + +- If a namespace appears once, it is safe. +- If a namespace appears multiple times, compare the layouts. +- Compatible layouts must share the same prefix. +- Appending fields is safe. +- Reordering fields or inserting fields before existing fields is unsafe. +- Incompatible duplicate namespaces fail validation. diff --git a/cli/project-template/spec/M1/compose.json b/cli/project-template/spec/M1/compose.json new file mode 100644 index 00000000..7bcbdc1d --- /dev/null +++ b/cli/project-template/spec/M1/compose.json @@ -0,0 +1,39 @@ +{ + "project": "my-diamond-project", + "compose": "0.0.3", + "framework": "foundry", + + "diamonds": { + "MyDiamond": { + "contract": "src/MyDiamond.sol:MyDiamond", + "facets": { + "DiamondInspectFacet": { + "source": "package", + "package": "@perfect-abstractions/compose", + "contract": "DiamondInspectFacet" + }, + "DiamondUpgradeFacet": { + "source": "package", + "package": "@perfect-abstractions/compose", + "contract": "DiamondUpgradeFacet" + }, + "CounterFacet": { + "source": "local", + "contract": "src/facets/CounterFacet.sol:CounterFacet" + } + }, + "init": { + "contract": "src/init/DiamondInit.sol:DiamondInit", + "function": "init(address)", + "args": ["{{deployer}}"] + } + } + }, + + "chains": { + "sepolia": { "rpc": "${SEPOLIA_RPC_URL}", "chainId": 11155111 }, + "base": { "rpc": "${BASE_RPC_URL}", "chainId": 8453 } + }, + + "registry": "https://registry.compose.diamonds" +} \ No newline at end of file diff --git a/cli/project-template/spec/M1/compose.lock b/cli/project-template/spec/M1/compose.lock new file mode 100644 index 00000000..95f2f10c --- /dev/null +++ b/cli/project-template/spec/M1/compose.lock @@ -0,0 +1,22 @@ +{ + "compose": "0.0.3", + "deployments": { + "MyDiamond": { + "sepolia": { + "diamond": "0xabc...123", + "facets": { + "DiamondInspectFacet": "0xdef...456", + "DiamondUpgradeFacet": "0xghi...789", + "CounterFacet": "0xjkl...012" + }, + "lastSync": "2026-04-06T12:00:00Z", + "txHash": "0x..." + }, + "base": { + "diamond": "0xmno...345", + "facets": {}, + "lastSync": "2026-04-05T08:30:00Z" + } + } + } +} \ No newline at end of file diff --git a/cli/project-template/spec/Metric.md b/cli/project-template/spec/Metric.md new file mode 100644 index 00000000..9abd82e4 --- /dev/null +++ b/cli/project-template/spec/Metric.md @@ -0,0 +1,10 @@ +### Metrics by Milestone + + +| Milestone | Key Signals | How Measured | +| --------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| **M1: Foundation** | Projects created with `compose init`; `validate` runs per week; template popularity | Opt-in telemetry, GitHub stars/clones, npm download counts | +| **M2: On-Chain Read** | Unique diamonds inspected; `diff` command usage; chains queried | Opt-in telemetry | +| **M3: Deploy** | Deployments via CLI (vs. manual scripts); chains deployed to; `verify` and `status` usage | Opt-in telemetry, registry deploy events (M5) | +| **M4: Upgrade** | Upgrades planned and applied; plan-to-apply conversion rate; facets upgraded per operation | Opt-in telemetry | +| **M5: Registry** | Registry facets referenced in projects; gas saved vs. self-deploy; chains with registry usage | Opt-in telemetry, registry index fetch logs | diff --git a/cli/project-template/spec/internal-architecture.md b/cli/project-template/spec/internal-architecture.md new file mode 100644 index 00000000..87dafb74 --- /dev/null +++ b/cli/project-template/spec/internal-architecture.md @@ -0,0 +1,689 @@ +# Compose CLI - Internal Architecture Proposal + +> **Status:** Draft +> **Date:** 2026-05-20 +> **Product:** Compose CLI +> **Version:** 0.1.0 + +--- + +## Table of Contents + +1. [Pipeline-Oriented Modular Architecture](#pipeline-oriented-modular-architecture) +2. [Assumptions / Constraints](#assumptions--constraints) +3. [General](#general) +4. [Context Object (`ctx`)](#context-object-ctx) + - [Example Context Types](#example-context-types) + - [Example Context Values](#example-context-values) + - [Notes](#notes) +5. [Pipeline](#pipeline) + - [Main Pipeline](#main-pipeline) + - [Child Pipeline](#child-pipeline) + - [Child Pipeline and Concurrency](#child-pipeline-and-concurrency) + - [Example](#example) + - [Note](#note) +6. [Modules](#modules) + - [Modules Example](#modules-example) + - [Note](#note-1) +7. [Module, Pipeline, and Adapters](#module-pipeline-and-adapters) +8. [Adapters](#adapters) + - [Adapter example](#adapter-example) + - [Modules and Adapters](#modules-and-adapters) +9. [Dependency Resolver and Registry](#dependency-resolver-and-registry) + - [Example](#example-1) +10. [Utils](#utils) + - [Utils Workflow](#utils-workflow) +11. [Change Log](#change-log) + +--- + +## **Pipeline-Oriented Modular Architecture** + +A modular architecture where pipelines own orchestration, modules provide stateless units of work, and adapters isolate external tools and libraries. + +## Assumptions / Constraints + +- The CLI is a one-shot tool, not a daemon or background service. + +- The CLI will evolve across multiple milestones, so the architecture should tolerate new features and breaking changes. + +- The CLI remains a Node.js tool, consistent with the current tech stack. + +- The CLI never holds, stores, or transmits private keys. + +- This architecture should respect the constraints defined in the original PRD. + +## General + +![Pipeline-Oriented Modular Architecture](./resources/pipeline-oriented-modular-architecture.png) + +This architecture is built around four main components: **Context (`ctx`)** , **Pipeline**, **Module**, and **Adapter**. + +- Pipelines define sequential chains of actions for specific workflows. + +- Context carries shared state throughout a pipeline execution. + +- Modules are focused units of work that handle the business logic of each step. + +- Adapters are focused units of work that handle external tools, external libraries, or runtime-specific concerns. + +Each command input is parsed into a context object, then passed into the corresponding pipeline. + +The pipeline calls modules in order to process the workflow. If a module needs to interact with an external tool, library, API, or runtime, it calls an adapter. + +The same context is passed from the first module to the last module in the pipeline. + +## Context Object (`ctx`) + +The context object represents the execution scenario of a single command. It centralizes shared information so that modules executed later in the pipeline have the data they need to complete their work. + +The context object can contain parameters, config, module state, child pipeline state, and errors produced during execution. However, it should not own execution behavior. Its role is to carry information between modules and child pipelines through a controlled shared structure, instead of making them depend on each other directly. + +### Example Context Types + +```ts + +type ComposeError = { + code: string; + message: string; + nativeError: unknown | null; +}; + +type ModuleState = { + success: boolean; + result: T | null; + error: ComposeError | null; +}; + +type ExecutionStatus = { + success: boolean; + stopped: boolean; + failedAt: string | null; + error: ComposeError | null; +}; + +type ChildPipelineState = { + success: boolean; + state: Record; + status: ExecutionStatus; +}; + +type ComposeContext = { + param: Record; + config: Record; + state: Record; + status: ExecutionStatus; +}; + +``` + +### Example Context Values + +```ts + +const ctx: ComposeContext = { + param: { + // Parsed command input from the command entrypoint. + }, + + config: { + // Loaded config and long-lived project data, + // such as compose.json, compose.lock, and project metadata. + }, + + state: { + fileSystem: { + success: true, + result: { + // File System Module output. + }, + error: null, + }, + + validationPipeline: { + success: false, + + state: { + validateIdentifier: { + success: true, + result: { + // Identifier validation output. + }, + error: null, + }, + + validateLayout: { + success: false, + result: null, + error: { + code: "LAYOUT_VALIDATION_FAILED", + message: "Layout validation failed.", + nativeError: null, + }, + }, + }, + + status: { + success: false, + stopped: true, + failedAt: "validationPipeline.validateLayout", + + error: { + code: "VALIDATION_PIPELINE_FAILED", + message: "Validation pipeline failed.", + nativeError: null, + }, + }, + }, + + // ... + // more modules / child pipelines here + }, + + status: { + // Top-level command execution status. + + success: false, + stopped: true, + failedAt: "validationPipeline.validateLayout", + + error: { + code: "COMMAND_FAILED", + message: "Command execution failed.", + nativeError: null, + }, + }, +}; + +``` + +### Notes + +- Context allows modules to be added, removed, or reordered across different stages of a pipeline with fewer direct dependencies between modules. + +- It gives modules executed later in the pipeline access to the information produced by previous steps, which enables fallback scenarios when something does not work as expected. + +- In a development environment, it can also provide a full trace of the workflow, which is valuable for debugging. + +## Pipeline + +In this architecture, a pipeline acts as an orchestrator. +It defines what the workflow looks like, which modules should be called, and the order in which they should run. + +A pipeline should only coordinate module execution. It should not execute business logic directly, and it should not interact with adapters by itself. If external interaction is needed, the pipeline calls a module, and the module calls the adapter. + +### Main Pipeline + +![Main-Pipeline](./resources/main-pipeline.png) + +A Main Pipeline is the primary workflow of a CLI command. + +Each command, such as init, validate, diff, or plan, creates a context object and passes it into one Main Pipeline from its command entrypoint. From there, the Main Pipeline orchestrates the modules required to complete the command. + +The Main Pipeline does not need to be directly aware of the command implementation. It only receives a context object and executes the workflow defined for that pipeline. + +### Child Pipeline + +At some stages of a pipeline, there may be multiple smaller modules that need to run as a group. In that case, a pipeline can call another pipeline as part of its workflow. This inner pipeline is called a Child Pipeline. + +![Child-Pipeline](./resources/child-pipeline.png) + +Each Child Pipeline should create its own context object from the parent context, containing only the information required for that pipeline to run. + +After the Child Pipeline finishes, it returns its result. The parent pipeline then appends the Child Pipeline context or result into the state of its own context, and continues to the next action. + +This keeps the Child Pipeline isolated while still allowing the parent pipeline to collect its output in a controlled way. + +### Child Pipeline and Concurrency + +A Child Pipeline is particularly useful for managing concurrency. +Each Child Pipeline reads from and writes to its own context object, while keeping the workflow of its internal modules consistent and predictable. + +A useful benefit of this model is that concurrency is handled at the Child Pipeline boundary. The parent pipeline only sees the Child Pipeline as one executable unit, while the Child Pipeline keeps its internal module workflow predictable. + +When the workflow needs to evolve, developers can add more modules inside the Child Pipeline and arrange their order normally, without manually managing parallel execution, thread locks, or shared-state coordination for each individual module. + +Parallel Child Pipelines should be executed with a reasonable concurrency limit, so the CLI can improve performance without overwhelming the local machine or RPC provider. + +### Example + +**Main Pipeline** + +```ts + +async function mainPipeline(ctx: ComposeContext): Promise { + // Run normal modules in the parent pipeline. + ctx = await FileSystem.load(ctx); + + // Execute the child pipeline with an isolated child context. + const validationCtx: ComposeContext = await ValidationPipeline.execute(ctx); + + // Append the child pipeline result back into the parent context state. + ctx.state.validationPipeline = { + success: validationCtx.status.success, + state: validationCtx.state, + status: validationCtx.status, + }; + + // Continue the parent pipeline after collecting the child pipeline output. + ctx = await Output.write(ctx); + + return ctx; +} + +``` + +**Child Pipeline** + +```ts + +const ValidationPipeline = { + async execute(parentCtx: ComposeContext): Promise { + const fileSystemState = parentCtx.state.fileSystem as ModuleState<{ + identifiers: unknown; + layout: unknown; + }>; + + // Create an isolated context for this child pipeline. + // Only pass the data this pipeline needs from the parent context. + let childCtx: ComposeContext = Context.createChild(parentCtx, { + name: "validationPipeline", + input: { + identifiers: fileSystemState.result?.identifiers, + layout: fileSystemState.result?.layout, + }, + }); + + // Run actions from the Validation module in a predictable order. + childCtx = await Validation.computeKeccak(childCtx); + childCtx = await Validation.validateIdentifier(childCtx); + childCtx = await Validation.validateLayout(childCtx); + + // Finalize the child pipeline status before returning it to the parent. + childCtx.status = { + success: true, + stopped: false, + failedAt: null, + error: null, + }; + + // Return the child context to the parent pipeline. + // The parent pipeline can append this result into its own context state. + return childCtx; + }, +}; + +``` + +### Note + +- While powerful, Child Pipelines should not be overused. Too many nested pipelines can make the workflow branch too much and increase unnecessary complexity. + +- The Main Pipeline can call Modules directly or call a Child Pipeline when a group of actions needs isolation, concurrency, or room to grow. + +- Child Pipelines should not be nested further unless there is a very strong reason, because deep nesting makes the workflow harder to follow. + +**For Compose CLI, I recommend keeping the structure to one Main Pipeline and at most one level of Child Pipeline.** + +## Modules + +A Module is a focused group of units of work inside a pipeline. +It represents a group of related functions that solve one business concern. + +Each function inside a module should do one focused task. It can read the context, perform its work, and write its own result back into the context. + +A module function should not modify state owned by another module. If it needs data from another module, it should read that data from the context instead of depending on that module directly. + +### Modules Example + +```ts + +const Validation = { + async computeKeccak(ctx: ComposeContext): Promise { + // Compute keccak-based identifiers. + // Write result to ctx.state.computeKeccak. + return ctx; + }, + + async validateIdentifier(ctx: ComposeContext): Promise { + // Validate identifier rules. + // Write result to ctx.state.validateIdentifier. + return ctx; + }, + + async validateLayout(ctx: ComposeContext): Promise { + // Validate layout rules. + // Write result to ctx.state.validateLayout. + return ctx; + }, +}; + +``` + +### Note + +Module functions follow a functional-style interface. **They receive a context object and return the context object**. However, for a CLI tool, recreating the whole context object on every module call may create unnecessary memory overhead, especially when the context contains artifacts, layouts, bytecode, or reports. Because of that, modules are allowed to append their own execution result into the context. This is a more practical and efficient approach, as long as each module only writes to its own state section. + +Grouping module functions by semantic meaning also creates a clear anchor for reading the source code. A new developer can quickly understand which modules a pipeline uses, then go deeper into a specific pipeline or module when needed. This improves readability for humans and also provides better context anchors for AI-assisted development. + +## Module, Pipeline, and Adapters + +Some modules may need to interact with external tools, libraries, APIs, or runtimes. These interactions should be handled through adapters. + +To reduce the cost of future changes, modules should not declare or create adapters directly. Instead, the required dependencies should be resolved by the pipeline and passed into the module from the beginning. + +A module function can receive dependencies through its signature, for example: + +```ts + +Module.function(ctx, deps) + +``` + +The pipeline is the orchestration layer. It knows which modules and adapters are needed for its business flow, so it is the practical place to select and assign adapters explicitly. + +This makes sense because the pipeline does not directly perform adapter operations or internal business logic. It simply coordinates the correct adapter with the correct module. + +```ts + +interface HashingAdapterInterface { + keccak256(value: string): string; +} + +type SelectorCollisionDeps = { + hashing: HashingAdapterInterface; +}; + +// Create adapters from registry. +const deps = (await DependencyResolver.resolve([ + { + key: DependencyKey.Hashing, + }, +])) as SelectorCollisionDeps; + +// Assign adapters to module. +ctx = await ValidationModule.detectSelectorCollisions(ctx, { + hashing: deps.hashing, +}); + +``` + +This keeps modules stateless and testable, while allowing pipelines to control adapter lifecycle and dependency scope. +The dependency resolver and adapter conventions will be described in later sections. + +## Adapters + +![Adapters](./resources/adapters.png) + +In traditional software design, an adapter is a pattern used to interact with external dependencies through a stable interface. + +The idea is simple: modules should call a stable adapter interface instead of depending directly on a specific library. If we later need to replace a library, we can update the registry and implement a new adapter while keeping most modules and pipelines unchanged. + +This is especially important for Web3 tooling, where libraries and frameworks can change quickly. If Compose CLI wants to keep up with new tooling trends, adapters can become an important leverage point. + +**For Compose CLI, adapters can also provide another benefit. They can help the Node.js layer communicate with external systems or extensions cleanly.** + +Some performance-sensitive or security-sensitive features could be implemented outside the Node.js layer, for example in Rust or C++. In that case, the adapter becomes the communication boundary between the Node.js orchestration layer and the external implementation. + +The Node.js pipeline and modules can still orchestrate the workflow, while adapters handle the integration details. + +If the CLI later needs to rewrite part of the core, the new core can reuse these extensions naturally as long as the adapter boundary remains stable and well-defined. + +### Adapter example + +A `DiamondInspectAdapter` can be used to read Diamond introspection data without coupling modules directly to a specific RPC library. + +The adapter exposes an interface: + +```ts + +interface DiamondInspectAdapter { + facetAddresses(address: string): Promise; +} + +``` + +The implementation can use `viem`, `ethers.js`, or another RPC library internally: + +```ts + +class ViemDiamondInspectAdapter implements DiamondInspectAdapter { + constructor(private readonly client: ViemClient) {} + + async facetAddresses(address: string): Promise { + const result = await this.client.readContract({ + address: address as `0x${string}`, + abi: IDiamondInspectAbi, + functionName: "facetAddresses", + }); + + return [...result].map(String); + } +} + +``` + +The module only depends on the interface: + +```ts + +type DiamondInspectDeps = { + diamondInspect: DiamondInspectAdapter; +}; + +const DiamondInspect = { + async readFacetAddresses( + ctx: ComposeContext, + { diamondInspect }: DiamondInspectDeps + ): Promise { + const facetAddresses = await diamondInspect.facetAddresses( + ctx.param.address as string + ); + + ctx.state.diamondInspect = { + success: true, + result: { + facetAddresses, + }, + error: null, + }; + + return ctx; + }, +}; + +``` + +The pipeline asks the dependency resolver to create the adapter from the registry, then passes the resolved dependency into the module: + +```ts + +const chain = ctx.config.chains[ctx.param.chain]; + +const deps = (await DependencyResolver.resolve([ + { + key: DependencyKey.DiamondInspect, + params: { + rpcUrl: chain.rpc, + chainId: chain.chainId, + }, + }, +])) as DiamondInspectDeps; + +ctx = await DiamondInspect.readFacetAddresses(ctx, deps); + +``` + +If Compose later switches from `viem` to `ethers.js`, only the `ViemDiamondInspectAdapter` implementation needs to change. + +### Modules and Adapters + +Modules and adapters have a special relationship. Both are grouped units of work that help handle a real feature or concern. + +- A module handles internal data and business logic. + +- An adapter handles communication with the external world. + +Together, they allow the CLI to implement a feature without coupling the internal logic directly to external libraries, tools, APIs, or runtimes. + +However, this boundary needs to be balanced carefully. + +If an adapter contains too much business logic, such as validating domain input or enforcing feature-specific output constraints, it can become too specific to one module and hard to reuse elsewhere. + +On the other hand, if an adapter is too thin and only mirrors the external library API, then many modules can use it, but replacing the underlying library may still force changes across the codebase. In that case, the adapter loses part of its value. + +A balanced approach is to split responsibilities by feature: + +- The module owns the feature logic and validation. + +- The adapter owns the external communication and library-specific details. + +This gives us a clean boundary without turning adapters into hidden business modules. + +Adapters and interfaces are powerful concepts, but overusing them can create a lot of unnecessary code. So they should be used mainly for features that need flexibility or may change over time, such as RPC clients or external extensions. + +For stable dependencies that are unlikely to change often, such as basic CLI parsing or filesystem access, it may be more practical to use a simple module/ utils and accept some coupling. + +## Dependency Resolver and Registry + +The dependency resolver is a special module that provides a lightweight mechanism for resolving adapter dependencies at the pipeline level. + +It is similar to dependency injection in the sense that dependencies are still defined explicitly in one place. However, instead of letting a container construct and inject dependencies automatically, the pipeline resolves the adapter dependencies it needs at the stage where they are needed and passes them into modules explicitly. + +This concept is useful for a CLI because it gives us the convenience of separating adapter definitions from the main workflow, while avoiding the burden of loading or constructing every dependency upfront. + +![Resolver](./resources/resolver.png) + +- The Registry is the single source of truth for mapping dependency keys to adapter factories. + +- Resolver creates adapters from explicit dependency requests. + +- Pipeline decides which adapter dependencies are needed for each stage. + +- Module receives the resolved adapters through its function signature. + +### Example + +Registry Example + +```ts + +// Dependency keys are also used as output field names in the resolved deps object. +enum DependencyKey { + DiamondInspect = "diamondInspect", +} + +type DependencyParams = Record; + +type DependencyFactory = ( + params?: DependencyParams +) => Promise | T; + +type DependencyRequest = { + key: DependencyKey; + params?: DependencyParams; +}; + +// Registry maps dependency keys to adapter factories. +const DependencyRegistry: Record = { + [DependencyKey.DiamondInspect]: (params) => { + const rpcUrl = params?.rpcUrl as string; + const chainId = params?.chainId as number; + + const client = createViemClient({ + rpcUrl, + chainId, + }); + + return new ViemDiamondInspectAdapter(client); + }, +}; + +``` + +Dependency Resolver Example + +```ts + +const DependencyResolver = { + async resolve( + requests: DependencyRequest[] + ): Promise> { + // Resolved dependencies are returned as an object keyed by DependencyKey values. + const deps: Record = {}; + + for (const request of requests) { + const factory = DependencyRegistry[request.key]; + + if (!factory) { + throw new Error(`Dependency factory not found: ${request.key}`); + } + + // Create the adapter only when the pipeline explicitly requests it. + deps[request.key] = await factory(request.params); + } + + return deps; + }, +}; + +``` + +## Utils + +Utils are shared mechanical helpers. + +They are not a workflow component. They are a supporting source layer for small reusable operations that do not carry Compose business meaning. + +```txt +src/ + utils/ + files.ts + regex.ts + ... +``` + +Examples: + +- file helpers +- regex helpers +- string normalization helpers +- generic JSON helpers +- small path helpers + +Do not put Compose domain logic in utils. + +A util should not depend on: + +- `ComposeContext` +- `BasesCatalog` +- `FacetEntry` +- module state +- pipelines +- adapters +- resolver + +If a helper understands selected facets, bases, selector ownership, storage layout rules, scaffold categories, or validation rules, it belongs in a module instead of utils. + +### Utils Workflow + +Before adding a new helper: + +1. Check the existing `src/utils` directory. +2. Reuse an existing util if it already solves the problem. +3. If a close category exists, append the helper to that util file. +4. If no close category exists, create a small new util category. +5. Do not rewrite, delete, or change existing util behavior unless the task is explicitly utility maintenance. + +Main rule: + +```txt +Utils handle mechanics. +Modules handle meaning. +``` + +## Change Log + +| Date | Change | Rationale | +|---|---|---| +| 2026-06-16 | Added `Utils` as a supporting source layer. | AI usually creates small helpers for utility logic, which can add duplicate or fragmented helper functions across module files. The utils layer defines a mechanism to organize utility logic effectively without dumping everything into one object or scattering fragmented functions across the codebase. | diff --git a/cli/project-template/src/adapters/hashingAdapter.ts b/cli/project-template/src/adapters/hashingAdapter.ts new file mode 100644 index 00000000..297362ef --- /dev/null +++ b/cli/project-template/src/adapters/hashingAdapter.ts @@ -0,0 +1,12 @@ +import { keccak256, stringToBytes, type Hex } from "viem"; + +export interface HashingAdapterInterface { + keccak256(value: string): Hex; +} + +export const HashingAdapter: HashingAdapterInterface = { + // Hash a UTF-8 string using keccak256 and return the hex digest. + keccak256(value: string): Hex { + return keccak256(stringToBytes(value)); + }, +}; diff --git a/cli/project-template/src/context/context.ts b/cli/project-template/src/context/context.ts new file mode 100644 index 00000000..4062243c --- /dev/null +++ b/cli/project-template/src/context/context.ts @@ -0,0 +1,18 @@ +import { ComposeContext } from "./types"; + +export const Context = { + // Create a fresh command context with empty params, config, state, and successful status. + create(): ComposeContext { + return { + param: {}, + config: {}, + state: {}, + status: { + success: true, + stopped: false, + failedAt: null, + error: null, + }, + }; + }, +}; diff --git a/cli/project-template/src/context/types.ts b/cli/project-template/src/context/types.ts new file mode 100644 index 00000000..eec4b3e4 --- /dev/null +++ b/cli/project-template/src/context/types.ts @@ -0,0 +1,31 @@ +export type ComposeError = { + code: string; + message: string; + nativeError: unknown | null; +}; + +export type ModuleState = { + success: boolean; + result: T | null; + error: ComposeError | null; +}; + +export type ExecutionStatus = { + success: boolean; + stopped: boolean; + failedAt: string | null; + error: ComposeError | null; +}; + +export type ChildPipelineState = { + success: boolean; + state: Record; + status: ExecutionStatus; +}; + +export type ComposeContext = { + param: Record; + config: Record; + state: Record; + status: ExecutionStatus; +}; diff --git a/cli/project-template/src/index.ts b/cli/project-template/src/index.ts new file mode 100644 index 00000000..02fba2a1 --- /dev/null +++ b/cli/project-template/src/index.ts @@ -0,0 +1,20 @@ +import { Context } from "./context/context"; +import { ComposeContext } from "./context/types"; +import { EntryPipeline } from "./pipelines/entryPipeline"; + +// Create the command context, run the entry pipeline, and set the process exit code. +async function main(argv: string[]): Promise { + const ctx: ComposeContext = Context.create(); + ctx.param.argv = argv; + + const result = await EntryPipeline.execute(ctx); + if (!result.status.success) { + process.exitCode = 1; + } +} + +main(process.argv.slice(2)).catch((error: unknown) => { + // Keep failure handling simple in the template. + console.error("Unhandled error:", error); + process.exitCode = 1; +}); diff --git a/cli/project-template/src/modules/configModule.ts b/cli/project-template/src/modules/configModule.ts new file mode 100644 index 00000000..d042ef0e --- /dev/null +++ b/cli/project-template/src/modules/configModule.ts @@ -0,0 +1,102 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { ComposeContext } from "../context/types"; + +export type FacetEntry = { + path: string; +}; + +export type BaseDefinition = { + label: string; + required: Record; + optional: Record; +}; + +type BaseManifest = Record; + +export type BasesCatalog = { + globals: { + diamond?: BaseDefinition; + libraries?: BaseDefinition; + examples?: BaseDefinition; + }; + features: Record; +}; + +// ===================== +// Helper +// ===================== + +const BASE_ORDER = new Map([ + ["erc-20", 20], + ["erc-721", 721], + ["erc-721-enumerable", 722], + ["erc-1155", 1155], + ["erc-6909", 6909], +]); + +// Sort base features by Compose standard order, then by label for custom bases. +function sortBaseFeatures(features: Record): Record { + return Object.fromEntries( + Object.entries(features).sort(([leftKey, leftDefinition], [rightKey, rightDefinition]) => { + const leftOrder = BASE_ORDER.get(leftKey) ?? Number.MAX_SAFE_INTEGER; + const rightOrder = BASE_ORDER.get(rightKey) ?? Number.MAX_SAFE_INTEGER; + + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + + return leftDefinition.label.localeCompare(rightDefinition.label); + }), + ); +} + +// ===================== +// Modules +// ===================== + +export const ConfigModule = { + // Load Compose-owned base, diamond, library, and example metadata from bases/. + async loadBasesCatalog(ctx: ComposeContext): Promise { + const basesRoot = path.resolve(process.cwd(), "bases"); + const entries = await fs.readdir(basesRoot, { withFileTypes: true }); + + const globals: BasesCatalog["globals"] = {}; + const features: Record = {}; + + for (const entry of entries) { + if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== ".json") { + continue; + } + + const manifestPath = path.join(basesRoot, entry.name); + const raw = await fs.readFile(manifestPath, "utf8"); + const manifest = JSON.parse(raw) as BaseManifest; + + for (const [key, definition] of Object.entries(manifest)) { + if (key === "diamond" || key === "libraries" || key === "examples") { + globals[key] = definition; + continue; + } + + features[key] = definition; + } + } + + const catalog = { globals, features: sortBaseFeatures(features) }; + + ctx.config.bases = catalog; + ctx.state.config = { + success: true, + result: { + baseCount: Object.keys(catalog.features).length, + hasDiamondCatalog: Boolean(globals.diamond), + hasLibrariesCatalog: Boolean(globals.libraries), + hasExamplesCatalog: Boolean(globals.examples), + }, + error: null, + }; + + return ctx; + }, +}; diff --git a/cli/project-template/src/modules/entryModule.ts b/cli/project-template/src/modules/entryModule.ts new file mode 100644 index 00000000..1340e54c --- /dev/null +++ b/cli/project-template/src/modules/entryModule.ts @@ -0,0 +1,434 @@ +import { ComposeContext, ModuleState } from "../context/types"; +import { BasesCatalog } from "./configModule"; +import { cyan, red, yellow } from "../utils/terminal"; + +// ===================== +// Helper +// ===================== + +const COMPOSE_DOCS_URL = "https://compose.diamonds/"; +const VERSION = "0.1.0"; + +const COMPOSE_HEADER = ` + _____ ____ __ __ _____ ____ _____ ______ _____ _ _____ + / ____/ __ \\| \\/ | __ \\ / __ \\ / ____| ____| / ____| | |_ _| + | | | | | | \\ / | |__) | | | | (___ | |__ | | | | | | + | | | | | | |\\/| | ___/| | | |\\___ \\| __| | | | | | | + | |___| |__| | | | | | | |__| |____) | |____ | |____| |____ _| |_ + \\_____\\____/|_| |_|_| \\____/|_____/|______| \\_____|______|_____| + + `; + +const HELP_TEXT = ` +Compose CLI v${VERSION} + +Scaffolds Diamond-based projects using the Compose Library + +Usage: + compose init [options] + compose validate [options] + compose inspect [options] + compose --help + +Options: + --framework + --starter + --out + --help + +For more information about Compose, see: ${COMPOSE_DOCS_URL} +`; + +type PromptApi = { + checkbox: ( + config: { + message: string; + choices: readonly { name: string; value: Value; checked?: boolean }[]; + theme?: { + prefix?: string | { idle?: string; done?: string }; + icon?: { + checked?: string; + unchecked?: string; + cursor?: string; + disabledChecked?: string; + disabledUnchecked?: string; + }; + style?: { + keysHelpTip?: (keys: [key: string, action: string][]) => string | undefined; + }; + }; + }, + ) => Promise; + select: ( + config: { + message: string; + choices: readonly { name: string; value: Value }[]; + default?: Value; + theme?: { + prefix?: string | { idle?: string; done?: string }; + icon?: { + cursor?: string; + }; + style?: { + keysHelpTip?: (keys: [key: string, action: string][]) => string | undefined; + }; + }; + }, + ) => Promise; +}; + +type SelectorExportIssue = { + facetName: string; + path: string; + missingExports: string[]; + extraExports: string[]; +}; + +type SelectorExportValidationResult = { + checkedFacets: number; + issues: SelectorExportIssue[]; +}; + +type SelectorOwner = { + facetName: string; + path: string; + functionName: string; + signature: string; +}; + +type SelectorCollision = { + selector: string; + owners: SelectorOwner[]; +}; + +type SelectorCollisionValidationResult = { + checkedFacets: number; + collisions: SelectorCollision[]; +}; + +type IdentifierCollisionOwner = { + facetName: string; + path: string; + slot: string; + layout: string[]; + source: "erc8042" | "slot-assignment"; + structName: string | null; +}; + +type IdentifierCollision = { + identifier: string; + owners: IdentifierCollisionOwner[]; +}; + +type IdentifierCollisionValidationResult = { + checkedFacets: number; + collisions: IdentifierCollision[]; +}; + +type FacetScanWarning = { + facetName: string; + path: string; + warnings: string[]; +}; + +type FacetScanResult = { + facets: FacetScanWarning[]; +}; + +const checkboxTheme = { + prefix: "", + icon: { + checked: "[✓]", + unchecked: "[ ]", + cursor: ">", + disabledChecked: "[✓]", + disabledUnchecked: "[ ]", + }, + style: { + keysHelpTip: () => undefined, + }, +} as const; + +const selectTheme = { + prefix: "", + icon: { + cursor: ">", + }, + style: { + keysHelpTip: () => undefined, + }, +} as const; + +// Load Inquirer lazily so non-interactive commands do not pay prompt startup cost. +async function loadPrompts(): Promise { + return (await import("@inquirer/prompts")) as unknown as PromptApi; +} + +// Read selector export validation state from context with a typed result. +function getSelectorExportValidationState( + ctx: ComposeContext, +): ModuleState | null { + return (ctx.state.validationSelectorExports as ModuleState | undefined) ?? null; +} + +// Read selector collision validation state from context with a typed result. +function getSelectorCollisionValidationState( + ctx: ComposeContext, +): ModuleState | null { + return ( + ctx.state.validationSelectorCollisions as ModuleState | undefined + ) ?? null; +} + +// Read identifier collision validation state from context with a typed result. +function getIdentifierCollisionValidationState( + ctx: ComposeContext, +): ModuleState | null { + return ( + ctx.state.validationIdentifierCollisions as ModuleState | undefined + ) ?? null; +} + +// Read facet scan state so warnings can be reported before hard failures. +function getFacetScanState(ctx: ComposeContext): ModuleState | null { + return (ctx.state.facetScan as ModuleState | undefined) ?? null; +} + +// ===================== +// Modules +// ===================== + +export const EntryModule = { + // Print the Compose welcome header shown by interactive init. + async showComposeHeader(ctx: ComposeContext): Promise { + console.log(cyan(COMPOSE_HEADER)); + console.log(cyan("Scaffold your diamond smart contracts project with Compose")); + console.log(cyan(`Explore our library: ${COMPOSE_DOCS_URL}\n`)); + + ctx.state.welcome = { + success: true, + result: { + header: COMPOSE_HEADER, + docsUrl: COMPOSE_DOCS_URL, + }, + error: null, + }; + + return ctx; + }, + + // Print top-level command help for empty CLI invocations. + async showHelp(ctx: ComposeContext): Promise { + console.log(HELP_TEXT.trim()); + + ctx.state.entry = { + success: true, + result: { + helpText: HELP_TEXT.trim(), + }, + error: null, + }; + + return ctx; + }, + + // Render validation warnings and fail-fast validation reports. + async showReport(ctx: ComposeContext): Promise { + const facetScan = getFacetScanState(ctx); + const scanWarnings = (facetScan?.result?.facets ?? []) + .map((facet) => ({ + facetName: facet.facetName, + path: facet.path, + warnings: facet.warnings, + })) + .filter((facet) => facet.warnings.length > 0); + + if (scanWarnings.length > 0) { + console.warn(yellow("\nValidation warnings")); + for (const facet of scanWarnings) { + console.warn(`\n${facet.facetName}`); + console.warn(` ${facet.path}`); + for (const warning of facet.warnings) { + console.warn(` ${warning}`); + } + } + } + + const selectorExportValidation = getSelectorExportValidationState(ctx); + + if (selectorExportValidation && !selectorExportValidation.success) { + console.error(red("\nValidation failed")); + console.error(red(selectorExportValidation.error?.message ?? "Validation failed.")); + + for (const issue of selectorExportValidation.result?.issues ?? []) { + console.error(`\n${issue.facetName}`); + console.error(` ${issue.path}`); + + if (issue.missingExports.length > 0) { + console.error(` Missing exports: ${issue.missingExports.join(", ")}`); + } + + if (issue.extraExports.length > 0) { + console.error(` Extra exports: ${issue.extraExports.join(", ")}`); + } + } + + ctx.status = { + success: false, + stopped: true, + failedAt: "validationSelectorExports", + error: selectorExportValidation.error, + }; + + return ctx; + } + + const selectorCollisionValidation = getSelectorCollisionValidationState(ctx); + + if (selectorCollisionValidation && !selectorCollisionValidation.success) { + console.error(red("\nValidation failed")); + console.error(red(selectorCollisionValidation.error?.message ?? "Validation failed.")); + + for (const collision of selectorCollisionValidation.result?.collisions ?? []) { + console.error(`\n${collision.selector}`); + for (const owner of collision.owners) { + console.error(` ${owner.facetName}: ${owner.signature}`); + console.error(` ${owner.path}`); + } + } + + ctx.status = { + success: false, + stopped: true, + failedAt: "validationSelectorCollisions", + error: selectorCollisionValidation.error, + }; + + return ctx; + } + + const identifierCollisionValidation = getIdentifierCollisionValidationState(ctx); + + if (identifierCollisionValidation && !identifierCollisionValidation.success) { + console.error(red("\nValidation failed")); + console.error(red(identifierCollisionValidation.error?.message ?? "Validation failed.")); + + for (const collision of identifierCollisionValidation.result?.collisions ?? []) { + console.error(`\n${collision.identifier}`); + for (const owner of collision.owners) { + console.error(` ${owner.facetName}: [${owner.layout.join(", ")}]`); + console.error(` ${owner.path}`); + } + } + + ctx.status = { + success: false, + stopped: true, + failedAt: "validationIdentifierCollisions", + error: identifierCollisionValidation.error, + }; + } + + return ctx; + }, + + // Run the no-argument interactive init prompt flow. + async runInitInteractive(ctx: ComposeContext): Promise { + const { checkbox, select } = await loadPrompts(); + const catalog = ctx.config.bases as BasesCatalog; + const featureChoices = Object.entries(catalog.features).map(([key, definition]) => ({ + name: definition.label, + value: key, + })); + + const framework = await select({ + message: "Select project framework:", + choices: [ + { name: "Foundry", value: "foundry" }, + { name: "Hardhat", value: "hardhat" }, + ] as const, + default: "foundry", + theme: selectTheme, + }); + + const selectedBaseKey = await select({ + message: "Select base:", + choices: featureChoices, + theme: selectTheme, + }); + + const selectedBase = catalog.features[selectedBaseKey]; + + const mergedRequiredFacets = { + ...(catalog.globals.diamond?.required ?? {}), + ...(catalog.globals.libraries?.required ?? {}), + ...selectedBase.required, + }; + + const availableLibraryFacets = Object.fromEntries( + [ + ...Object.entries(catalog.globals.diamond?.optional ?? {}), + ...Object.entries(catalog.globals.libraries?.optional ?? {}), + ].filter( + ([facetName]) => !(facetName in mergedRequiredFacets), + ), + ); + + const libraryChoices = Object.keys(availableLibraryFacets).map((facetName) => ({ + name: facetName, + value: facetName, + })); + + const selectedLibraries = await checkbox({ + message: "Select Compose library facets:", + choices: libraryChoices, + theme: checkboxTheme, + }); + + const extensionChoices = Object.keys(selectedBase.optional).map((facetName) => ({ + name: facetName, + value: facetName, + })); + + const selectedExtensions = await checkbox({ + message: "Select extension facets:", + choices: extensionChoices, + theme: checkboxTheme, + }); + + const localExampleChoices = Object.keys(catalog.globals.examples?.optional ?? {}).map((facetName) => ({ + name: facetName, + value: facetName, + })); + + const selectedLocalExamples = await checkbox({ + message: "Select local example facets:", + choices: localExampleChoices, + theme: checkboxTheme, + }); + + ctx.param.framework = framework; + ctx.param.base = selectedBaseKey; + ctx.param.extensions = selectedExtensions; + ctx.param.libraries = selectedLibraries; + ctx.param.localExamples = selectedLocalExamples; + + ctx.config.bases = catalog; + ctx.state.entry = { + success: true, + result: { + framework, + selectedBaseKey, + selectedBaseLabel: selectedBase.label, + selectedLibraries, + selectedExtensions, + selectedLocalExamples, + requiredFacets: Object.keys(mergedRequiredFacets), + availableLibraryFacets: Object.keys(availableLibraryFacets), + }, + error: null, + }; + + return ctx; + }, +}; diff --git a/cli/project-template/src/modules/foundryProjectModule.ts b/cli/project-template/src/modules/foundryProjectModule.ts new file mode 100644 index 00000000..7652df32 --- /dev/null +++ b/cli/project-template/src/modules/foundryProjectModule.ts @@ -0,0 +1,33 @@ +import path from "node:path"; +import { ComposeContext } from "../context/types"; +import { pathExists } from "../utils/files"; + +// ===================== +// Modules +// ===================== + +export const FoundryProjectModule = { + // Resolve whether init should write into the current Foundry project or a new folder. + async resolveProjectRoot(ctx: ComposeContext): Promise { + const projectName = String(ctx.param.projectName ?? "my-diamond"); + const outDir = String(ctx.param.outDir ?? process.cwd()); + const currentFoundryToml = path.resolve(process.cwd(), "foundry.toml"); + const projectRoot = (await pathExists(currentFoundryToml)) + ? process.cwd() + : path.resolve(outDir, projectName); + + ctx.param.projectRoot = projectRoot; + ctx.state.foundryProject = { + success: true, + result: { + projectName, + outDir, + projectRoot, + usedCurrentDirectory: projectRoot === process.cwd(), + }, + error: null, + }; + + return ctx; + }, +}; diff --git a/cli/project-template/src/modules/pipelineBuilderModule.ts b/cli/project-template/src/modules/pipelineBuilderModule.ts new file mode 100644 index 00000000..42486380 --- /dev/null +++ b/cli/project-template/src/modules/pipelineBuilderModule.ts @@ -0,0 +1,55 @@ +import { ComposeContext } from "../context/types"; +import { FoundryInitPipeline } from "../pipelines/foundryInitPipeline"; + +// ===================== +// Modules +// ===================== + +export const PipelineBuilderModule = { + // Route the parsed command and framework to the correct command pipeline. + async route(ctx: ComposeContext): Promise { + const command = String(ctx.param.command ?? ""); + const args = (ctx.param.args as string[]) ?? []; + + let framework = String(ctx.param.framework ?? "foundry").toLowerCase(); + for (let i = 0; i < args.length; i++) { + if (args[i] === "--framework" && args[i + 1]) { + framework = args[i + 1].toLowerCase(); + break; + } + } + + switch (ctx.param.command) { + case "init": + ctx.state.commandSelected = { + success: true, + result: { command, framework }, + error: null, + }; + + if (framework === "foundry") { + return FoundryInitPipeline.execute(ctx); + } + ctx.state.commandRouting = { + success: false, + result: null, + error: { + code: "UNSUPPORTED_FRAMEWORK", + message: `Init pipeline is not implemented for framework: ${framework}`, + nativeError: null, + }, + }; + return ctx; + case "validate": + case "inspect": + ctx.state.commandSelected = { + success: true, + result: { command }, + error: null, + }; + return ctx; + default: + return ctx; + } + }, +}; diff --git a/cli/project-template/src/modules/scaffoldingModule.ts b/cli/project-template/src/modules/scaffoldingModule.ts new file mode 100644 index 00000000..24a5ecd1 --- /dev/null +++ b/cli/project-template/src/modules/scaffoldingModule.ts @@ -0,0 +1,516 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { ComposeContext } from "../context/types"; +import { BasesCatalog, FacetEntry } from "./configModule"; +import { + copyFileIfMissing, + resolveLocalSolidityImportClosure, + writeFileIfMissing, +} from "../utils/files"; +import { escapeRegExp } from "../utils/regex"; +import { + parseExportedSelectorNames, + parseSolidityFunctions, + removeSolidityComments, + type SolidityFunctionInfo, +} from "../utils/solidityText"; + +// ===================== +// Helper +// ===================== + +type SeedFile = { + source: string; + target: string; +}; + +type SelectedFacetSource = + | "diamond-required" + | "diamond-optional" + | "library-required" + | "base-required" + | "library-optional" + | "extension" + | "local-example"; + +type SelectedFacet = { + name: string; + source: SelectedFacetSource; + entry: FacetEntry; +}; + +type StorageLayoutInfo = { + slot: string; + layout: string[]; + source: "erc8042" | "slot-assignment"; + structName: string | null; +}; + +type FacetScanResult = { + facetName: string; + source: SelectedFacetSource; + path: string; + contractName: string | null; + functions: SolidityFunctionInfo[]; + exportedSelectors: string[]; + missingExports: string[]; + extraExports: string[]; + storageLayouts: StorageLayoutInfo[]; + warnings: string[]; +}; + +// Extract storage layouts using M1's annotation-first, slot-assignment fallback rule. +function parseStorageLayouts(source: string): { layouts: StorageLayoutInfo[]; warnings: string[] } { + const cleanSource = removeSolidityComments(source); + const layouts: StorageLayoutInfo[] = []; + const warnings: string[] = []; + const structs = parseStructs(cleanSource); + const annotationPattern = /@custom:storage-location\s+erc8042:([A-Za-z0-9_.:-]+)[\s\S]*?\bstruct\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/g; + let match: RegExpExecArray | null; + + while ((match = annotationPattern.exec(source)) !== null) { + layouts.push(...buildStorageLayouts(match[1], match[2], structs, "erc8042")); + } + + if (layouts.length > 0) { + return { layouts, warnings }; + } + + const inferredLayouts = parseSlotAssignmentLayouts(cleanSource, structs); + if (inferredLayouts.length > 0) { + warnings.push( + "Missing `@custom:storage-location` annotation -- storage slot inferred from `.slot :=` pattern. Add the annotation for reliable detection.", + ); + return { layouts: inferredLayouts, warnings }; + } + + warnings.push("Cannot determine storage layout -- no ERC-8042 annotation or recognized storage pattern found."); + return { layouts: [], warnings }; +} + +// Collect struct bodies by name so storage layout parsing can resolve fields. +function parseStructs(source: string): Map { + const structs = new Map(); + const structPattern = /\bstruct\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/g; + let match: RegExpExecArray | null; + + while ((match = structPattern.exec(source)) !== null) { + const bodyStart = structPattern.lastIndex; + const bodyEnd = findMatchingBrace(source, bodyStart - 1); + if (bodyEnd === -1) { + continue; + } + + structs.set(match[1], source.slice(bodyStart, bodyEnd)); + structPattern.lastIndex = bodyEnd + 1; + } + + return structs; +} + +// Find the closing brace that matches the given opening brace index. +function findMatchingBrace(source: string, openBraceIndex: number): number { + let depth = 0; + for (let i = openBraceIndex; i < source.length; i++) { + if (source[i] === "{") { + depth++; + continue; + } + + if (source[i] === "}") { + depth--; + if (depth === 0) { + return i; + } + } + } + + return -1; +} + +// Build the storage layout record for one namespace and struct pair. +function buildStorageLayouts( + slot: string, + structName: string, + structs: Map, + source: StorageLayoutInfo["source"], +): StorageLayoutInfo[] { + const root = buildStructLayout(structName, structs); + return [ + { + slot, + layout: root, + source, + structName, + }, + ]; +} + +// Infer storage layout records from accessor functions that assign `.slot`. +function parseSlotAssignmentLayouts( + source: string, + structs: Map, +): StorageLayoutInfo[] { + const layouts: StorageLayoutInfo[] = []; + const functionPattern = /\bfunction\b[\s\S]*?\{[\s\S]*?\bassembly\s*\{[\s\S]*?\.slot\s*:=[\s\S]*?\}[\s\S]*?\n\s*\}/g; + let match: RegExpExecArray | null; + + while ((match = functionPattern.exec(source)) !== null) { + const functionSource = match[0]; + const structName = parseReturnedStorageStruct(functionSource); + const slotExpression = parseSlotAssignmentExpression(functionSource); + + if (!structName || !slotExpression) { + continue; + } + + const slot = resolveSlotNamespace(slotExpression, functionSource, source); + if (!slot || !structs.has(structName)) { + continue; + } + + layouts.push(...buildStorageLayouts(slot, structName, structs, "slot-assignment")); + } + + return dedupeStorageLayouts(layouts); +} + +// Read the storage struct returned by an accessor function. +function parseReturnedStorageStruct(functionSource: string): string | null { + const match = functionSource.match(/\breturns\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s+storage\s+[A-Za-z_][A-Za-z0-9_]*\s*\)/); + return match?.[1] ?? null; +} + +// Read the expression assigned to `.slot` inside an assembly block. +function parseSlotAssignmentExpression(functionSource: string): string | null { + const match = functionSource.match(/\.slot\s*:=\s*([A-Za-z_][A-Za-z0-9_]*|"[^"]+"|'[^']+'|0x[0-9a-fA-F]+)\b/); + return match?.[1] ?? null; +} + +// Resolve a `.slot` assignment expression back to its namespace string. +function resolveSlotNamespace( + expression: string, + functionSource: string, + fullSource: string, +): string | null { + const directString = expression.match(/^["']([^"']+)["']$/); + if (directString) { + return directString[1]; + } + + const directConstant = resolveConstantNamespace(expression, fullSource); + if (directConstant) { + return directConstant; + } + + const localAssignmentPattern = new RegExp(`\\bbytes32\\s+${escapeRegExp(expression)}\\s*=\\s*([^;]+);`); + const localAssignment = functionSource.match(localAssignmentPattern); + if (!localAssignment) { + return null; + } + + const localExpression = localAssignment[1].trim(); + const localKeccak = parseKeccakNamespace(localExpression); + if (localKeccak) { + return localKeccak; + } + + return resolveConstantNamespace(localExpression, fullSource); +} + +// Resolve a bytes32 constant declaration into its keccak namespace. +function resolveConstantNamespace(name: string, source: string): string | null { + const constantPattern = new RegExp(`\\bbytes32\\s+(?:internal\\s+|private\\s+|public\\s+)?constant\\s+${escapeRegExp(name)}\\s*=\\s*([^;]+);`); + const match = source.match(constantPattern); + if (!match) { + return null; + } + + return parseKeccakNamespace(match[1]); +} + +// Extract the string namespace from a `keccak256("...")` expression. +function parseKeccakNamespace(expression: string): string | null { + const match = expression.match(/\bkeccak256\s*\(\s*["']([^"']+)["']\s*\)/); + return match?.[1] ?? null; +} + +// Remove duplicate storage layout records produced by repeated source patterns. +function dedupeStorageLayouts(layouts: StorageLayoutInfo[]): StorageLayoutInfo[] { + const seen = new Set(); + return layouts.filter((layout) => { + const key = `${layout.slot}:${layout.structName ?? ""}:${layout.layout.join(",")}`; + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); +} + +// Flatten a struct into the normalized storage type sequence used by validation. +function buildStructLayout( + structName: string, + structs: Map, +): string[] { + const body = structs.get(structName); + if (!body) { + return []; + } + + const layout: string[] = []; + + for (const field of parseStructFields(body)) { + const fieldType = normalizeStorageType(field.type); + + if (structs.has(fieldType)) { + const inlineLayout = buildStructLayout(fieldType, structs); + layout.push(...inlineLayout); + continue; + } + + layout.push(storageTypeToken(fieldType)); + } + + return layout; +} + +// Split a Solidity struct body into field type/name pairs. +function parseStructFields(body: string): { type: string; name: string }[] { + return body + .split(";") + .map((rawField) => rawField.trim()) + .filter(Boolean) + .map((field) => field.replace(/\s+/g, " ")) + .map((field) => { + const match = field.match(/^(.+)\s+([A-Za-z_][A-Za-z0-9_]*)$/); + if (!match) { + return null; + } + + return { type: match[1], name: match[2] }; + }) + .filter((field): field is { type: string; name: string } => field !== null); +} + +// Remove Solidity storage-location keywords from a type string. +function normalizeStorageType(type: string): string { + return type + .replace(/\b(calldata|memory|storage|indexed)\b/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +// Normalize a Solidity storage type for layout comparison. +function storageTypeToken(type: string): string { + let normalized = normalizeStorageType(type) + .replace(/\buint\b/g, "uint256") + .replace(/\bint\b/g, "int256") + .replace(/\s+/g, " ") + .trim(); + + normalized = stripStorageVariableNames(normalized); + normalized = normalized + .replace(/\s*=>\s*/g, "=>") + .replace(/\s*,\s*/g, ",") + .replace(/\s+/g, ""); + + return normalized; +} + +// Remove mapping parameter names so equivalent mapping types compare equally. +function stripStorageVariableNames(type: string): string { + let current = type; + let previous: string; + + do { + previous = current; + current = current.replace( + /\b([A-Za-z_][A-Za-z0-9_]*(?:[0-9]+)?(?:\[[^\]]*\])?)\s+[A-Za-z_][A-Za-z0-9_]*(?=\s*(?:=>|,|\)|$))/g, + "$1", + ); + } while (current !== previous); + + return current; +} + +// Resolve all facets selected by the current init context. +function getSelectedFacets(ctx: ComposeContext): SelectedFacet[] { + const catalog = ctx.config.bases as BasesCatalog; + const selectedBaseKey = String(ctx.param.base ?? ""); + const selectedBase = catalog.features[selectedBaseKey]; + const selectedLibraryNames = new Set((ctx.param.libraries as string[] | undefined) ?? []); + const selectedExtensionNames = new Set((ctx.param.extensions as string[] | undefined) ?? []); + const selectedLocalExampleNames = new Set((ctx.param.localExamples as string[] | undefined) ?? []); + + const selected: SelectedFacet[] = []; + + for (const [name, entry] of Object.entries(catalog.globals.diamond?.required ?? {})) { + selected.push({ name, entry, source: "diamond-required" }); + } + + for (const [name, entry] of Object.entries(catalog.globals.libraries?.required ?? {})) { + selected.push({ name, entry, source: "library-required" }); + } + + for (const [name, entry] of Object.entries(selectedBase.required)) { + selected.push({ name, entry, source: "base-required" }); + } + + for (const [name, entry] of Object.entries(catalog.globals.diamond?.optional ?? {})) { + if (selectedLibraryNames.has(name)) { + selected.push({ name, entry, source: "diamond-optional" }); + } + } + + for (const [name, entry] of Object.entries(catalog.globals.libraries?.optional ?? {})) { + if (selectedLibraryNames.has(name)) { + selected.push({ name, entry, source: "library-optional" }); + } + } + + for (const [name, entry] of Object.entries(selectedBase.optional)) { + if (selectedExtensionNames.has(name)) { + selected.push({ name, entry, source: "extension" }); + } + } + + for (const [name, entry] of Object.entries(catalog.globals.examples?.optional ?? {})) { + if (selectedLocalExampleNames.has(name)) { + selected.push({ name, entry, source: "local-example" }); + } + } + + return selected; +} + +// Choose the scaffold target directory based on the selected facet category. +function targetDirectoryForFacet(root: string, facet: SelectedFacet): string { + if (facet.source === "diamond-required" || facet.source === "diamond-optional") { + return path.join(root, "src", "diamond"); + } + + if (facet.source === "library-required" || facet.source === "library-optional") { + return path.join(root, "src", "libraries"); + } + + return path.join(root, "src", "facets"); +} + +// ===================== +// Modules +// ===================== + +export const ScaffoldingModule = { + // Scan selected facets for functions, exported selectors, and storage layouts. + async scanSelectedFacets(ctx: ComposeContext): Promise { + const selectedFacets = getSelectedFacets(ctx); + const results: FacetScanResult[] = []; + + for (const facet of selectedFacets) { + const resolvedPath = path.resolve(process.cwd(), facet.entry.path); + const source = await fs.readFile(resolvedPath, "utf8"); + const contractMatch = source.match(/\bcontract\s+([A-Za-z_][A-Za-z0-9_]*)\b/); + const functions = parseSolidityFunctions(source); + const exportedSelectors = parseExportedSelectorNames(source); + const exportedSet = new Set(exportedSelectors); + const functionNames = new Set(functions.map((fn) => fn.name)); + const warnings: string[] = []; + const storageScan = parseStorageLayouts(source); + + if (exportedSelectors.length === 0) { + warnings.push("exportSelectors() was not found or did not export any this..selector entries."); + } + warnings.push(...storageScan.warnings); + + results.push({ + facetName: facet.name, + source: facet.source, + path: facet.entry.path, + contractName: contractMatch?.[1] ?? null, + functions, + exportedSelectors, + missingExports: functions.filter((fn) => !exportedSet.has(fn.name)).map((fn) => fn.signature), + extraExports: exportedSelectors.filter((name) => !functionNames.has(name)), + storageLayouts: storageScan.layouts, + warnings, + }); + } + + ctx.state.facetScan = { + success: results.every((result) => result.missingExports.length === 0 && result.extraExports.length === 0), + result: { + facets: results, + facetCount: results.length, + selectorHashing: "not-computed", + onchain: false, + }, + error: null, + }; + + return ctx; + }, + + // Create the minimal Foundry project layout and copy selected facet sources. + async scaffoldFoundryLayout(ctx: ComposeContext): Promise { + const root = String(ctx.param.projectRoot ?? ""); + const selectedFacets = getSelectedFacets(ctx); + + await fs.mkdir(root, { recursive: true }); + await fs.mkdir(path.join(root, "src"), { recursive: true }); + await fs.mkdir(path.join(root, "src", "diamond"), { recursive: true }); + await fs.mkdir(path.join(root, "src", "libraries"), { recursive: true }); + await fs.mkdir(path.join(root, "src", "facets"), { recursive: true }); + await fs.mkdir(path.join(root, "script"), { recursive: true }); + await fs.mkdir(path.join(root, "test"), { recursive: true }); + + const allSeeds: SeedFile[] = selectedFacets.map((facet) => ({ + source: path.resolve(process.cwd(), facet.entry.path), + target: path.join(targetDirectoryForFacet(root, facet), path.basename(facet.entry.path)), + })); + const seedSources = allSeeds.map((x) => x.source); + const closure = await resolveLocalSolidityImportClosure(seedSources); + + const sourceToTarget = new Map(); + for (const seed of allSeeds) { + sourceToTarget.set(path.resolve(seed.source), seed.target); + } + + for (const file of closure) { + const directTarget = sourceToTarget.get(path.resolve(file)); + if (directTarget) { + await copyFileIfMissing(file, directTarget); + continue; + } + + // For transitive imports, co-locate with the importer directory in target by filename. + // This keeps relative import structure stable for local imports. + const parentSeed = allSeeds.find((seed) => file.startsWith(path.dirname(path.resolve(seed.source)))); + const baseTargetDir = parentSeed ? path.dirname(parentSeed.target) : path.join(root, "src"); + await copyFileIfMissing(file, path.join(baseTargetDir, path.basename(file))); + } + + const foundryToml = `[profile.default]\nsrc = "src"\nout = "out"\nlibs = ["lib"]\n`; + const remappings = "@perfect-abstractions/compose/=lib/compose/src/\n"; + const gitignore = "out/\ncache/\n"; + const readme = `# ${String(ctx.param.projectName)}\n\nGenerated by compose init (demo)\n`; + + await writeFileIfMissing(path.join(root, "foundry.toml"), foundryToml); + await writeFileIfMissing(path.join(root, "remappings.txt"), remappings); + await writeFileIfMissing(path.join(root, ".gitignore"), gitignore); + await writeFileIfMissing(path.join(root, "README.md"), readme); + + return ctx; + }, + + // Write the generated compose.json into the resolved project root. + async writeComposeConfig(ctx: ComposeContext): Promise { + const root = String(ctx.param.projectRoot ?? ""); + const composeJson = ctx.config.composeJson; + + await fs.writeFile(path.join(root, "compose.json"), `${JSON.stringify(composeJson, null, 2)}\n`, "utf8"); + + return ctx; + }, +}; diff --git a/cli/project-template/src/modules/validationModule.ts b/cli/project-template/src/modules/validationModule.ts new file mode 100644 index 00000000..57d5a4c8 --- /dev/null +++ b/cli/project-template/src/modules/validationModule.ts @@ -0,0 +1,297 @@ +import { ComposeContext, ModuleState } from "../context/types"; +import { HashingAdapterInterface } from "../adapters/hashingAdapter"; + +// ===================== +// Helper +// ===================== + +type FunctionInfo = { + name: string; + signature: string; + visibility: "external" | "public"; +}; + +type FacetScanResult = { + facetName: string; + path: string; + functions: FunctionInfo[]; + exportedSelectors: string[]; + missingExports: string[]; + extraExports: string[]; + storageLayouts: StorageLayoutInfo[]; +}; + +type FacetScanStateResult = { + facets: FacetScanResult[]; + facetCount: number; +}; + +type SelectorExportIssue = { + facetName: string; + path: string; + missingExports: string[]; + extraExports: string[]; +}; + +type SelectorOwner = { + facetName: string; + path: string; + functionName: string; + signature: string; +}; + +type SelectorCollision = { + selector: string; + owners: SelectorOwner[]; +}; + +type SelectorCollisionDeps = { + hashing: HashingAdapterInterface; +}; + +type StorageLayoutInfo = { + slot: string; + layout: string[]; + source: "erc8042" | "slot-assignment"; + structName: string | null; +}; + +type IdentifierCollisionOwner = { + facetName: string; + path: string; + slot: string; + layout: string[]; + source: StorageLayoutInfo["source"]; + structName: string | null; +}; + +type IdentifierCollision = { + identifier: string; + owners: IdentifierCollisionOwner[]; +}; + +// Read the facet scan result that all validation stages depend on. +function getFacetScanResult(ctx: ComposeContext): FacetScanStateResult | null { + const state = ctx.state.facetScan as ModuleState | undefined; + return state?.result ?? null; +} + +// Find facets whose exportSelectors list is incomplete or references missing functions. +function findSelectorExportIssues(facets: FacetScanResult[]): SelectorExportIssue[] { + return facets + .map((facet) => ({ + facetName: facet.facetName, + path: facet.path, + missingExports: facet.missingExports, + extraExports: facet.extraExports, + })) + .filter((issue) => issue.missingExports.length > 0 || issue.extraExports.length > 0); +} + +// Compute exported function selectors and group duplicate selector owners. +function findSelectorCollisions( + facets: FacetScanResult[], + hashing: HashingAdapterInterface, +): SelectorCollision[] { + const ownersBySelector = new Map(); + + for (const facet of facets) { + const functionsByName = new Map(facet.functions.map((fn) => [fn.name, fn])); + + for (const exportedName of facet.exportedSelectors) { + const fn = functionsByName.get(exportedName); + if (!fn) { + continue; + } + + const selector = hashing.keccak256(fn.signature).slice(0, 10); + const owners = ownersBySelector.get(selector) ?? []; + owners.push({ + facetName: facet.facetName, + path: facet.path, + functionName: fn.name, + signature: fn.signature, + }); + ownersBySelector.set(selector, owners); + } + } + + return [...ownersBySelector.entries()] + .map(([selector, owners]) => ({ selector, owners })) + .filter((collision) => new Set(collision.owners.map((owner) => owner.facetName)).size > 1); +} + +// Group storage layouts by identifier and keep only incompatible groups. +function findIdentifierCollisions(facets: FacetScanResult[]): IdentifierCollision[] { + const ownersByIdentifier = new Map(); + + for (const facet of facets) { + for (const storageLayout of facet.storageLayouts) { + const identifier = buildIdentifier(storageLayout.slot); + const owners = ownersByIdentifier.get(identifier) ?? []; + owners.push({ + facetName: facet.facetName, + path: facet.path, + slot: storageLayout.slot, + layout: storageLayout.layout, + source: storageLayout.source, + structName: storageLayout.structName, + }); + ownersByIdentifier.set(identifier, owners); + } + } + + return [...ownersByIdentifier.entries()] + .filter(([, owners]) => owners.length > 1) + .filter(([, owners]) => !areLayoutsPrefixCompatible(owners.map((owner) => owner.layout))) + .map(([identifier, owners]) => ({ identifier, owners })); +} + +// Build the identifier key used to group storage layouts. +function buildIdentifier(slot: string): string { + return slot; +} + +// Check whether all layouts are safe prefix extensions of the shortest layout. +function areLayoutsPrefixCompatible(layouts: string[][]): boolean { + if (layouts.length <= 1) { + return true; + } + + const sorted = [...layouts].sort((a, b) => a.length - b.length); + const shortest = sorted[0]; + + return sorted.every((layout) => isPrefix(shortest, layout)); +} + +// Return true when the first layout is a prefix of the second layout. +function isPrefix(prefix: string[], layout: string[]): boolean { + if (prefix.length > layout.length) { + return false; + } + + return prefix.every((value, index) => layout[index] === value); +} + +// ===================== +// Modules +// ===================== + +export const ValidationModule = { + // Validate that every public/external function is exported by exportSelectors. + async validateSelectorExports(ctx: ComposeContext): Promise { + const facetScan = getFacetScanResult(ctx); + + if (!facetScan) { + ctx.state.validationSelectorExports = { + success: false, + result: null, + error: { + code: "FACET_SCAN_MISSING", + message: "Facet scan must run before selector export validation.", + nativeError: null, + }, + }; + return ctx; + } + + const issues = findSelectorExportIssues(facetScan.facets); + const success = issues.length === 0; + + ctx.state.validationSelectorExports = { + success, + result: { + checkedFacets: facetScan.facetCount, + issues, + }, + error: success + ? null + : { + code: "SELECTOR_EXPORT_INVALID", + message: "One or more selected facets do not export all intended selectors.", + nativeError: null, + }, + }; + + return ctx; + }, + + // Detect duplicate 4-byte selectors across selected exported facet functions. + async detectSelectorCollisions( + ctx: ComposeContext, + { hashing }: SelectorCollisionDeps, + ): Promise { + const facetScan = getFacetScanResult(ctx); + + if (!facetScan) { + ctx.state.validationSelectorCollisions = { + success: false, + result: null, + error: { + code: "FACET_SCAN_MISSING", + message: "Facet scan must run before selector collision detection.", + nativeError: null, + }, + }; + return ctx; + } + + const collisions = findSelectorCollisions(facetScan.facets, hashing); + const success = collisions.length === 0; + + ctx.state.validationSelectorCollisions = { + success, + result: { + checkedFacets: facetScan.facetCount, + collisions, + }, + error: success + ? null + : { + code: "SELECTOR_COLLISION_DETECTED", + message: "Two or more selected facets export the same function selector.", + nativeError: null, + }, + }; + + return ctx; + }, + + // Detect incompatible storage layouts sharing the same storage identifier. + async detectIdentifierCollisions(ctx: ComposeContext): Promise { + const facetScan = getFacetScanResult(ctx); + + if (!facetScan) { + ctx.state.validationIdentifierCollisions = { + success: false, + result: null, + error: { + code: "FACET_SCAN_MISSING", + message: "Facet scan must run before identifier collision detection.", + nativeError: null, + }, + }; + return ctx; + } + + const collisions = findIdentifierCollisions(facetScan.facets); + const success = collisions.length === 0; + + ctx.state.validationIdentifierCollisions = { + success, + result: { + checkedFacets: facetScan.facetCount, + collisions, + }, + error: success + ? null + : { + code: "IDENTIFIER_COLLISION_DETECTED", + message: "Two or more selected facets use incompatible storage layouts for the same identifier.", + nativeError: null, + }, + }; + + return ctx; + }, +}; diff --git a/cli/project-template/src/pipelines/entryPipeline.ts b/cli/project-template/src/pipelines/entryPipeline.ts new file mode 100644 index 00000000..1c5fe4c6 --- /dev/null +++ b/cli/project-template/src/pipelines/entryPipeline.ts @@ -0,0 +1,48 @@ +import { ComposeContext } from "../context/types"; +import { ConfigModule } from "../modules/configModule"; +import { EntryModule } from "../modules/entryModule"; +import { PipelineBuilderModule } from "../modules/pipelineBuilderModule"; + +export const EntryPipeline = { + // Execute top-level CLI flow: parse command, run interactive entry work, route, and report. + async execute(ctx: ComposeContext): Promise { + + const argv = (ctx.param.argv as string[]) ?? []; + + if (argv.length === 0) { + ctx = await EntryModule.showHelp(ctx); + return ctx; + } + + ctx.param.command = argv[0] ?? ""; + ctx.param.args = argv.slice(1); + const args = (ctx.param.args as string[]) ?? []; + + if (ctx.param.command === "init" && args.length === 0) { + ctx = await EntryModule.showComposeHeader(ctx); + ctx = await ConfigModule.loadBasesCatalog(ctx); + ctx = await EntryModule.runInitInteractive(ctx); + } + + ctx = await PipelineBuilderModule.route(ctx); + ctx = await EntryModule.showReport(ctx); + + if (ctx.status.stopped) { + return ctx; + } + + if (!ctx.state.commandSelected && !ctx.state.commandRouting) { + ctx.state.commandRouting = { + success: false, + result: null, + error: { + code: "COMMAND_NOT_SUPPORTED", + message: "Unknown or missing command.", + nativeError: null, + }, + }; + } + + return ctx; + }, +}; diff --git a/cli/project-template/src/pipelines/foundryInitPipeline.ts b/cli/project-template/src/pipelines/foundryInitPipeline.ts new file mode 100644 index 00000000..eec48a68 --- /dev/null +++ b/cli/project-template/src/pipelines/foundryInitPipeline.ts @@ -0,0 +1,58 @@ +import { ComposeContext, ModuleState } from "../context/types"; +import { ValidationModule } from "../modules/validationModule"; +import { ScaffoldingModule } from "../modules/scaffoldingModule"; +import { FoundryProjectModule } from "../modules/foundryProjectModule"; +import { DependencyKey } from "../resolver/dependencyKey"; +import { DependencyResolver } from "../resolver/dependencyResolver"; + +export const FoundryInitPipeline = { + // Execute Foundry init by validating selected facets, resolving the project root, and scaffolding. + async execute(ctx: ComposeContext): Promise { + ctx = await ScaffoldingModule.scanSelectedFacets(ctx); + ctx = await ValidationModule.validateSelectorExports(ctx); + + const selectorExportValidation = ctx.state.validationSelectorExports as ModuleState | undefined; + if (selectorExportValidation && !selectorExportValidation.success) { + return ctx; + } + + const deps = await DependencyResolver.resolve([ + { key: DependencyKey.Hashing }, + ]); + + if (!deps.hashing) { + throw new Error("Hashing dependency was not resolved."); + } + + ctx = await ValidationModule.detectSelectorCollisions(ctx, { + hashing: deps.hashing, + }); + + const selectorCollisionValidation = ctx.state.validationSelectorCollisions as ModuleState | undefined; + if (selectorCollisionValidation && !selectorCollisionValidation.success) { + return ctx; + } + + ctx = await ValidationModule.detectIdentifierCollisions(ctx); + + const identifierCollisionValidation = ctx.state.validationIdentifierCollisions as ModuleState | undefined; + if (identifierCollisionValidation && !identifierCollisionValidation.success) { + return ctx; + } + + ctx = await FoundryProjectModule.resolveProjectRoot(ctx); + ctx = await ScaffoldingModule.scaffoldFoundryLayout(ctx); + ctx = await ScaffoldingModule.writeComposeConfig(ctx); + + ctx.state.initPipeline = { + success: true, + result: { + message: "Foundry demo project generated.", + projectRoot: ctx.param.projectRoot, + }, + error: null, + }; + + return ctx; + }, +}; diff --git a/cli/project-template/src/resolver/dependencyKey.ts b/cli/project-template/src/resolver/dependencyKey.ts new file mode 100644 index 00000000..8e4882ca --- /dev/null +++ b/cli/project-template/src/resolver/dependencyKey.ts @@ -0,0 +1,3 @@ +export enum DependencyKey { + Hashing = "hashing", +} diff --git a/cli/project-template/src/resolver/dependencyRegistry.ts b/cli/project-template/src/resolver/dependencyRegistry.ts new file mode 100644 index 00000000..8403fc9e --- /dev/null +++ b/cli/project-template/src/resolver/dependencyRegistry.ts @@ -0,0 +1,22 @@ +import { + HashingAdapter, + HashingAdapterInterface, +} from "../adapters/hashingAdapter"; +import { DependencyKey } from "./dependencyKey"; + +export type DependencyParams = Record; + +export type DependencyFactory = ( + params?: DependencyParams, +) => Promise | T; + +export type DependencyMap = { + [DependencyKey.Hashing]: HashingAdapterInterface; +}; + +// Map dependency keys to adapter factories used by the resolver. +export const DependencyRegistry: { + [Key in DependencyKey]: DependencyFactory; +} = { + [DependencyKey.Hashing]: () => HashingAdapter, +}; diff --git a/cli/project-template/src/resolver/dependencyResolver.ts b/cli/project-template/src/resolver/dependencyResolver.ts new file mode 100644 index 00000000..88a37bcc --- /dev/null +++ b/cli/project-template/src/resolver/dependencyResolver.ts @@ -0,0 +1,32 @@ +import { + DependencyMap, + DependencyParams, + DependencyRegistry, +} from "./dependencyRegistry"; +import { DependencyKey } from "./dependencyKey"; + +export type DependencyRequest = { + key: DependencyKey; + params?: DependencyParams; +}; + +export const DependencyResolver = { + // Resolve only the adapter dependencies explicitly requested by the pipeline. + async resolve( + requests: DependencyRequest[], + ): Promise> { + const deps: Partial = {}; + + for (const request of requests) { + const factory = DependencyRegistry[request.key]; + + if (!factory) { + throw new Error(`Dependency factory not found: ${request.key}`); + } + + deps[request.key] = await factory(request.params); + } + + return deps; + }, +}; diff --git a/cli/project-template/src/templates/access/AccessControl/Admin/AccessControlAdminFacet.sol b/cli/project-template/src/templates/access/AccessControl/Admin/AccessControlAdminFacet.sol new file mode 100644 index 00000000..51f590fc --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Admin/AccessControlAdminFacet.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract AccessControlAdminFacet { + /** + * @notice Emitted when the admin role for a role is changed. + * @param _role The role that was changed. + * @param _previousAdminRole The previous admin role. + * @param _newAdminRole The new admin role. + */ + event RoleAdminChanged(bytes32 indexed _role, bytes32 indexed _previousAdminRole, bytes32 indexed _newAdminRole); + + /** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ + error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + + /** + * @notice Storage slot identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("compose.accesscontrol"); + + /** + * @notice Storage struct for the AccessControl. + * @custom:storage-location erc8042:compose.accesscontrol + */ + struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; + } + + /** + * @notice Returns the storage for the AccessControl. + * @return s The storage for the AccessControl. + */ + function getStorage() internal pure returns (AccessControlStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Sets the admin role for a role. + * @param _role The role to set the admin for. + * @param _adminRole The new admin role to set. + * @dev Emits a {RoleAdminChanged} event. + * @custom:error AccessControlUnauthorizedAccount If the caller is not the current admin of the role. + */ + function setRoleAdmin(bytes32 _role, bytes32 _adminRole) external { + AccessControlStorage storage s = getStorage(); + bytes32 previousAdminRole = s.adminRole[_role]; + + /** + * Check if the caller is the current admin of the role. + */ + if (!s.hasRole[msg.sender][previousAdminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, previousAdminRole); + } + + s.adminRole[_role] = _adminRole; + emit RoleAdminChanged(_role, previousAdminRole, _adminRole); + } + + /** + * @notice Exports the selectors that are exposed by the facet. + * @return Selectors that are exported by the facet. + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.setRoleAdmin.selector); + } +} diff --git a/cli/project-template/src/templates/access/AccessControl/Admin/AccessControlAdminMod.sol b/cli/project-template/src/templates/access/AccessControl/Admin/AccessControlAdminMod.sol new file mode 100644 index 00000000..57c7794d --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Admin/AccessControlAdminMod.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Emitted when the admin role for a role is changed. + * @param _role The role that was changed. + * @param _previousAdminRole The previous admin role. + * @param _newAdminRole The new admin role. + */ +event RoleAdminChanged(bytes32 indexed _role, bytes32 indexed _previousAdminRole, bytes32 indexed _newAdminRole); + +/** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ +error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + +/* + * @notice Storage slot identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("compose.accesscontrol"); + +/** + * @notice Storage struct for the AccessControl. + * @custom:storage-location erc8042:compose.accesscontrol + */ +struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; +} + +/** + * @notice Returns the storage for the AccessControl. + * @return s The storage for the AccessControl. + */ +function getStorage() pure returns (AccessControlStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Sets the admin role for a role. + * @param _role The role to set the admin for. + * @param _adminRole The new admin role to set. + * @dev Emits a {RoleAdminChanged} event. + * @custom:error AccessControlUnauthorizedAccount If the caller is not the current admin of the role. + */ +function setRoleAdmin(bytes32 _role, bytes32 _adminRole) { + AccessControlStorage storage s = getStorage(); + bytes32 previousAdminRole = s.adminRole[_role]; + + /** + * Check if the caller is the current admin of the role. + */ + if (!s.hasRole[msg.sender][previousAdminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, previousAdminRole); + } + + s.adminRole[_role] = _adminRole; + emit RoleAdminChanged(_role, previousAdminRole, _adminRole); +} diff --git a/cli/project-template/src/templates/access/AccessControl/Batch/Grant/AccessControlGrantBatchFacet.sol b/cli/project-template/src/templates/access/AccessControl/Batch/Grant/AccessControlGrantBatchFacet.sol new file mode 100644 index 00000000..6183c03c --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Batch/Grant/AccessControlGrantBatchFacet.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract AccessControlGrantBatchFacet { + /** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ + error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + + /** + * @notice Emitted when a role is granted to an account. + * @param _role The role that was granted. + * @param _account The account that was granted the role. + * @param _sender The sender that granted the role. + */ + event RoleGranted(bytes32 indexed _role, address indexed _account, address indexed _sender); + + /** + * @notice Storage slot identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("compose.accesscontrol"); + + /** + * @notice Storage struct for the AccessControl. + * @custom:storage-location erc8042:compose.accesscontrol + */ + struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; + } + + /** + * @notice Returns the storage for the AccessControl. + * @return s The storage for the AccessControl. + */ + function getStorage() internal pure returns (AccessControlStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Grants a role to multiple accounts in a single transaction. + * @param _role The role to grant. + * @param _accounts The accounts to grant the role to. + * @dev Emits a {RoleGranted} event for each newly granted account. + * @custom:error AccessControlUnauthorizedAccount If the caller is not the admin of the role. + */ + function grantRoleBatch(bytes32 _role, address[] calldata _accounts) external { + AccessControlStorage storage s = getStorage(); + bytes32 adminRole = s.adminRole[_role]; + + /** + * Check if the caller is the admin of the role. + */ + if (!s.hasRole[msg.sender][adminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, adminRole); + } + + uint256 length = _accounts.length; + for (uint256 i = 0; i < length; i++) { + address account = _accounts[i]; + bool _hasRole = s.hasRole[account][_role]; + if (!_hasRole) { + s.hasRole[account][_role] = true; + emit RoleGranted(_role, account, msg.sender); + } + } + } + + /** + * @notice Exports the selectors that are exposed by the facet. + * @return Selectors that are exported by the facet. + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.grantRoleBatch.selector); + } +} + diff --git a/cli/project-template/src/templates/access/AccessControl/Batch/Grant/AccessControlGrantBatchMod.sol b/cli/project-template/src/templates/access/AccessControl/Batch/Grant/AccessControlGrantBatchMod.sol new file mode 100644 index 00000000..23d2f14f --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Batch/Grant/AccessControlGrantBatchMod.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ +error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + +/** + * @notice Emitted when a role is granted to an account. + * @param _role The role that was granted. + * @param _account The account that was granted the role. + * @param _sender The sender that granted the role. + */ +event RoleGranted(bytes32 indexed _role, address indexed _account, address indexed _sender); + +/* + * @notice Storage slot identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("compose.accesscontrol"); + +/** + * @notice storage struct for the AccessControl. + * @custom:storage-location erc8042:compose.accesscontrol + */ +struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; +} + +/** + * @notice Returns the storage for the AccessControl. + * @return _s The storage for the AccessControl. + */ +function getStorage() pure returns (AccessControlStorage storage _s) { + bytes32 position = STORAGE_POSITION; + assembly { + _s.slot := position + } +} + +/** + * @notice function to grant a role to multiple accounts in a single transaction. + * @param _role The role to grant. + * @param _accounts The accounts to grant the role to. + * @dev Emits a {RoleGranted} event for each newly granted account. + * @custom:error AccessControlUnauthorizedAccount If the caller is not the admin of the role. + */ +function grantRoleBatch(bytes32 _role, address[] calldata _accounts) { + AccessControlStorage storage s = getStorage(); + bytes32 adminRole = s.adminRole[_role]; + + if (!s.hasRole[msg.sender][adminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, adminRole); + } + + uint256 length = _accounts.length; + for (uint256 i = 0; i < length; i++) { + address account = _accounts[i]; + bool _hasRole = s.hasRole[account][_role]; + if (!_hasRole) { + s.hasRole[account][_role] = true; + emit RoleGranted(_role, account, msg.sender); + } + } +} + diff --git a/cli/project-template/src/templates/access/AccessControl/Batch/Revoke/AccessControlRevokeBatchFacet.sol b/cli/project-template/src/templates/access/AccessControl/Batch/Revoke/AccessControlRevokeBatchFacet.sol new file mode 100644 index 00000000..2f0cbfa1 --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Batch/Revoke/AccessControlRevokeBatchFacet.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract AccessControlRevokeBatchFacet { + /** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ + error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + + /** + * @notice Emitted when a role is revoked from an account. + * @param _role The role that was revoked. + * @param _account The account from which the role was revoked. + * @param _sender The account that revoked the role. + */ + event RoleRevoked(bytes32 indexed _role, address indexed _account, address indexed _sender); + + /** + * @notice Storage slot identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("compose.accesscontrol"); + + /** + * @notice Storage struct for the AccessControl. + * @custom:storage-location erc8042:compose.accesscontrol + */ + struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; + } + + /** + * @notice Returns the storage for the AccessControl. + * @return s The storage for the AccessControl. + */ + function getStorage() internal pure returns (AccessControlStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Revokes a role from multiple accounts in a single transaction. + * @param _role The role to revoke. + * @param _accounts The accounts to revoke the role from. + * @dev Emits a {RoleRevoked} event for each account the role is revoked from. + * @custom:error AccessControlUnauthorizedAccount If the caller is not the admin of the role. + */ + function revokeRoleBatch(bytes32 _role, address[] calldata _accounts) external { + AccessControlStorage storage s = getStorage(); + bytes32 adminRole = s.adminRole[_role]; + + /** + * Check if the caller is the admin of the role. + */ + if (!s.hasRole[msg.sender][adminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, adminRole); + } + + uint256 length = _accounts.length; + for (uint256 i = 0; i < length; i++) { + address account = _accounts[i]; + bool _hasRole = s.hasRole[account][_role]; + if (_hasRole) { + s.hasRole[account][_role] = false; + emit RoleRevoked(_role, account, msg.sender); + } + } + } + + /** + * @notice Exports the selectors that are exposed by the facet. + * @return Selectors that are exported by the facet. + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.revokeRoleBatch.selector); + } +} + diff --git a/cli/project-template/src/templates/access/AccessControl/Batch/Revoke/AccessControlRevokeBatchMod.sol b/cli/project-template/src/templates/access/AccessControl/Batch/Revoke/AccessControlRevokeBatchMod.sol new file mode 100644 index 00000000..2439ad06 --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Batch/Revoke/AccessControlRevokeBatchMod.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ +error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + +/** + * @notice Emitted when a role is revoked from an account. + * @param _role The role that was revoked. + * @param _account The account from which the role was revoked. + * @param _sender The account that revoked the role. + */ +event RoleRevoked(bytes32 indexed _role, address indexed _account, address indexed _sender); + +/* + * @notice Storage slot identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("compose.accesscontrol"); + +/** + * @notice storage struct for the AccessControl. + * @custom:storage-location erc8042:compose.accesscontrol + */ +struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; +} + +/** + * @notice Returns the storage for the AccessControl. + * @return _s The storage for the AccessControl. + */ +function getStorage() pure returns (AccessControlStorage storage _s) { + bytes32 position = STORAGE_POSITION; + assembly { + _s.slot := position + } +} + +/** + * @notice function to revoke a role from multiple accounts in a single transaction. + * @param _role The role to revoke. + * @param _accounts The accounts to revoke the role from. + * @dev Emits a {RoleRevoked} event for each account the role is revoked from. + * @custom:error AccessControlUnauthorizedAccount If the caller is not the admin of the role. + */ +function revokeRoleBatch(bytes32 _role, address[] calldata _accounts) { + AccessControlStorage storage s = getStorage(); + bytes32 adminRole = s.adminRole[_role]; + + if (!s.hasRole[msg.sender][adminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, adminRole); + } + + uint256 length = _accounts.length; + for (uint256 i = 0; i < length; i++) { + address account = _accounts[i]; + bool _hasRole = s.hasRole[account][_role]; + if (_hasRole) { + s.hasRole[account][_role] = false; + emit RoleRevoked(_role, account, msg.sender); + } + } +} + diff --git a/cli/project-template/src/templates/access/AccessControl/Data/AccessControlDataFacet.sol b/cli/project-template/src/templates/access/AccessControl/Data/AccessControlDataFacet.sol new file mode 100644 index 00000000..9ecb108e --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Data/AccessControlDataFacet.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract AccessControlDataFacet { + /** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ + error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + + /** + * @notice Storage slot identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("compose.accesscontrol"); + + /** + * @notice storage struct for the AccessControl. + * @custom:storage-location erc8042:compose.accesscontrol + */ + struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; + } + + /** + * @notice Returns the storage for the AccessControl. + * @return s The storage for the AccessControl. + */ + function getStorage() internal pure returns (AccessControlStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns if an account has a role. + * @param _role The role to check. + * @param _account The account to check the role for. + * @return True if the account has the role, false otherwise. + */ + function hasRole(bytes32 _role, address _account) external view returns (bool) { + AccessControlStorage storage s = getStorage(); + return s.hasRole[_account][_role]; + } + + /** + * @notice Checks if an account has a required role. + * @param _role The role to check. + * @param _account The account to check the role for. + * @custom:error AccessControlUnauthorizedAccount If the account does not have the role. + */ + function requireRole(bytes32 _role, address _account) external view { + AccessControlStorage storage s = getStorage(); + if (!s.hasRole[_account][_role]) { + revert AccessControlUnauthorizedAccount(_account, _role); + } + } + + /** + * @notice Returns the admin role for a role. + * @param _role The role to get the admin for. + * @return The admin role for the role. + */ + function getRoleAdmin(bytes32 _role) external view returns (bytes32) { + AccessControlStorage storage s = getStorage(); + return s.adminRole[_role]; + } + + /** + * @notice Exports the selectors that are exposed by the facet. + * @return Selectors that are exported by the facet. + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.hasRole.selector, this.requireRole.selector, this.getRoleAdmin.selector); + } +} diff --git a/cli/project-template/src/templates/access/AccessControl/Data/AccessControlDataMod.sol b/cli/project-template/src/templates/access/AccessControl/Data/AccessControlDataMod.sol new file mode 100644 index 00000000..1c315e9d --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Data/AccessControlDataMod.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ +error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + +/* + * @notice Storage slot identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("compose.accesscontrol"); + +/* + * @notice Default admin role. + */ +bytes32 constant DEFAULT_ADMIN_ROLE = 0x00; + +/** + * @notice storage struct for the AccessControl. + * @custom:storage-location erc8042:compose.accesscontrol + */ +struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; +} + +/** + * @notice Returns the storage for the AccessControl. + * @return _s The storage for the AccessControl. + */ +function getStorage() pure returns (AccessControlStorage storage _s) { + bytes32 position = STORAGE_POSITION; + assembly { + _s.slot := position + } +} + +/** + * @notice function to check if an account has a required role. + * @param _role The role to assert. + * @param _account The account to assert the role for. + * @custom:error AccessControlUnauthorizedAccount If the account does not have the role. + */ +function requireRole(bytes32 _role, address _account) view { + AccessControlStorage storage s = getStorage(); + if (!s.hasRole[_account][_role]) { + revert AccessControlUnauthorizedAccount(_account, _role); + } +} + +/** + * @notice function to check if an account has a role. + * @param _role The role to check. + * @param _account The account to check the role for. + * @return True if the account has the role, false otherwise. + */ +function hasRole(bytes32 _role, address _account) view returns (bool) { + AccessControlStorage storage s = getStorage(); + return s.hasRole[_account][_role]; +} diff --git a/cli/project-template/src/templates/access/AccessControl/Grant/AccessControlGrantFacet.sol b/cli/project-template/src/templates/access/AccessControl/Grant/AccessControlGrantFacet.sol new file mode 100644 index 00000000..0b0991af --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Grant/AccessControlGrantFacet.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract AccessControlGrantFacet { + /** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ + error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + + /** + * @notice Emitted when a role is granted to an account. + * @param _role The role that was granted. + * @param _account The account that was granted the role. + * @param _sender The sender that granted the role. + */ + event RoleGranted(bytes32 indexed _role, address indexed _account, address indexed _sender); + + /** + * @notice Storage slot identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("compose.accesscontrol"); + + /** + * @notice Storage struct for the AccessControl. + * @custom:storage-location erc8042:compose.accesscontrol + */ + struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; + } + + /** + * @notice Returns the storage for the AccessControl. + * @return s The storage for the AccessControl. + */ + function getStorage() internal pure returns (AccessControlStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Grants a role to an account. + * @param _role The role to grant. + * @param _account The account to grant the role to. + * @dev Emits a {RoleGranted} event. + * @custom:error AccessControlUnauthorizedAccount If the caller is not the admin of the role. + */ + function grantRole(bytes32 _role, address _account) external { + AccessControlStorage storage s = getStorage(); + bytes32 adminRole = s.adminRole[_role]; + + /** + * Check if the caller is the admin of the role. + */ + if (!s.hasRole[msg.sender][adminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, adminRole); + } + + bool _hasRole = s.hasRole[_account][_role]; + if (!_hasRole) { + s.hasRole[_account][_role] = true; + emit RoleGranted(_role, _account, msg.sender); + } + } + + /** + * @notice Exports the selectors that are exposed by the facet. + * @return Selectors that are exported by the facet. + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.grantRole.selector); + } +} + diff --git a/cli/project-template/src/templates/access/AccessControl/Grant/AccessControlGrantMod.sol b/cli/project-template/src/templates/access/AccessControl/Grant/AccessControlGrantMod.sol new file mode 100644 index 00000000..3493bc76 --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Grant/AccessControlGrantMod.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ +error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + +/** + * @notice Emitted when a role is granted to an account. + * @param _role The role that was granted. + * @param _account The account that was granted the role. + * @param _sender The sender that granted the role. + */ +event RoleGranted(bytes32 indexed _role, address indexed _account, address indexed _sender); + +/* + * @notice Storage slot identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("compose.accesscontrol"); + +/** + * @notice storage struct for the AccessControl. + * @custom:storage-location erc8042:compose.accesscontrol + */ +struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; +} + +/** + * @notice Returns the storage for the AccessControl. + * @return _s The storage for the AccessControl. + */ +function getStorage() pure returns (AccessControlStorage storage _s) { + bytes32 position = STORAGE_POSITION; + assembly { + _s.slot := position + } +} + +/** + * @notice function to grant a role to an account. + * @param _role The role to grant. + * @param _account The account to grant the role to. + * @return True if the role was granted, false otherwise. + * @custom:error AccessControlUnauthorizedAccount If the caller is not the admin of the role. + */ +function grantRole(bytes32 _role, address _account) returns (bool) { + AccessControlStorage storage s = getStorage(); + bytes32 adminRole = s.adminRole[_role]; + + if (!s.hasRole[msg.sender][adminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, adminRole); + } + + bool _hasRole = s.hasRole[_account][_role]; + if (!_hasRole) { + s.hasRole[_account][_role] = true; + emit RoleGranted(_role, _account, msg.sender); + return true; + } + return false; +} + diff --git a/cli/project-template/src/templates/access/AccessControl/Pausable/AccessControlPausableFacet.sol b/cli/project-template/src/templates/access/AccessControl/Pausable/AccessControlPausableFacet.sol new file mode 100644 index 00000000..956b21f2 --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Pausable/AccessControlPausableFacet.sol @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract AccessControlPausableFacet { + /** + * @notice Event emitted when a role is paused. + * @param _role The role that was paused. + * @param _account The account that paused the role. + */ + event RolePaused(bytes32 indexed _role, address indexed _account); + + /** + * @notice Event emitted when a role is unpaused. + * @param _role The role that was unpaused. + * @param _account The account that unpaused the role. + */ + event RoleUnpaused(bytes32 indexed _role, address indexed _account); + + /** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ + error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + + /** + * @notice Thrown when a role is paused and an operation requiring that role is attempted. + * @param _role The role that is paused. + */ + error AccessControlRolePaused(bytes32 _role); + + /** + * @notice Storage slot identifier for AccessControl (reused to access roles). + */ + bytes32 constant ACCESS_CONTROL_STORAGE_POSITION = keccak256("compose.accesscontrol"); + + /** + * @notice Storage struct for AccessControl (reused struct definition). + * @dev Must match the struct definition in AccessControlDataFacet / AccessControlDataMod. + * @custom:storage-location erc8042:compose.accesscontrol + */ + struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; + } + + /** + * @notice Storage slot identifier for Pausable functionality. + */ + bytes32 constant PAUSABLE_STORAGE_POSITION = keccak256("compose.accesscontrol.pausable"); + + /** + * @notice Storage struct for AccessControlPausable. + * @custom:storage-location erc8042:compose.accesscontrol.pausable + */ + struct AccessControlPausableStorage { + mapping(bytes32 role => bool paused) pausedRoles; + } + + /** + * @notice Returns the storage for AccessControl. + * @return s The AccessControl storage struct. + */ + function getAccessControlStorage() internal pure returns (AccessControlStorage storage s) { + bytes32 position = ACCESS_CONTROL_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns the storage for AccessControlPausable. + * @return s The AccessControlPausable storage struct. + */ + function getStorage() internal pure returns (AccessControlPausableStorage storage s) { + bytes32 position = PAUSABLE_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns if a role is paused. + * @param _role The role to check. + * @return True if the role is paused, false otherwise. + */ + function isRolePaused(bytes32 _role) external view returns (bool) { + return getStorage().pausedRoles[_role]; + } + + /** + * @notice Temporarily disables a role, preventing all accounts from using it. + * @param _role The role to pause. + * @dev Only the admin of the role can pause it. + * Emits a {RolePaused} event. + * @custom:error AccessControlUnauthorizedAccount If the caller is not the admin of the role. + */ + function pauseRole(bytes32 _role) external { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlPausableStorage storage s = getStorage(); + + /** + * Get the admin role for this role + */ + bytes32 adminRole = acs.adminRole[_role]; + + /** + * Check if the caller is the admin of the role. + */ + if (!acs.hasRole[msg.sender][adminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, adminRole); + } + + /** + * Pause the role + */ + s.pausedRoles[_role] = true; + emit RolePaused(_role, msg.sender); + } + + /** + * @notice Re-enables a role that was previously paused. + * @param _role The role to unpause. + * @dev Only the admin of the role can unpause it. + * Emits a {RoleUnpaused} event. + * @custom:error AccessControlUnauthorizedAccount If the caller is not the admin of the role. + */ + function unpauseRole(bytes32 _role) external { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlPausableStorage storage s = getStorage(); + + /** + * Get the admin role for this role + */ + bytes32 adminRole = acs.adminRole[_role]; + + /** + * Check if the caller is the admin of the role. + */ + if (!acs.hasRole[msg.sender][adminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, adminRole); + } + + /** + * Unpause the role + */ + s.pausedRoles[_role] = false; + emit RoleUnpaused(_role, msg.sender); + } + + /** + * @notice Checks if an account has a role and if the role is not paused. + * @param _role The role to check. + * @param _account The account to check the role for. + * @custom:error AccessControlUnauthorizedAccount If the account does not have the role. + * @custom:error AccessControlRolePaused If the role is paused. + */ + function requireRoleNotPaused(bytes32 _role, address _account) external view { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlPausableStorage storage s = getStorage(); + + /** + * First check if the account has the role + */ + if (!acs.hasRole[_account][_role]) { + revert AccessControlUnauthorizedAccount(_account, _role); + } + + /** + * Then check if the role is paused + */ + if (s.pausedRoles[_role]) { + revert AccessControlRolePaused(_role); + } + } + + /** + * @notice Exports the selectors that are exposed by the facet. + * @return Selectors that are exported by the facet. + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat( + this.isRolePaused.selector, + this.pauseRole.selector, + this.unpauseRole.selector, + this.requireRoleNotPaused.selector + ); + } +} diff --git a/cli/project-template/src/templates/access/AccessControl/Pausable/AccessControlPausableMod.sol b/cli/project-template/src/templates/access/AccessControl/Pausable/AccessControlPausableMod.sol new file mode 100644 index 00000000..53575e14 --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Pausable/AccessControlPausableMod.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Event emitted when a role is paused. + * @param _role The role that was paused. + * @param _account The account that paused the role. + */ +event RolePaused(bytes32 indexed _role, address indexed _account); + +/** + * @notice Event emitted when a role is unpaused. + * @param _role The role that was unpaused. + * @param _account The account that unpaused the role. + */ +event RoleUnpaused(bytes32 indexed _role, address indexed _account); + +/** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ +error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + +/** + * @notice Thrown when a role is paused and an operation requiring that role is attempted. + * @param _role The role that is paused. + */ +error AccessControlRolePaused(bytes32 _role); + +/* + * @notice Storage slot identifier for AccessControl (reused to access roles). + */ +bytes32 constant ACCESS_CONTROL_STORAGE_POSITION = keccak256("compose.accesscontrol"); + +/** + * @notice Storage struct for AccessControl (reused struct definition). + * @dev Must match the struct definition in AccessControlDataFacet / AccessControlDataMod. + * @custom:storage-location erc8042:compose.accesscontrol + */ +struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; +} + +/* + * @notice Storage slot identifier for Pausable functionality. + */ +bytes32 constant PAUSABLE_STORAGE_POSITION = keccak256("compose.accesscontrol.pausable"); + +/** + * @notice Storage struct for AccessControlPausable. + * @custom:storage-location erc8042:compose.accesscontrol.pausable + */ +struct AccessControlPausableStorage { + mapping(bytes32 role => bool paused) pausedRoles; +} + +/** + * @notice Returns the storage for AccessControl. + * @return s The AccessControl storage struct. + */ +function getAccessControlStorage() pure returns (AccessControlStorage storage s) { + bytes32 position = ACCESS_CONTROL_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Returns the storage for AccessControlPausable. + * @return s The AccessControlPausable storage struct. + */ +function getStorage() pure returns (AccessControlPausableStorage storage s) { + bytes32 position = PAUSABLE_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice function to check if a role is paused. + * @param _role The role to check. + * @return True if the role is paused, false otherwise. + */ +function isRolePaused(bytes32 _role) view returns (bool) { + AccessControlPausableStorage storage s = getStorage(); + return s.pausedRoles[_role]; +} + +/** + * @notice function to pause a role. + * @param _role The role to pause. + */ +function pauseRole(bytes32 _role) { + AccessControlPausableStorage storage s = getStorage(); + s.pausedRoles[_role] = true; + emit RolePaused(_role, msg.sender); +} + +/** + * @notice function to unpause a role. + * @param _role The role to unpause. + */ +function unpauseRole(bytes32 _role) { + AccessControlPausableStorage storage s = getStorage(); + s.pausedRoles[_role] = false; + emit RoleUnpaused(_role, msg.sender); +} + +/** + * @notice function to check if an account has a role and if the role is not paused. + * @param _role The role to check. + * @param _account The account to check the role for. + * @custom:error AccessControlUnauthorizedAccount If the account does not have the role. + * @custom:error AccessControlRolePaused If the role is paused. + */ +function requireRoleNotPaused(bytes32 _role, address _account) view { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlPausableStorage storage s = getStorage(); + + /** + * First check if the account has the role + */ + if (!acs.hasRole[_account][_role]) { + revert AccessControlUnauthorizedAccount(_account, _role); + } + + /** + * Then check if the role is paused + */ + if (s.pausedRoles[_role]) { + revert AccessControlRolePaused(_role); + } +} diff --git a/cli/project-template/src/templates/access/AccessControl/Renounce/AccessControlRenounceFacet.sol b/cli/project-template/src/templates/access/AccessControl/Renounce/AccessControlRenounceFacet.sol new file mode 100644 index 00000000..4f0e196b --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Renounce/AccessControlRenounceFacet.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract AccessControlRenounceFacet { + /** + * @notice Emitted when a role is revoked from an account. + * @param _role The role that was revoked. + * @param _account The account from which the role was revoked. + * @param _sender The account that revoked the role. + */ + event RoleRevoked(bytes32 indexed _role, address indexed _account, address indexed _sender); + + /** + * @notice Thrown when the sender is not the account to renounce the role from. + * @param _sender The sender that is not the account to renounce the role from. + * @param _account The account to renounce the role from. + */ + error AccessControlUnauthorizedSender(address _sender, address _account); + + /** + * @notice Storage slot identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("compose.accesscontrol"); + + /** + * @notice Storage struct for the AccessControl. + * @custom:storage-location erc8042:compose.accesscontrol + */ + struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; + } + + /** + * @notice Returns the storage for the AccessControl. + * @return s The storage for the AccessControl. + */ + function getStorage() internal pure returns (AccessControlStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Renounces a role from the caller. + * @param _role The role to renounce. + * @param _account The account to renounce the role from. + * @dev Emits a {RoleRevoked} event. + * @custom:error AccessControlUnauthorizedSender If the caller is not the account to renounce the role from. + */ + function renounceRole(bytes32 _role, address _account) external { + AccessControlStorage storage s = getStorage(); + + /** + * Check If the caller is not the account to renounce the role from. + */ + if (msg.sender != _account) { + revert AccessControlUnauthorizedSender(msg.sender, _account); + } + bool _hasRole = s.hasRole[_account][_role]; + if (_hasRole) { + s.hasRole[_account][_role] = false; + emit RoleRevoked(_role, _account, msg.sender); + } + } + + /** + * @notice Exports the selectors that are exposed by the facet. + * @return Selectors that are exported by the facet. + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.renounceRole.selector); + } +} diff --git a/cli/project-template/src/templates/access/AccessControl/Renounce/AccessControlRenounceMod.sol b/cli/project-template/src/templates/access/AccessControl/Renounce/AccessControlRenounceMod.sol new file mode 100644 index 00000000..204abfdf --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Renounce/AccessControlRenounceMod.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Emitted when a role is revoked from an account. + * @param _role The role that was revoked. + * @param _account The account from which the role was revoked. + * @param _sender The account that revoked the role. + */ +event RoleRevoked(bytes32 indexed _role, address indexed _account, address indexed _sender); + +/** + * @notice Thrown when the sender is not the account to renounce the role from. + * @param _sender The sender that is not the account to renounce the role from. + * @param _account The account to renounce the role from. + */ +error AccessControlUnauthorizedSender(address _sender, address _account); + +/* + * @notice Storage slot identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("compose.accesscontrol"); + +/** + * @notice storage struct for the AccessControl. + * @custom:storage-location erc8042:compose.accesscontrol + */ +struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; +} + +/** + * @notice Returns the storage for the AccessControl. + * @return _s The storage for the AccessControl. + */ +function getStorage() pure returns (AccessControlStorage storage _s) { + bytes32 position = STORAGE_POSITION; + assembly { + _s.slot := position + } +} + +/** + * @notice function to renounce a role from the caller. + * @param _role The role to renounce. + * @param _account The account to renounce the role from. + * @dev Emits a {RoleRevoked} event. + * @custom:error AccessControlUnauthorizedSender If the caller is not the account to renounce the role from. + */ +function renounceRole(bytes32 _role, address _account) { + AccessControlStorage storage s = getStorage(); + + if (msg.sender != _account) { + revert AccessControlUnauthorizedSender(msg.sender, _account); + } + bool _hasRole = s.hasRole[_account][_role]; + if (_hasRole) { + s.hasRole[_account][_role] = false; + emit RoleRevoked(_role, _account, msg.sender); + } +} diff --git a/cli/project-template/src/templates/access/AccessControl/Revoke/AccessControlRevokeFacet.sol b/cli/project-template/src/templates/access/AccessControl/Revoke/AccessControlRevokeFacet.sol new file mode 100644 index 00000000..7b5bbf43 --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Revoke/AccessControlRevokeFacet.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract AccessControlRevokeFacet { + /** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ + error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + + /** + * @notice Emitted when a role is revoked from an account. + * @param _role The role that was revoked. + * @param _account The account from which the role was revoked. + * @param _sender The account that revoked the role. + */ + event RoleRevoked(bytes32 indexed _role, address indexed _account, address indexed _sender); + + /** + * @notice Storage slot identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("compose.accesscontrol"); + + /** + * @notice Storage struct for the AccessControl. + * @custom:storage-location erc8042:compose.accesscontrol + */ + struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; + } + + /** + * @notice Returns the storage for the AccessControl. + * @return s The storage for the AccessControl. + */ + function getStorage() internal pure returns (AccessControlStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Revokes a role from an account. + * @param _role The role to revoke. + * @param _account The account to revoke the role from. + * @dev Emits a {RoleRevoked} event. + * @custom:error AccessControlUnauthorizedAccount If the caller is not the admin of the role. + */ + function revokeRole(bytes32 _role, address _account) external { + AccessControlStorage storage s = getStorage(); + bytes32 adminRole = s.adminRole[_role]; + + /** + * Check if the caller is the admin of the role. + */ + if (!s.hasRole[msg.sender][adminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, adminRole); + } + + bool _hasRole = s.hasRole[_account][_role]; + if (_hasRole) { + s.hasRole[_account][_role] = false; + emit RoleRevoked(_role, _account, msg.sender); + } + } + + /** + * @notice Exports the selectors that are exposed by the facet. + * @return Selectors that are exported by the facet. + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.revokeRole.selector); + } +} + diff --git a/cli/project-template/src/templates/access/AccessControl/Revoke/AccessControlRevokeMod.sol b/cli/project-template/src/templates/access/AccessControl/Revoke/AccessControlRevokeMod.sol new file mode 100644 index 00000000..842db628 --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Revoke/AccessControlRevokeMod.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ +error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + +/** + * @notice Emitted when a role is revoked from an account. + * @param _role The role that was revoked. + * @param _account The account from which the role was revoked. + * @param _sender The account that revoked the role. + */ +event RoleRevoked(bytes32 indexed _role, address indexed _account, address indexed _sender); + +/* + * @notice Storage slot identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("compose.accesscontrol"); + +/** + * @notice storage struct for the AccessControl. + * @custom:storage-location erc8042:compose.accesscontrol + */ +struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; +} + +/** + * @notice Returns the storage for the AccessControl. + * @return _s The storage for the AccessControl. + */ +function getStorage() pure returns (AccessControlStorage storage _s) { + bytes32 position = STORAGE_POSITION; + assembly { + _s.slot := position + } +} + +/** + * @notice function to revoke a role from an account. + * @param _role The role to revoke. + * @param _account The account to revoke the role from. + * @return True if the role was revoked, false otherwise. + * @custom:error AccessControlUnauthorizedAccount If the caller is not the admin of the role. + */ +function revokeRole(bytes32 _role, address _account) returns (bool) { + AccessControlStorage storage s = getStorage(); + bytes32 adminRole = s.adminRole[_role]; + + /** + * Check if the caller is the admin of the role. + */ + if (!s.hasRole[msg.sender][adminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, adminRole); + } + + bool _hasRole = s.hasRole[_account][_role]; + if (_hasRole) { + s.hasRole[_account][_role] = false; + emit RoleRevoked(_role, _account, msg.sender); + return true; + } + return false; +} + diff --git a/cli/project-template/src/templates/access/AccessControl/Temporal/Data/AccessControlTemporalDataFacet.sol b/cli/project-template/src/templates/access/AccessControl/Temporal/Data/AccessControlTemporalDataFacet.sol new file mode 100644 index 00000000..ff5e8692 --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Temporal/Data/AccessControlTemporalDataFacet.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract AccessControlTemporalDataFacet { + /** + * @notice Event emitted when a role is granted with an expiry timestamp. + * @param _role The role that was granted. + * @param _account The account that was granted the role. + * @param _expiresAt The timestamp when the role expires. + * @param _sender The account that granted the role. + */ + event RoleGrantedWithExpiry( + bytes32 indexed _role, address indexed _account, uint256 _expiresAt, address indexed _sender + ); + + /** + * @notice Event emitted when a temporal role is revoked. + * @param _role The role that was revoked. + * @param _account The account from which the role was revoked. + * @param _sender The account that revoked the role. + */ + event TemporalRoleRevoked(bytes32 indexed _role, address indexed _account, address indexed _sender); + + /** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ + error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + + /** + * @notice Thrown when a role has expired. + * @param _role The role that has expired. + * @param _account The account whose role has expired. + */ + error AccessControlRoleExpired(bytes32 _role, address _account); + + /** + * @notice Storage slot identifier for AccessControl (reused to access roles). + */ + bytes32 constant ACCESS_CONTROL_STORAGE_POSITION = keccak256("compose.accesscontrol"); + + /** + * @notice Storage struct for AccessControl (reused struct definition). + * @dev Must match the struct definition in AccessControlDataFacet. + * @custom:storage-location erc8042:compose.accesscontrol + */ + struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; + } + + /** + * @notice Storage slot identifier for Temporal functionality. + */ + bytes32 constant TEMPORAL_STORAGE_POSITION = keccak256("compose.accesscontrol.temporal"); + + /** + * @notice Storage struct for AccessControlTemporal. + * @custom:storage-location erc8042:compose.accesscontrol.temporal + */ + struct AccessControlTemporalStorage { + mapping(address account => mapping(bytes32 role => uint256 expiryTimestamp)) roleExpiry; + } + + /** + * @notice Returns the storage for AccessControl. + * @return s The AccessControl storage struct. + */ + function getAccessControlStorage() internal pure returns (AccessControlStorage storage s) { + bytes32 position = ACCESS_CONTROL_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns the storage for AccessControlTemporal. + * @return s The AccessControlTemporal storage struct. + */ + function getStorage() internal pure returns (AccessControlTemporalStorage storage s) { + bytes32 position = TEMPORAL_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns the expiry timestamp for a role assignment. + * @param _role The role to check. + * @param _account The account to check. + * @return The expiry timestamp, or 0 if no expiry is set. + */ + function getRoleExpiry(bytes32 _role, address _account) external view returns (uint256) { + return getStorage().roleExpiry[_account][_role]; + } + + /** + * @notice Checks if a role assignment has expired. + * @param _role The role to check. + * @param _account The account to check. + * @return True if the role has expired or doesn't exist, false if still valid. + */ + function isRoleExpired(bytes32 _role, address _account) external view returns (bool) { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlTemporalStorage storage s = getStorage(); + uint256 expiry = s.roleExpiry[_account][_role]; + + /** + * If no expiry set (0), role is valid if account has it + */ + if (expiry == 0) { + return !acs.hasRole[_account][_role]; + } + + /** + * Role is expired if current time is past expiry + */ + return block.timestamp >= expiry; + } + + /** + * @notice Checks if an account has a valid (non-expired) role. + * @param _role The role to check. + * @param _account The account to check the role for. + * @custom:error AccessControlUnauthorizedAccount If the account does not have the role. + * @custom:error AccessControlRoleExpired If the role has expired. + */ + function requireValidRole(bytes32 _role, address _account) external view { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlTemporalStorage storage s = getStorage(); + + /** + * Check if account has the role + */ + if (!acs.hasRole[_account][_role]) { + revert AccessControlUnauthorizedAccount(_account, _role); + } + + /** + * Check if role has expired + */ + uint256 expiry = s.roleExpiry[_account][_role]; + if (expiry > 0 && block.timestamp >= expiry) { + revert AccessControlRoleExpired(_role, _account); + } + } + + /** + * @notice Exports the selectors that are exposed by the facet. + * @return Selectors that are exported by the facet. + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.getRoleExpiry.selector, this.isRoleExpired.selector, this.requireValidRole.selector); + } +} diff --git a/cli/project-template/src/templates/access/AccessControl/Temporal/Data/AccessControlTemporalDataMod.sol b/cli/project-template/src/templates/access/AccessControl/Temporal/Data/AccessControlTemporalDataMod.sol new file mode 100644 index 00000000..23c1216e --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Temporal/Data/AccessControlTemporalDataMod.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Event emitted when a role is granted with an expiry timestamp. + * @param _role The role that was granted. + * @param _account The account that was granted the role. + * @param _expiresAt The timestamp when the role expires. + * @param _sender The account that granted the role. + */ +event RoleGrantedWithExpiry( + bytes32 indexed _role, address indexed _account, uint256 _expiresAt, address indexed _sender +); + +/** + * @notice Event emitted when a temporal role is revoked. + * @param _role The role that was revoked. + * @param _account The account from which the role was revoked. + * @param _sender The account that revoked the role. + */ +event TemporalRoleRevoked(bytes32 indexed _role, address indexed _account, address indexed _sender); + +/** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ +error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + +/** + * @notice Thrown when a role has expired. + * @param _role The role that has expired. + * @param _account The account whose role has expired. + */ +error AccessControlRoleExpired(bytes32 _role, address _account); + +/* + * @notice Storage slot identifier for AccessControl. + */ +bytes32 constant ACCESS_CONTROL_STORAGE_POSITION = keccak256("compose.accesscontrol"); + +/** + * @notice Storage struct for AccessControl (reused struct definition). + * @dev Must match the struct definition in AccessControlDataFacet. + * @custom:storage-location erc8042:compose.accesscontrol + */ +struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; +} + +/* + * @notice Storage slot identifier for Temporal functionality. + */ +bytes32 constant TEMPORAL_STORAGE_POSITION = keccak256("compose.accesscontrol.temporal"); + +/** + * @notice Storage struct for AccessControlTemporal. + * @custom:storage-location erc8042:compose.accesscontrol.temporal + */ +struct AccessControlTemporalStorage { + mapping(address account => mapping(bytes32 role => uint256 expiryTimestamp)) roleExpiry; +} + +/** + * @notice Returns the storage for AccessControl. + * @return s The AccessControl storage struct. + */ +function getAccessControlStorage() pure returns (AccessControlStorage storage s) { + bytes32 position = ACCESS_CONTROL_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Returns the storage for AccessControlTemporal. + * @return s The AccessControlTemporal storage struct. + */ +function getStorage() pure returns (AccessControlTemporalStorage storage s) { + bytes32 position = TEMPORAL_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Returns the expiry timestamp for a role assignment. + * @param _role The role to check. + * @param _account The account to check. + * @return The expiry timestamp, or 0 if no expiry is set. + */ +function getRoleExpiry(bytes32 _role, address _account) view returns (uint256) { + return getStorage().roleExpiry[_account][_role]; +} + +/** + * @notice Checks if a role assignment has expired. + * @param _role The role to check. + * @param _account The account to check. + * @return True if the role has expired or doesn't exist, false if still valid. + */ +function isRoleExpired(bytes32 _role, address _account) view returns (bool) { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlTemporalStorage storage s = getStorage(); + uint256 expiry = s.roleExpiry[_account][_role]; + + /** + * If no expiry set (0), role is valid if account has it + */ + if (expiry == 0) { + return !acs.hasRole[_account][_role]; + } + + /** + * Role is expired if current time is past expiry + */ + return block.timestamp >= expiry; +} + +/** + * @notice Checks if an account has a valid (non-expired) role. + * @param _role The role to check. + * @param _account The account to check the role for. + * @custom:error AccessControlUnauthorizedAccount If the account does not have the role. + * @custom:error AccessControlRoleExpired If the role has expired. + */ +function requireValidRole(bytes32 _role, address _account) view { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlTemporalStorage storage s = getStorage(); + + /** + * Check if account has the role + */ + if (!acs.hasRole[_account][_role]) { + revert AccessControlUnauthorizedAccount(_account, _role); + } + + /** + * Check if role has expired + */ + uint256 expiry = s.roleExpiry[_account][_role]; + if (expiry > 0 && block.timestamp >= expiry) { + revert AccessControlRoleExpired(_role, _account); + } +} diff --git a/cli/project-template/src/templates/access/AccessControl/Temporal/Grant/AccessControlTemporalGrantFacet.sol b/cli/project-template/src/templates/access/AccessControl/Temporal/Grant/AccessControlTemporalGrantFacet.sol new file mode 100644 index 00000000..c8615746 --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Temporal/Grant/AccessControlTemporalGrantFacet.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract AccessControlTemporalGrantFacet { + /** + * @notice Event emitted when a role is granted with an expiry timestamp. + * @param _role The role that was granted. + * @param _account The account that was granted the role. + * @param _expiresAt The timestamp when the role expires. + * @param _sender The account that granted the role. + */ + event RoleGrantedWithExpiry( + bytes32 indexed _role, address indexed _account, uint256 _expiresAt, address indexed _sender + ); + + /** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ + error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + + /** + * @notice Thrown when a role has expired. + * @param _role The role that has expired. + * @param _account The account whose role has expired. + */ + error AccessControlRoleExpired(bytes32 _role, address _account); + + /** + * @notice Storage slot identifier for AccessControl (reused to access roles). + */ + bytes32 constant ACCESS_CONTROL_STORAGE_POSITION = keccak256("compose.accesscontrol"); + + /** + * @notice Storage struct for AccessControl (reused struct definition). + * @dev Must match the struct definition in AccessControlDataFacet. + * @custom:storage-location erc8042:compose.accesscontrol + */ + struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; + } + + /** + * @notice Storage slot identifier for Temporal functionality. + */ + bytes32 constant TEMPORAL_STORAGE_POSITION = keccak256("compose.accesscontrol.temporal"); + + /** + * @notice Storage struct for AccessControlTemporal. + * @custom:storage-location erc8042:compose.accesscontrol.temporal + */ + struct AccessControlTemporalStorage { + mapping(address account => mapping(bytes32 role => uint256 expiryTimestamp)) roleExpiry; + } + + /** + * @notice Returns the storage for AccessControl. + * @return s The AccessControl storage struct. + */ + function getAccessControlStorage() internal pure returns (AccessControlStorage storage s) { + bytes32 position = ACCESS_CONTROL_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns the storage for AccessControlTemporal. + * @return s The AccessControlTemporal storage struct. + */ + function getStorage() internal pure returns (AccessControlTemporalStorage storage s) { + bytes32 position = TEMPORAL_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Grants a role to an account with an expiry timestamp. + * @param _role The role to grant. + * @param _account The account to grant the role to. + * @param _expiresAt The timestamp when the role should expire (must be in the future). + * @dev Only the admin of the role can grant it with expiry. + * Emits a {RoleGrantedWithExpiry} event. + * @custom:error AccessControlUnauthorizedAccount If the caller is not the admin of the role. + */ + function grantRoleWithExpiry(bytes32 _role, address _account, uint256 _expiresAt) external { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlTemporalStorage storage s = getStorage(); + bytes32 adminRole = acs.adminRole[_role]; + + /** + * Check if the caller is the admin of the role. + */ + if (!acs.hasRole[msg.sender][adminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, adminRole); + } + + /** + * Require expiry is in the future + */ + if (_expiresAt <= block.timestamp) { + revert AccessControlRoleExpired(_role, _account); + } + + /** + * Grant the role + */ + bool _hasRole = acs.hasRole[_account][_role]; + if (!_hasRole) { + acs.hasRole[_account][_role] = true; + } + + /** + * Set expiry timestamp + */ + s.roleExpiry[_account][_role] = _expiresAt; + emit RoleGrantedWithExpiry(_role, _account, _expiresAt, msg.sender); + } + + /** + * @notice Exports the selectors that are exposed by the facet. + * @return Selectors that are exported by the facet. + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.grantRoleWithExpiry.selector); + } +} diff --git a/cli/project-template/src/templates/access/AccessControl/Temporal/Grant/AccessControlTemporalGrantMod.sol b/cli/project-template/src/templates/access/AccessControl/Temporal/Grant/AccessControlTemporalGrantMod.sol new file mode 100644 index 00000000..71dca03d --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Temporal/Grant/AccessControlTemporalGrantMod.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Event emitted when a role is granted with an expiry timestamp. + * @param _role The role that was granted. + * @param _account The account that was granted the role. + * @param _expiresAt The timestamp when the role expires. + * @param _sender The account that granted the role. + */ +event RoleGrantedWithExpiry( + bytes32 indexed _role, address indexed _account, uint256 _expiresAt, address indexed _sender +); + +/** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ +error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + +/** + * @notice Thrown when a role has expired. + * @param _role The role that has expired. + * @param _account The account whose role has expired. + */ +error AccessControlRoleExpired(bytes32 _role, address _account); + +/* + * @notice Storage slot identifier for AccessControl (reused to access roles). + */ +bytes32 constant ACCESS_CONTROL_STORAGE_POSITION = keccak256("compose.accesscontrol"); + +/** + * @notice Storage struct for AccessControl (reused struct definition). + * @dev Must match the struct definition in AccessControlDataFacet. + * @custom:storage-location erc8042:compose.accesscontrol + */ +struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; +} + +/* + * @notice Storage slot identifier for Temporal functionality. + */ +bytes32 constant TEMPORAL_STORAGE_POSITION = keccak256("compose.accesscontrol.temporal"); + +/** + * @notice Storage struct for AccessControlTemporal. + * @custom:storage-location erc8042:compose.accesscontrol.temporal + */ +struct AccessControlTemporalStorage { + mapping(address account => mapping(bytes32 role => uint256 expiryTimestamp)) roleExpiry; +} + +/** + * @notice Returns the storage for AccessControl. + * @return s The AccessControl storage struct. + */ +function getAccessControlStorage() pure returns (AccessControlStorage storage s) { + bytes32 position = ACCESS_CONTROL_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Returns the storage for AccessControlTemporal. + * @return s The AccessControlTemporal storage struct. + */ +function getStorage() pure returns (AccessControlTemporalStorage storage s) { + bytes32 position = TEMPORAL_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Grants a role to an account with an expiry timestamp. + * @param _role The role to grant. + * @param _account The account to grant the role to. + * @param _expiresAt The timestamp when the role should expire (must be in the future). + * @dev Only the admin of the role can grant it with expiry. + * Emits a {RoleGrantedWithExpiry} event. + * @custom:error AccessControlUnauthorizedAccount If the caller is not the admin of the role. + */ +function grantRoleWithExpiry(bytes32 _role, address _account, uint256 _expiresAt) { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlTemporalStorage storage s = getStorage(); + bytes32 adminRole = acs.adminRole[_role]; + + /** + * Check if the caller is the admin of the role. + */ + if (!acs.hasRole[msg.sender][adminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, adminRole); + } + + /** + * Require expiry is in the future + */ + if (_expiresAt <= block.timestamp) { + revert AccessControlRoleExpired(_role, _account); + } + + /** + * Grant the role + */ + bool _hasRole = acs.hasRole[_account][_role]; + if (!_hasRole) { + acs.hasRole[_account][_role] = true; + } + + /** + * Set expiry timestamp + */ + s.roleExpiry[_account][_role] = _expiresAt; + emit RoleGrantedWithExpiry(_role, _account, _expiresAt, msg.sender); +} diff --git a/cli/project-template/src/templates/access/AccessControl/Temporal/Revoke/AccessControlTemporalRevokeFacet.sol b/cli/project-template/src/templates/access/AccessControl/Temporal/Revoke/AccessControlTemporalRevokeFacet.sol new file mode 100644 index 00000000..e8395980 --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Temporal/Revoke/AccessControlTemporalRevokeFacet.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract AccessControlTemporalRevokeFacet { + /** + * @notice Event emitted when a temporal role is revoked. + * @param _role The role that was revoked. + * @param _account The account from which the role was revoked. + * @param _sender The account that revoked the role. + */ + event TemporalRoleRevoked(bytes32 indexed _role, address indexed _account, address indexed _sender); + + /** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ + error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + + /** + * @notice Storage slot identifier for AccessControl (reused to access roles). + */ + bytes32 constant ACCESS_CONTROL_STORAGE_POSITION = keccak256("compose.accesscontrol"); + + /** + * @notice Storage struct for AccessControl (reused struct definition). + * @dev Must match the struct definition in AccessControlDataFacet. + * @custom:storage-location erc8042:compose.accesscontrol + */ + struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; + } + + /** + * @notice Storage slot identifier for Temporal functionality. + */ + bytes32 constant TEMPORAL_STORAGE_POSITION = keccak256("compose.accesscontrol.temporal"); + + /** + * @notice Storage struct for AccessControlTemporal. + * @custom:storage-location erc8042:compose.accesscontrol.temporal + */ + struct AccessControlTemporalStorage { + mapping(address account => mapping(bytes32 role => uint256 expiryTimestamp)) roleExpiry; + } + + /** + * @notice Returns the storage for AccessControl. + * @return s The AccessControl storage struct. + */ + function getAccessControlStorage() internal pure returns (AccessControlStorage storage s) { + bytes32 position = ACCESS_CONTROL_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns the storage for AccessControlTemporal. + * @return s The AccessControlTemporal storage struct. + */ + function getStorage() internal pure returns (AccessControlTemporalStorage storage s) { + bytes32 position = TEMPORAL_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Revokes a temporal role from an account. + * @param _role The role to revoke. + * @param _account The account to revoke the role from. + * @dev Only the admin of the role can revoke it. + * Emits a {TemporalRoleRevoked} event. + * @custom:error AccessControlUnauthorizedAccount If the caller is not the admin of the role. + */ + function revokeTemporalRole(bytes32 _role, address _account) external { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlTemporalStorage storage s = getStorage(); + bytes32 adminRole = acs.adminRole[_role]; + + /** + * Check if the caller is the admin of the role. + */ + if (!acs.hasRole[msg.sender][adminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, adminRole); + } + + /** + * Revoke the role + */ + bool _hasRole = acs.hasRole[_account][_role]; + + /** + * Only revoke if the role is currently granted + */ + if (_hasRole) { + /** + * Revoke the role from AccessControl storage + */ + acs.hasRole[_account][_role] = false; + + /** + * Clear expiry timestamp + */ + s.roleExpiry[_account][_role] = 0; + + emit TemporalRoleRevoked(_role, _account, msg.sender); + } + } + + /** + * @notice Exports the selectors that are exposed by the facet. + * @return Selectors that are exported by the facet. + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.revokeTemporalRole.selector); + } +} diff --git a/cli/project-template/src/templates/access/AccessControl/Temporal/Revoke/AccessControlTemporalRevokeMod.sol b/cli/project-template/src/templates/access/AccessControl/Temporal/Revoke/AccessControlTemporalRevokeMod.sol new file mode 100644 index 00000000..0ee20098 --- /dev/null +++ b/cli/project-template/src/templates/access/AccessControl/Temporal/Revoke/AccessControlTemporalRevokeMod.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Event emitted when a temporal role is revoked. + * @param _role The role that was revoked. + * @param _account The account from which the role was revoked. + * @param _sender The account that revoked the role. + */ +event TemporalRoleRevoked(bytes32 indexed _role, address indexed _account, address indexed _sender); + +/** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ +error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + +/* + * @notice Storage slot identifier for AccessControl (reused to access roles). + */ +bytes32 constant ACCESS_CONTROL_STORAGE_POSITION = keccak256("compose.accesscontrol"); + +/** + * @notice Storage struct for AccessControl (reused struct definition). + * @dev Must match the struct definition in AccessControlDataFacet. + * @custom:storage-location erc8042:compose.accesscontrol + */ +struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; +} + +/* + * @notice Storage slot identifier for Temporal functionality. + */ +bytes32 constant TEMPORAL_STORAGE_POSITION = keccak256("compose.accesscontrol.temporal"); + +/** + * @notice Storage struct for AccessControlTemporal. + * @custom:storage-location erc8042:compose.accesscontrol.temporal + */ +struct AccessControlTemporalStorage { + mapping(address account => mapping(bytes32 role => uint256 expiryTimestamp)) roleExpiry; +} + +/** + * @notice Returns the storage for AccessControl. + * @return s The AccessControl storage struct. + */ +function getAccessControlStorage() pure returns (AccessControlStorage storage s) { + bytes32 position = ACCESS_CONTROL_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Returns the storage for AccessControlTemporal. + * @return s The AccessControlTemporal storage struct. + */ +function getStorage() pure returns (AccessControlTemporalStorage storage s) { + bytes32 position = TEMPORAL_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Revokes a temporal role from an account. + * @param _role The role to revoke. + * @param _account The account to revoke the role from. + * @dev Only the admin of the role can revoke it. + * Emits a {TemporalRoleRevoked} event. + * @custom:error AccessControlUnauthorizedAccount If the caller is not the admin of the role. + */ +function revokeTemporalRole(bytes32 _role, address _account) { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlTemporalStorage storage s = getStorage(); + bytes32 adminRole = acs.adminRole[_role]; + + /** + * Check if the caller is the admin of the role. + */ + if (!acs.hasRole[msg.sender][adminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, adminRole); + } + + /** + * Revoke the role + */ + bool _hasRole = acs.hasRole[_account][_role]; + + /** + * Only revoke if the role is currently granted + */ + if (_hasRole) { + /** + * Revoke the role from AccessControl storage + */ + acs.hasRole[_account][_role] = false; + + /** + * Clear expiry timestamp + */ + s.roleExpiry[_account][_role] = 0; + + emit TemporalRoleRevoked(_role, _account, msg.sender); + } +} diff --git a/cli/project-template/src/templates/access/Owner/Data/OwnerDataFacet.sol b/cli/project-template/src/templates/access/Owner/Data/OwnerDataFacet.sol new file mode 100644 index 00000000..fa87b366 --- /dev/null +++ b/cli/project-template/src/templates/access/Owner/Data/OwnerDataFacet.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title OwnerDataFacet + */ +contract OwnerDataFacet { + /** + * @notice Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc173.owner"); + + /** + * @custom:storage-location erc8042:erc173.owner + */ + struct OwnerStorage { + address owner; + } + + /** + * @notice Returns the owner storage struct. + * @return s The owner storage struct. + */ + function getStorage() internal pure returns (OwnerStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Get the address of the owner + * @return The address of the owner. + */ + function owner() external view returns (address) { + return getStorage().owner; + } + + /** + * @notice Exports the function selectors of the OwnerDataFacet + * @dev Used as a selector discovery mechanism for diamonds. + * @return selectors The exported function selectors of the OwnerDataFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.owner.selector); + } +} diff --git a/cli/project-template/src/templates/access/Owner/Data/OwnerDataMod.sol b/cli/project-template/src/templates/access/Owner/Data/OwnerDataMod.sol new file mode 100644 index 00000000..12d7b395 --- /dev/null +++ b/cli/project-template/src/templates/access/Owner/Data/OwnerDataMod.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/* + * @title ERC-173 Contract Ownership + * @notice Provides internal functions and storage layout for owner management. + */ + +/** + * @dev This emits when ownership of a contract changes. + */ +event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + +/* + * @notice Thrown when a non-owner attempts an action restricted to owner. + */ +error OwnerUnauthorizedAccount(); +/* + * @notice Thrown when attempting to transfer ownership from a renounced state. + */ +error OwnerAlreadyRenounced(); + +bytes32 constant STORAGE_POSITION = keccak256("erc173.owner"); + +/** + * @custom:storage-location erc8042:erc173.owner + */ +struct OwnerStorage { + address owner; +} + +/** + * @notice Returns a pointer to the ERC-173 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The OwnerStorage struct in storage. + */ +function getStorage() pure returns (OwnerStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Sets the stored owner and emits `OwnershipTransferred` with `previousOwner == address(0)`. + * @dev Does not enforce access control. Use from trusted init paths (for example the diamond constructor); + * for guarded changes, use a transfer facet with `OwnerTransferMod` or similar. + * @param _initialOwner Address written to `OwnerStorage.owner`. + */ +function setContractOwner(address _initialOwner) { + OwnerStorage storage s = getStorage(); + s.owner = _initialOwner; + emit OwnershipTransferred(address(0), _initialOwner); +} + +/** + * @notice Get the address of the owner + * @return The address of the owner. + */ +function owner() view returns (address) { + return getStorage().owner; +} + +/** + * @notice Reverts if the caller is not the owner. + */ +function requireOwner() view { + if (getStorage().owner != msg.sender) { + revert OwnerUnauthorizedAccount(); + } +} diff --git a/cli/project-template/src/templates/access/Owner/Renounce/OwnerRenounceFacet.sol b/cli/project-template/src/templates/access/Owner/Renounce/OwnerRenounceFacet.sol new file mode 100644 index 00000000..6f9efcf6 --- /dev/null +++ b/cli/project-template/src/templates/access/Owner/Renounce/OwnerRenounceFacet.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-173 Contract Facet to Renounce Ownership + */ +contract OwnerRenounceFacet { + /** + * @dev This emits when ownership of a contract changes. + */ + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + /** + * @notice Thrown when a non-owner attempts an action restricted to owner. + */ + error OwnerUnauthorizedAccount(); + + bytes32 constant STORAGE_POSITION = keccak256("erc173.owner"); + + /** + * @custom:storage-location erc8042:erc173.owner + */ + struct OwnerStorage { + address owner; + } + + /** + * @notice Returns a pointer to the owner storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The OwnerStorage struct in storage. + */ + function getStorage() internal pure returns (OwnerStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Renounce ownership of the contract + * @dev Sets the owner to address(0), disabling all functions restricted to the owner. + */ + function renounceOwnership() external { + OwnerStorage storage s = getStorage(); + if (msg.sender != s.owner) { + revert OwnerUnauthorizedAccount(); + } + address previousOwner = s.owner; + s.owner = address(0); + emit OwnershipTransferred(previousOwner, address(0)); + } + + /** + * @notice Exports the function selectors of the OwnerRenounceFacet + * @dev This function is used as a selector discovery mechanism for diamonds. + * @return selectors The exported function selectors of the OwnerRenounceFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.renounceOwnership.selector); + } +} diff --git a/cli/project-template/src/templates/access/Owner/Renounce/OwnerRenounceMod.sol b/cli/project-template/src/templates/access/Owner/Renounce/OwnerRenounceMod.sol new file mode 100644 index 00000000..9efbb822 --- /dev/null +++ b/cli/project-template/src/templates/access/Owner/Renounce/OwnerRenounceMod.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-173 Renounce Ownership Module + * @notice Provides logic to renounce ownership. + */ + +/** + * @dev This emits when ownership of a contract changes. + */ +event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + +/** + * @notice Thrown when a non-owner attempts an action restricted to owner. + */ +error OwnerUnauthorizedAccount(); + +bytes32 constant STORAGE_POSITION = keccak256("erc173.owner"); + +/** + * @custom:storage-location erc8042:erc173.owner + */ +struct OwnerStorage { + address owner; +} + +/** + * @notice Returns a pointer to the ERC-173 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The OwnerStorage struct in storage. + */ +function getStorage() pure returns (OwnerStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Renounce ownership of the contract. + * @dev Sets the owner to address(0), disabling all functions restricted to the owner. + */ +function renounceOwnership() { + OwnerStorage storage s = getStorage(); + if (msg.sender != s.owner) { + revert OwnerUnauthorizedAccount(); + } + address previousOwner = s.owner; + s.owner = address(0); + emit OwnershipTransferred(previousOwner, address(0)); +} + diff --git a/cli/project-template/src/templates/access/Owner/Transfer/OwnerTransferFacet.sol b/cli/project-template/src/templates/access/Owner/Transfer/OwnerTransferFacet.sol new file mode 100644 index 00000000..7e835a58 --- /dev/null +++ b/cli/project-template/src/templates/access/Owner/Transfer/OwnerTransferFacet.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-173 Contract Facet for Ownership Transfer + */ +contract OwnerTransferFacet { + /** + * @dev This emits when ownership of a contract changes. + */ + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + /** + * @notice Thrown when a non-owner attempts an action restricted to owner. + */ + error OwnerUnauthorizedAccount(); + + bytes32 constant STORAGE_POSITION = keccak256("erc173.owner"); + + /** + * @custom:storage-location erc8042:erc173.owner + */ + struct OwnerStorage { + address owner; + } + + /** + * @notice Returns a pointer to the owner storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The OwnerStorage struct in storage. + */ + function getStorage() internal pure returns (OwnerStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Set the address of the new owner of the contract. + * @dev Set _newOwner to address(0) to renounce any ownership. + * @param _newOwner The address of the new owner of the contract. + */ + function transferOwnership(address _newOwner) external { + OwnerStorage storage s = getStorage(); + if (msg.sender != s.owner) { + revert OwnerUnauthorizedAccount(); + } + address previousOwner = s.owner; + s.owner = _newOwner; + emit OwnershipTransferred(previousOwner, _newOwner); + } + + /** + * @notice Exports the function selectors of the OwnerTransferFacet + * @dev Used as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the OwnerTransferFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.transferOwnership.selector); + } +} diff --git a/cli/project-template/src/templates/access/Owner/Transfer/OwnerTransferMod.sol b/cli/project-template/src/templates/access/Owner/Transfer/OwnerTransferMod.sol new file mode 100644 index 00000000..dbaae6da --- /dev/null +++ b/cli/project-template/src/templates/access/Owner/Transfer/OwnerTransferMod.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/* + * @title ERC-173 Ownership Transfer Module + * @notice Provides ownership transfer logic + */ + +/** + * @dev This emits when ownership of a contract changes. + */ +event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + +/* + * @notice Thrown when a non-owner attempts an action restricted to owner. + */ +error OwnerUnauthorizedAccount(); + +bytes32 constant STORAGE_POSITION = keccak256("erc173.owner"); + +/** + * @custom:storage-location erc8042:erc173.owner + */ +struct OwnerStorage { + address owner; +} + +/** + * @notice Returns a pointer to the ERC-173 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The OwnerStorage struct in storage. + */ +function getStorage() pure returns (OwnerStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Set the address of the new owner of the contract. + * @dev Set _newOwner to address(0) to renounce any ownership. + * @param _newOwner The address of the new owner of the contract. + */ +function transferOwnership(address _newOwner) { + OwnerStorage storage s = getStorage(); + if (msg.sender != s.owner) { + revert OwnerUnauthorizedAccount(); + } + address previousOwner = s.owner; + s.owner = _newOwner; + emit OwnershipTransferred(previousOwner, _newOwner); +} diff --git a/cli/project-template/src/templates/access/Owner/TwoSteps/Data/OwnerTwoStepDataFacet.sol b/cli/project-template/src/templates/access/Owner/TwoSteps/Data/OwnerTwoStepDataFacet.sol new file mode 100644 index 00000000..4f07573e --- /dev/null +++ b/cli/project-template/src/templates/access/Owner/TwoSteps/Data/OwnerTwoStepDataFacet.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title OwnerTwoStepDataFacet + */ +contract OwnerTwoStepDataFacet { + bytes32 constant STORAGE_POSITION = keccak256("erc173.owner.pending"); + + /** + * @custom:storage-location erc8042:erc173.owner.pending + */ + struct PendingOwnerStorage { + address pendingOwner; + } + + /** + * @notice Returns a pointer to the PendingOwner storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The PendingOwnerStorage struct in storage. + */ + function getStorage() internal pure returns (PendingOwnerStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Get the address of the pending owner + * @return The address of the pending owner. + */ + function pendingOwner() external view returns (address) { + return getStorage().pendingOwner; + } + + /** + * @notice Exports the function selectors of the OwnerTwoStepDataFacet + * @dev This function is used as a selector discovery mechanism for diamonds. + * @return selectors The exported function selectors of the OwnerTwoStepDataFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.pendingOwner.selector); + } +} diff --git a/cli/project-template/src/templates/access/Owner/TwoSteps/Data/OwnerTwoStepDataMod.sol b/cli/project-template/src/templates/access/Owner/TwoSteps/Data/OwnerTwoStepDataMod.sol new file mode 100644 index 00000000..1690f405 --- /dev/null +++ b/cli/project-template/src/templates/access/Owner/TwoSteps/Data/OwnerTwoStepDataMod.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/* + * @title ERC-173 Two-Step Ownership Data Module + * @notice Provides data for two-step ownership transfer + */ + +bytes32 constant STORAGE_POSITION = keccak256("erc173.owner.pending"); + +/** + * @custom:storage-location erc8042:erc173.owner.pending + */ +struct PendingOwnerStorage { + address pendingOwner; +} + +/** + * @notice Returns a pointer to the PendingOwner storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The PendingOwnerStorage struct in storage. + */ +function getStorage() pure returns (PendingOwnerStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Get the address of the pending owner + * @return The address of the pending owner. + */ +function pendingOwner() view returns (address) { + return getStorage().pendingOwner; +} diff --git a/cli/project-template/src/templates/access/Owner/TwoSteps/Renounce/OwnerTwoStepRenounceFacet.sol b/cli/project-template/src/templates/access/Owner/TwoSteps/Renounce/OwnerTwoStepRenounceFacet.sol new file mode 100644 index 00000000..e9f5fc64 --- /dev/null +++ b/cli/project-template/src/templates/access/Owner/TwoSteps/Renounce/OwnerTwoStepRenounceFacet.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title OwnerTwoStepRenounceFacet + */ +contract OwnerTwoStepRenounceFacet { + /** + * @dev This emits when ownership of a contract changes. + */ + event OwnershipTransferred(address indexed _previousOwner, address indexed _newOwner); + + /** + * @notice Thrown when a non-owner attempts an action restricted to owner. + */ + error OwnerUnauthorizedAccount(); + + bytes32 constant OWNER_STORAGE_POSITION = keccak256("erc173.owner"); + + /** + * @custom:storage-location erc8042:erc173.owner + */ + struct OwnerStorage { + address owner; + } + + bytes32 constant PENDING_OWNER_STORAGE_POSITION = keccak256("erc173.owner.pending"); + + /** + * @custom:storage-location erc8042:erc173.owner.pending + */ + struct PendingOwnerStorage { + address pendingOwner; + } + + /** + * @notice Returns a pointer to the Owner storage struct. + * @dev Uses inline assembly to access the storage slot defined by OWNER_STORAGE_POSITION. + * @return s The OwnerStorage struct in storage. + */ + function getOwnerStorage() internal pure returns (OwnerStorage storage s) { + bytes32 position = OWNER_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns a pointer to the PendingOwner storage struct. + * @dev Uses inline assembly to access the storage slot defined by PENDING_OWNER_STORAGE_POSITION. + * @return s The PendingOwnerStorage struct in storage. + */ + function getPendingOwnerStorage() internal pure returns (PendingOwnerStorage storage s) { + bytes32 position = PENDING_OWNER_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Renounce ownership of the contract + * @dev Sets the owner to address(0), disabling all functions restricted to the owner. + */ + function renounceOwnership() external { + OwnerStorage storage ownerStorage = getOwnerStorage(); + PendingOwnerStorage storage pendingStorage = getPendingOwnerStorage(); + if (msg.sender != ownerStorage.owner) { + revert OwnerUnauthorizedAccount(); + } + address previousOwner = ownerStorage.owner; + ownerStorage.owner = address(0); + pendingStorage.pendingOwner = address(0); + emit OwnershipTransferred(previousOwner, address(0)); + } + + /** + * @notice Exports the function selectors of the OwnerTwoStepRenounceFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the OwnerTwoStepRenounceFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.renounceOwnership.selector); + } +} diff --git a/cli/project-template/src/templates/access/Owner/TwoSteps/Renounce/OwnerTwoStepRenounceMod.sol b/cli/project-template/src/templates/access/Owner/TwoSteps/Renounce/OwnerTwoStepRenounceMod.sol new file mode 100644 index 00000000..369302ac --- /dev/null +++ b/cli/project-template/src/templates/access/Owner/TwoSteps/Renounce/OwnerTwoStepRenounceMod.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/* + * @title ERC-173 Two-Step Renounce Ownership Module + * @notice Provides logic to renounce ownership in a two-step ownership model. + */ + +/** + * @dev This emits when ownership of a contract changes. + */ +event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + +/* + * @notice Thrown when a non-owner attempts an action restricted to owner. + */ +error OwnerUnauthorizedAccount(); + +bytes32 constant OWNER_STORAGE_POSITION = keccak256("erc173.owner"); + +/** + * @custom:storage-location erc8042:erc173.owner + */ +struct OwnerStorage { + address owner; +} + +bytes32 constant PENDING_OWNER_STORAGE_POSITION = keccak256("erc173.owner.pending"); + +/** + * @custom:storage-location erc8042:erc173.owner.pending + */ +struct PendingOwnerStorage { + address pendingOwner; +} + +/** + * @notice Returns a pointer to the ERC-173 storage struct. + * @dev Uses inline assembly to access the storage slot defined by OWNER_STORAGE_POSITION. + * @return s The OwnerStorage struct in storage. + */ +function getOwnerStorage() pure returns (OwnerStorage storage s) { + bytes32 position = OWNER_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Returns a pointer to the PendingOwner storage struct. + * @dev Uses inline assembly to access the storage slot defined by PENDING_OWNER_STORAGE_POSITION. + * @return s The PendingOwnerStorage struct in storage. + */ +function getPendingOwnerStorage() pure returns (PendingOwnerStorage storage s) { + bytes32 position = PENDING_OWNER_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Renounce ownership of the contract. + * @dev Sets the owner to address(0) and clears any pending owner, + * disabling all functions restricted to the owner. + */ +function renounceOwnership() { + OwnerStorage storage ownerStorage = getOwnerStorage(); + PendingOwnerStorage storage pendingStorage = getPendingOwnerStorage(); + if (msg.sender != ownerStorage.owner) { + revert OwnerUnauthorizedAccount(); + } + address previousOwner = ownerStorage.owner; + ownerStorage.owner = address(0); + pendingStorage.pendingOwner = address(0); + emit OwnershipTransferred(previousOwner, address(0)); +} + diff --git a/cli/project-template/src/templates/access/Owner/TwoSteps/Transfer/OwnerTwoStepTransferFacet.sol b/cli/project-template/src/templates/access/Owner/TwoSteps/Transfer/OwnerTwoStepTransferFacet.sol new file mode 100644 index 00000000..b80f5126 --- /dev/null +++ b/cli/project-template/src/templates/access/Owner/TwoSteps/Transfer/OwnerTwoStepTransferFacet.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title OwnerTwoStepTransferFacet + */ +contract OwnerTwoStepTransferFacet { + /** + * @dev This emits when ownership of a contract started transferring to the new owner for accepting the ownership. + */ + event OwnershipTransferStarted(address indexed _previousOwner, address indexed _newOwner); + + /** + * @dev This emits when ownership of a contract changes. + */ + event OwnershipTransferred(address indexed _previousOwner, address indexed _newOwner); + + /** + * @notice Thrown when a non-owner attempts an action restricted to owner. + */ + error OwnerUnauthorizedAccount(); + + bytes32 constant OWNER_STORAGE_POSITION = keccak256("erc173.owner"); + + /** + * @custom:storage-location erc8042:erc173.owner + */ + struct OwnerStorage { + address owner; + } + + bytes32 constant PENDING_OWNER_STORAGE_POSITION = keccak256("erc173.owner.pending"); + + /** + * @custom:storage-location erc8042:erc173.owner.pending + */ + struct PendingOwnerStorage { + address pendingOwner; + } + + /** + * @notice Returns a pointer to the Owner storage struct. + * @dev Uses inline assembly to access the storage slot defined by OWNER_STORAGE_POSITION. + * @return s The OwnerStorage struct in storage. + */ + function getOwnerStorage() internal pure returns (OwnerStorage storage s) { + bytes32 position = OWNER_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns a pointer to the PendingOwner storage struct. + * @dev Uses inline assembly to access the storage slot defined by PENDING_OWNER_STORAGE_POSITION. + * @return s The PendingOwnerStorage struct in storage. + */ + function getPendingOwnerStorage() internal pure returns (PendingOwnerStorage storage s) { + bytes32 position = PENDING_OWNER_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Set the address of the new owner of the contract + * @param _newOwner The address of the new owner of the contract + */ + function transferOwnership(address _newOwner) external { + OwnerStorage storage ownerStorage = getOwnerStorage(); + if (msg.sender != ownerStorage.owner) { + revert OwnerUnauthorizedAccount(); + } + getPendingOwnerStorage().pendingOwner = _newOwner; + emit OwnershipTransferStarted(ownerStorage.owner, _newOwner); + } + + /** + * @notice Accept the ownership of the contract + * @dev Only the pending owner can call this function. + */ + function acceptOwnership() external { + OwnerStorage storage ownerStorage = getOwnerStorage(); + PendingOwnerStorage storage pendingStorage = getPendingOwnerStorage(); + if (msg.sender != pendingStorage.pendingOwner) { + revert OwnerUnauthorizedAccount(); + } + address previousOwner = ownerStorage.owner; + ownerStorage.owner = pendingStorage.pendingOwner; + pendingStorage.pendingOwner = address(0); + emit OwnershipTransferred(previousOwner, ownerStorage.owner); + } + + /** + * @notice Exports the function selectors of the OwnerTwoStepTransferFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the OwnerTwoStepTransferFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.transferOwnership.selector, this.acceptOwnership.selector); + } +} diff --git a/cli/project-template/src/templates/access/Owner/TwoSteps/Transfer/OwnerTwoStepTransferMod.sol b/cli/project-template/src/templates/access/Owner/TwoSteps/Transfer/OwnerTwoStepTransferMod.sol new file mode 100644 index 00000000..b4c45998 --- /dev/null +++ b/cli/project-template/src/templates/access/Owner/TwoSteps/Transfer/OwnerTwoStepTransferMod.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/* + * @title ERC-173 Two-Step Ownership Transfer Module + * @notice Provides logic for two-step ownership transfers. + */ + +/** + * @dev Emitted when ownership transfer is initiated (pending owner set). + */ +event OwnershipTransferStarted(address indexed _previousOwner, address indexed _newOwner); + +/** + * @dev Emitted when ownership transfer is finalized. + */ +event OwnershipTransferred(address indexed _previousOwner, address indexed _newOwner); + +/* + * @notice Thrown when a non-owner attempts an action restricted to owner. + */ +error OwnerUnauthorizedAccount(); + +bytes32 constant OWNER_STORAGE_POSITION = keccak256("erc173.owner"); + +/** + * @custom:storage-location erc8042:erc173.owner + */ +struct OwnerStorage { + address owner; +} + +bytes32 constant PENDING_OWNER_STORAGE_POSITION = keccak256("erc173.owner.pending"); + +/** + * @custom:storage-location erc8042:erc173.owner.pending + */ +struct PendingOwnerStorage { + address pendingOwner; +} + +/** + * @notice Returns a pointer to the Owner storage struct. + * @dev Uses inline assembly to access the storage slot defined by OWNER_STORAGE_POSITION. + * @return s The OwnerStorage struct in storage. + */ +function getOwnerStorage() pure returns (OwnerStorage storage s) { + bytes32 position = OWNER_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Returns a pointer to the PendingOwner storage struct. + * @dev Uses inline assembly to access the storage slot defined by PENDING_OWNER_STORAGE_POSITION. + * @return s The PendingOwnerStorage struct in storage. + */ +function getPendingOwnerStorage() pure returns (PendingOwnerStorage storage s) { + bytes32 position = PENDING_OWNER_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Initiates a two-step ownership transfer. + * @param _newOwner The address of the new owner of the contract. + */ +function transferOwnership(address _newOwner) { + OwnerStorage storage ownerStorage = getOwnerStorage(); + if (msg.sender != ownerStorage.owner) { + revert OwnerUnauthorizedAccount(); + } + getPendingOwnerStorage().pendingOwner = _newOwner; + emit OwnershipTransferStarted(ownerStorage.owner, _newOwner); +} + +/** + * @notice Finalizes ownership transfer. + * @dev Only the pending owner can call this function. + */ +function acceptOwnership() { + OwnerStorage storage ownerStorage = getOwnerStorage(); + PendingOwnerStorage storage pendingStorage = getPendingOwnerStorage(); + if (msg.sender != pendingStorage.pendingOwner) { + revert OwnerUnauthorizedAccount(); + } + address previousOwner = ownerStorage.owner; + ownerStorage.owner = pendingStorage.pendingOwner; + pendingStorage.pendingOwner = address(0); + emit OwnershipTransferred(previousOwner, ownerStorage.owner); +} + diff --git a/cli/project-template/src/templates/diamond/DiamondInspectFacet.sol b/cli/project-template/src/templates/diamond/DiamondInspectFacet.sol new file mode 100644 index 00000000..4f1c4490 --- /dev/null +++ b/cli/project-template/src/templates/diamond/DiamondInspectFacet.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +interface IFacet { + function exportSelectors() external view returns (bytes memory); +} + +contract DiamondInspectFacet { + bytes32 constant DIAMOND_STORAGE_POSITION = keccak256("erc8153.diamond"); + + struct FacetNode { + address facet; + bytes4 prevFacetNodeId; + bytes4 nextFacetNodeId; + } + + struct FacetList { + bytes4 headFacetNodeId; + bytes4 tailFacetNodeId; + uint32 facetCount; + uint32 selectorCount; + } + + /** + * @custom:storage-location erc8042:erc8153.diamond + */ + struct DiamondStorage { + mapping(bytes4 functionSelector => FacetNode) facetNodes; + FacetList facetList; + } + + function getStorage() internal pure returns (DiamondStorage storage s) { + bytes32 position = DIAMOND_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Gets the facet address that handles the given selector. + * @dev If facet is not found return address(0). + * @param _functionSelector The function selector. + * @return facet The facet address. + */ + function facetAddress(bytes4 _functionSelector) external view returns (address facet) { + DiamondStorage storage s = getStorage(); + facet = s.facetNodes[_functionSelector].facet; + } + + /** + * @notice Decodes a packed bytes array into a standard bytes4[] array. + * @param packed The packed bytes (e.g., from `bytes.concat`). + * @return unpacked The standard padded bytes4[] array. + */ + function unpackSelectors(bytes memory packed) internal pure returns (bytes4[] memory unpacked) { + /* + * Allocate the output array + */ + uint256 count = packed.length / 4; + unpacked = new bytes4[](count); + /* + * Copy from packed to unpacked + */ + assembly ("memory-safe") { + /* + * 'src' points to the start of the data in the packed array (skip 32-byte length) + */ + let src := add(packed, 32) + /* + * 'dst' points to the start of the data in the new selectors array (skip 32-byte length) + */ + let dst := add(unpacked, 32) + /* + * 'end' is the stopping point for the destination pointer + */ + let end := add(dst, mul(count, 32)) + /* + * While 'dst' is less than 'end', keep copying + */ + for {} lt(dst, end) {} { + /* + * A. Load 32 bytes from the packed source. + * We read "dirty" data (neighboring bytes), but it doesn't matter + * because we truncate it when writing. + */ + let value := mload(src) + /* + * B. Clearn up the value to extract only the 4 bytes we want. + */ + value := and(value, 0xFFFFFFFF00000000000000000000000000000000000000000000000000000000) + /* + * C. Store the value into the destination + */ + mstore(dst, value) + /* + * D. Advance pointers + */ + src := add(src, 4) // Move forward 4 bytes in packed source + dst := add(dst, 32) // Move forward 32 bytes in destination target + } + } + } + + /** + * @notice Gets the function selectors that are handled by the given facet. + * @dev If facet is not found return empty array. + * @param _facet The facet address. + * @return facetSelectors The function selectors. + */ + function facetFunctionSelectors(address _facet) external view returns (bytes4[] memory facetSelectors) { + DiamondStorage storage s = getStorage(); + facetSelectors = unpackSelectors(IFacet(_facet).exportSelectors()); + if (facetSelectors.length == 0 || s.facetNodes[facetSelectors[0]].facet == address(0)) { + facetSelectors = new bytes4[](0); + } + } + + /** + * @notice Gets the facet addresses used by the diamond. + * @dev If no facets are registered return empty array. + * @return allFacets The facet addresses. + */ + function facetAddresses() external view returns (address[] memory allFacets) { + DiamondStorage storage s = getStorage(); + FacetList memory facetList = s.facetList; + allFacets = new address[](facetList.facetCount); + bytes4 currentSelector = facetList.headFacetNodeId; + for (uint256 i; i < facetList.facetCount; i++) { + address facet = s.facetNodes[currentSelector].facet; + allFacets[i] = facet; + currentSelector = s.facetNodes[currentSelector].nextFacetNodeId; + } + } + + struct Facet { + address facet; + bytes4[] functionSelectors; + } + + /** + * @notice Returns the facet address and function selectors of all facets + * in the diamond. + * @return facetsAndSelectors An array of Facet structs containing each + * facet address and its function selectors. + */ + function facets() external view returns (Facet[] memory facetsAndSelectors) { + DiamondStorage storage s = getStorage(); + FacetList memory facetList = s.facetList; + bytes4 currentSelector = facetList.headFacetNodeId; + facetsAndSelectors = new Facet[](facetList.facetCount); + for (uint256 i; i < facetList.facetCount; i++) { + address facet = s.facetNodes[currentSelector].facet; + bytes4[] memory facetSelectors = unpackSelectors(IFacet(facet).exportSelectors()); + facetsAndSelectors[i].facet = facet; + facetsAndSelectors[i].functionSelectors = facetSelectors; + currentSelector = s.facetNodes[currentSelector].nextFacetNodeId; + } + } + + /** + * @notice Exports the function selectors of the DiamondInspectFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the DiamondInspectFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat( + this.facetAddress.selector, + this.facetFunctionSelectors.selector, + this.facetAddresses.selector, + this.facets.selector + ); + } +} diff --git a/cli/project-template/src/templates/diamond/DiamondMod.sol b/cli/project-template/src/templates/diamond/DiamondMod.sol new file mode 100644 index 00000000..4cb090fb --- /dev/null +++ b/cli/project-template/src/templates/diamond/DiamondMod.sol @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/* +* @title Diamond Module +* @notice Internal functions and storage for diamond proxy functionality. +* @dev Follows EIP-8153 Facet-Based Diamonds +*/ + +interface IFacet { + function exportSelectors() external pure returns (bytes memory); +} + +bytes32 constant DIAMOND_STORAGE_POSITION = keccak256("erc8153.diamond"); + +struct FacetNode { + address facet; + bytes4 prevFacetNodeId; + bytes4 nextFacetNodeId; +} + +struct FacetList { + bytes4 headFacetNodeId; + bytes4 tailFacetNodeId; + uint32 facetCount; + uint32 selectorCount; +} + +/** + * @custom:storage-location erc8042:erc8153.diamond + */ +struct DiamondStorage { + mapping(bytes4 functionSelector => FacetNode) facetNodes; + FacetList facetList; +} + +function getDiamondStorage() pure returns (DiamondStorage storage s) { + bytes32 position = DIAMOND_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Emitted when a facet is added to a diamond. + * @dev The function selectors this facet handles can be retrieved by calling + * `IFacet(_facet).exportSelectors()` + * + * @param _facet The address of the facet that handles function calls to the diamond. + */ +event FacetAdded(address indexed _facet); + +/** + * @notice Emitted when an existing facet is replaced with a new facet. + * @dev + * - Selectors that are present in the new facet but not in the old facet are added to the diamond. + * - Selectors that are present in both the new and old facet are updated to use the new facet. + * - Selectors that are not present in the new facet but are present in the old facet are removed from + * the diamond. + * + * The function selectors handled by these facets can be retrieved by calling: + * - `IFacet(_oldFacet).exportSelectors()` + * - `IFacet(_newFacet).exportSelectors()` + * + * @param _oldFacet The address of the facet that previously handled function calls to the diamond. + * @param _newFacet The address of the facet that now handles function calls to the diamond. + */ +event FacetReplaced(address indexed _oldFacet, address indexed _newFacet); + +/** + * @notice Emitted when a facet is removed from a diamond. + * @dev The function selectors this facet handles can be retrieved by calling + * `IFacet(_facet).exportSelectors()` + * + * @param _facet The address of the facet that previouly handled function calls to the diamond. + */ +event FacetRemoved(address indexed _facet); + +/** + * @notice Emitted when a diamond's constructor function or function from a + * facet makes a `delegatecall`. + * + * @param _delegate The contract that was delegatecalled. + * @param _delegateCalldata The function call, including function selector and + * any arguments. + */ +event DiamondDelegateCall(address indexed _delegate, bytes _delegateCalldata); + +/** + * @notice Emitted to record information about a diamond. + * @dev This event records any arbitrary metadata. + * The format of `_tag` and `_data` are not specified by the + * standard. + * + * @param _tag Arbitrary metadata, such as a release version. + * @param _data Arbitrary metadata. + */ +event DiamondMetadata(bytes32 indexed _tag, bytes _data); + +/** + * @notice The upgradeDiamond function below detects and reverts + * with the following errors. + */ +error NoSelectorsForFacet(address _facet); +error NoBytecodeAtAddress(address _contractAddress); +error CannotAddFunctionToDiamondThatAlreadyExists(bytes4 _selector); +error FunctionSelectorsCallFailed(address _facet); +error IncorrectSelectorsEncoding(address _facet); + +function importSelectors(address _facet) view returns (bytes memory selectors) { + (bool success, bytes memory data) = _facet.staticcall(abi.encodeWithSelector(IFacet.exportSelectors.selector)); + if (success == false) { + revert FunctionSelectorsCallFailed(_facet); + } + /* + * Ensure the data is large enough. + * Offset (32 bytes) + array length (32 bytes) + */ + if (data.length < 64) { + if (_facet.code.length == 0) { + revert NoBytecodeAtAddress(_facet); + } else { + revert IncorrectSelectorsEncoding(_facet); + } + } + + /* Validate ABI offset == 0x20 for a single dynamic return */ + uint256 offset; + assembly ("memory-safe") { + offset := mload(add(data, 0x20)) + } + if (offset != 0x20) { + revert IncorrectSelectorsEncoding(_facet); + } + /* + * ZERO-COPY DECODE + * Instead of abi.decode(wrapper, (bytes)), which copies memory, + * we use assembly to point 'selectors' to the bytes array inside 'data'. + * The length of `data` is stored at 0 and an ABI offset is located at 0x20 (32). + * We skip over those to point `selectors` to the length of the + * bytes array. + */ + assembly ("memory-safe") { + selectors := add(data, 0x40) + } + uint256 selectorsLength = selectors.length; + unchecked { + if (selectorsLength > data.length - 64) { + revert IncorrectSelectorsEncoding(_facet); + } + } + if (selectorsLength < 4) { + revert NoSelectorsForFacet(_facet); + } + /* + * Function selectors are strictly 4 bytes. We ensure the length is a multiple of 4. + */ + if (selectorsLength % 4 != 0) { + revert IncorrectSelectorsEncoding(_facet); + } + return selectors; +} + +function at(bytes memory selectors, uint256 index) pure returns (bytes4 selector) { + assembly ("memory-safe") { + /** + * 1. Calculate Pointer + * add(selectors, 32) - skips the length field of the bytes array + * shl(2, index) is the same as index * 4 but cheaper + * This line executes: ptr = selectorsLength + (4 * index) + */ + let ptr := add(add(selectors, 32), shl(2, index)) + /** + * 2. Load & Return + * We load 32 bytes, but Solidity truncates to 4 bytes automatically + * upon return of this function, so masking is unnecessary. + */ + selector := mload(ptr) + } +} + +function addFacets(address[] memory _facets) { + DiamondStorage storage s = getDiamondStorage(); + uint256 facetLength = _facets.length; + if (facetLength == 0) { + return; + } + FacetList memory facetList = s.facetList; + /* + * Snapshot free memory pointer. We restore this at the end of every loop + * to prevent memory expansion costs from repeated `packedSelectors` calls. + */ + uint256 freeMemPtr; + assembly ("memory-safe") { + freeMemPtr := mload(0x40) + } + /* Algorithm Description: + * The first facet is handled separately to initialize the linked list pointers in the FacetNodes. + * This allows us to avoid additional conditional checks for linked list management in the main facet loop. + * + * For the first facet, we link the first selector to the previous facet or if this is the first facet in + * the diamond then we assign the first selector to facetList.firstFacetNodeId. + * + * All the selectors (except the first one) in the first facet are then added to the diamond. + * + * In the first iteration of the main facet loop the the selectors for the next facet are retrieved. + * This makes available the nextFacetNodeId value that is needed to store the first selector of the + * first facet. So then the first selector is stored. + * + * Then the selectors which were already retrieved for the next facet are stored, except the first selector. + * Then in the next iteration the selectors of the next facet are retrieved. This makes available the nextFacetNodeId + * value that is needed to store the first selector of the previous facet. The first selector is then stored. The loop + * continues. + * + * After the main facet loop ends, the first selector from the last facet is added to the diamond. + */ + + bytes4 prevFacetNodeId = facetList.tailFacetNodeId; + address facet = _facets[0]; + bytes memory selectors = importSelectors(facet); + /* + * currentFacetNodeId is the head node of the current facet. + * We cannot write it to storage yet because we don't know the `next` pointer. + */ + bytes4 currentFacetNodeId = at(selectors, 0); + if (facetList.facetCount == 0) { + facetList.headFacetNodeId = currentFacetNodeId; + } else { + /* + * Link the previous tail of the diamond to this new batch + */ + s.facetNodes[prevFacetNodeId].nextFacetNodeId = currentFacetNodeId; + } + /* + * Shift right by 2 is the same as dividing by 4, but cheaper. + * We do this to get the number of selectors + */ + uint256 selectorsLength = selectors.length >> 2; + unchecked { + facetList.selectorCount += uint32(selectorsLength); + } + /* + * Add all selectors, except the first, to the diamond. + */ + for (uint256 selectorIndex = 1; selectorIndex < selectorsLength; selectorIndex++) { + bytes4 selector = at(selectors, selectorIndex); + if (s.facetNodes[selector].facet != address(0)) { + revert CannotAddFunctionToDiamondThatAlreadyExists(selector); + } + s.facetNodes[selector] = FacetNode(facet, bytes4(0), bytes4(0)); + } + /* + * Reset memory for the main loop. + */ + assembly ("memory-safe") { + mstore(0x40, freeMemPtr) + } + /* + * Main facet loop. + * 1. Gets the next facet's selectors. + * 2. Now that the nextFacetNodeId value for the previous facet is available, adds the previous + * facet's first selector to the diamond. + * 3. Emits FacetAdded event for the previous facet. + * 3. Updates facet values: facet = nextFacet, etc. + * 4. Adds all the selectors (except the first) to the diamond. + * 5. Repeat loop. + */ + for (uint256 i = 1; i < facetLength; i++) { + address nextFacet = _facets[i]; + selectors = importSelectors(nextFacet); + /* + * Check to see if the PENDING first selector (from previous iteration) already exists in the diamond. + */ + if (s.facetNodes[currentFacetNodeId].facet != address(0)) { + revert CannotAddFunctionToDiamondThatAlreadyExists(currentFacetNodeId); + } + /* + * Identify the link to the next facet + */ + bytes4 nextFacetNodeId = at(selectors, 0); + /* + * Store the previous facet's first selector. + */ + s.facetNodes[currentFacetNodeId] = FacetNode(facet, prevFacetNodeId, nextFacetNodeId); + emit FacetAdded(facet); + /* + * Move pointers forward. + * These assignments switch us from processing the previous facet's first selector to + * processing the next facet's selectors. + * `currentFacetNodeId` becomes the new pending first selector. + */ + facet = nextFacet; + prevFacetNodeId = currentFacetNodeId; + currentFacetNodeId = nextFacetNodeId; + /* + * Shift right by 2 is the same as dividing by 4, but cheaper. + * We do this to get the number of selectors. + */ + selectorsLength = selectors.length >> 2; + /* + * Add all the selectors of the facet to the diamond, except the first selector. + */ + for (uint256 selectorIndex = 1; selectorIndex < selectorsLength; selectorIndex++) { + bytes4 selector = at(selectors, selectorIndex); + if (s.facetNodes[selector].facet != address(0)) { + revert CannotAddFunctionToDiamondThatAlreadyExists(selector); + } + s.facetNodes[selector] = FacetNode(facet, bytes4(0), bytes4(0)); + } + unchecked { + facetList.selectorCount += uint32(selectorsLength); + } + /* + * Restore Free Memory Pointer to reuse memory from packedSelectors() calls. + */ + assembly ("memory-safe") { + mstore(0x40, freeMemPtr) + } + } + /* + * Validates and adds the first selector of the last facet to the diamond. + */ + if (s.facetNodes[currentFacetNodeId].facet != address(0)) { + revert CannotAddFunctionToDiamondThatAlreadyExists(currentFacetNodeId); + } + s.facetNodes[currentFacetNodeId] = FacetNode(facet, prevFacetNodeId, bytes4(0)); + emit FacetAdded(facet); + facetList.facetCount += uint32(facetLength); + + facetList.tailFacetNodeId = currentFacetNodeId; + s.facetList = facetList; +} + +error FunctionNotFound(bytes4 _selector); + +/** + * Find facet for function that is called and execute the + * function if a facet is found and return any value. + */ +function diamondFallback() { + DiamondStorage storage s = getDiamondStorage(); + /** + * get facet from function selector + */ + address facet = s.facetNodes[msg.sig].facet; + if (facet == address(0)) { + revert FunctionNotFound(msg.sig); + } + /* + * Execute external function from facet using delegatecall and return any value. + */ + assembly { + /* + * copy function selector and any arguments + */ + calldatacopy(0, 0, calldatasize()) + /* + * execute function call using the facet + */ + let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0) + /* + * get any return value + */ + returndatacopy(0, 0, returndatasize()) + /* + * return any return value or error back to the caller + */ + switch result + case 0 { + revert(0, returndatasize()) + } + default { + return(0, returndatasize()) + } + } +} diff --git a/cli/project-template/src/templates/diamond/DiamondUpgradeFacet.sol b/cli/project-template/src/templates/diamond/DiamondUpgradeFacet.sol new file mode 100644 index 00000000..dfde194e --- /dev/null +++ b/cli/project-template/src/templates/diamond/DiamondUpgradeFacet.sol @@ -0,0 +1,725 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title Reference implementation for upgrade function for + * ERC-8153 Facet-Based Diamonds + * + * @dev + * Facets are stored as a doubly linked list and as a mapping of selectors to facet addresses. + * + * Facets are stored as a mapping of selectors to facet addresses for efficient delegatecall + * routing to facets. + * + * Facets are stored as a doubly linked list for efficient iteration over all facets, + * and for efficiently adding, replacing, and removing them. + * + * The `FacetList` struct contains information about the linked list of facets. + * + * Only the first FacetNode of each facet contains linked list pointers. + * * prevFacetNodeId - Is the selector of the first FacetNode of the previous + * facet. + * * nextFacetNodeId - Is the selector of the first FacetNode of the next + * facet. + * + * Here is a example that shows the structure: + * + * FacetList + * facetCount = 3 + * headFacetNodeId = selector1 // facetA + * tailFacetNodeId = selector7 // facetC + * + * facetNodes mapping (selector => FacetNode) + * + * selector facet prevFacetNodeId nextFacetNodeId + * ---------------------------------------------------------------- + * selector1 facetA 0x00000000 selector4 ← facetA LIST NODE + * selector2 facetA 0x00000000 0x00000000 + * selector3 facetA 0x00000000 0x00000000 + * + * selector4 facetB selector1 selector7 ← facetB LIST NODE + * selector5 facetB 0x00000000 0x00000000 + * selector6 facetB 0x00000000 0x00000000 + * + * selector7 facetC selector4 0x00000000 ← facetC LIST NODE + * selector8 facetC 0x00000000 0x00000000 + * selector9 facetC 0x00000000 0x00000000 + * + * Linked list order of facets: + * + * facetA (selector1) + * ↓ + * facetB (selector4) + * ↓ + * facetC (selector7) + * + * Notes: + * - Only the first selector of each facet participates in the linked list. + * - The linked list connects facets, not individual selectors. + * - Any values in "prevFacetNodeId" in non-first FacetNodes are not used. + * + * Checked/unchecked math note: + * We use unchecked math with `facetList.selectorCount` because that variable does not affect the adding, + * replacing, and removing of selectors and facets. It is used by some introspection functions. Checked math + * is used with facetList.facetCount because that affects adding, replacing, and removing selectors and facets. + * Of course these variables should never overflow/underflow anyway. + * + * Security: + * This implementation relies on the assumption that the owner of the diamond that has added or replaced any + * facet in the diamond has verified that each facet is not malicious, that each facet is immutable (not upgradeable), + * and that the `exportSelectors()` function in each facet is pure (is marked as a pure function and does not access state.) + */ + +interface IFacet { + function exportSelectors() external pure returns (bytes memory); +} + +contract DiamondUpgradeFacet { + /** + * @notice Thrown when a non-owner attempts an action restricted to owner. + */ + error OwnerUnauthorizedAccount(); + + bytes32 constant OWNER_STORAGE_POSITION = keccak256("erc173.owner"); + + /** + * @custom:storage-location erc8042:erc173.owner + */ + struct OwnerStorage { + address owner; + } + + /** + * @notice Returns a pointer to the owner storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The OwnerStorage struct in storage. + */ + function getOwnerStorage() internal pure returns (OwnerStorage storage s) { + bytes32 position = OWNER_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + bytes32 constant DIAMOND_STORAGE_POSITION = keccak256("erc8153.diamond"); + + struct FacetNode { + address facet; + bytes4 prevFacetNodeId; + bytes4 nextFacetNodeId; + } + + struct FacetList { + bytes4 headFacetNodeId; + bytes4 tailFacetNodeId; + uint32 facetCount; + uint32 selectorCount; + } + + /** + * @custom:storage-location erc8042:erc8153.diamond + */ + struct DiamondStorage { + mapping(bytes4 functionSelector => FacetNode) facetNodes; + FacetList facetList; + } + + function getDiamondStorage() internal pure returns (DiamondStorage storage s) { + bytes32 position = DIAMOND_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Emitted when a facet is added to a diamond. + * @dev The function selectors this facet handles can be retrieved by calling + * `IFacet(_facet).exportSelectors()` + * + * @param _facet The address of the facet that handles function calls to the diamond. + */ + event FacetAdded(address indexed _facet); + + /** + * @notice Emitted when an existing facet is replaced with a new facet. + * @dev + * - Selectors that are present in the new facet but not in the old facet are added to the diamond. + * - Selectors that are present in both the new and old facet are updated to use the new facet. + * - Selectors that are not present in the new facet but are present in the old facet are removed from + * the diamond. + * + * The function selectors handled by these facets can be retrieved by calling: + * - `IFacet(_oldFacet).exportSelectors()` + * - `IFacet(_newFacet).exportSelectors()` + * + * @param _oldFacet The address of the facet that previously handled function calls to the diamond. + * @param _newFacet The address of the facet that now handles function calls to the diamond. + */ + event FacetReplaced(address indexed _oldFacet, address indexed _newFacet); + + /** + * @notice Emitted when a facet is removed from a diamond. + * @dev The function selectors this facet handles can be retrieved by calling + * `IFacet(_facet).exportSelectors()` + * + * @param _facet The address of the facet that previously handled function calls to the diamond. + */ + event FacetRemoved(address indexed _facet); + + /** + * @notice Emitted when a diamond's constructor function or function from a + * facet makes a `delegatecall`. + * + * @param _delegate The contract that was delegatecalled. + * @param _delegateCalldata The function call, including function selector and + * any arguments. + */ + event DiamondDelegateCall(address indexed _delegate, bytes _delegateCalldata); + + /** + * @notice Emitted to record information about a diamond. + * @dev This event records any arbitrary metadata. + * The format of `_tag` and `_data` are not specified by the + * standard. + * + * @param _tag Arbitrary metadata, such as a release version. + * @param _data Arbitrary metadata. + */ + event DiamondMetadata(bytes32 indexed _tag, bytes _data); + + /** + * @notice The upgradeDiamond function below detects and reverts + * with the following errors. + */ + error NoSelectorsForFacet(address _facet); + error NoBytecodeAtAddress(address _contractAddress); + error CannotAddFunctionToDiamondThatAlreadyExists(bytes4 _selector); + error CannotRemoveFacetThatDoesNotExist(address _facet); + error CannotReplaceFacetWithSameFacet(address _facet); + error FacetToReplaceDoesNotExist(address _oldFacet); + error DelegateCallReverted(address _delegate, bytes _delegateCalldata); + error ExportSelectorsCallFailed(address _facet); + error IncorrectSelectorsEncoding(address _facet); + + /** + * @dev This error means that a function to replace exists in a + * facet other than the facet that was given to be replaced. + */ + error CannotReplaceFunctionFromNonReplacementFacet(bytes4 _selector); + + function importSelectors(address _facet) internal view returns (bytes memory selectors) { + (bool success, bytes memory data) = _facet.staticcall(abi.encodeWithSelector(IFacet.exportSelectors.selector)); + if (success == false) { + revert ExportSelectorsCallFailed(_facet); + } + /* + * Ensure the data is large enough. + * Offset (32 bytes) + array length (32 bytes) + */ + if (data.length < 64) { + if (_facet.code.length == 0) { + revert NoBytecodeAtAddress(_facet); + } else { + revert IncorrectSelectorsEncoding(_facet); + } + } + + /* Validate ABI offset == 0x20 for a single dynamic return */ + uint256 offset; + assembly ("memory-safe") { + offset := mload(add(data, 0x20)) + } + if (offset != 0x20) { + revert IncorrectSelectorsEncoding(_facet); + } + /* + * ZERO-COPY DECODE + * Instead of abi.decode(wrapper, (bytes)), which copies memory, + * we use assembly to point 'selectors' to the bytes array inside 'data'. + * The length of `data` is stored at 0 and an ABI offset is located at 0x20 (32). + * We skip over those to point `selectors` to the length of the + * bytes array. + */ + assembly ("memory-safe") { + selectors := add(data, 0x40) + } + uint256 selectorsLength = selectors.length; + unchecked { + if (selectorsLength > data.length - 64) { + revert IncorrectSelectorsEncoding(_facet); + } + } + if (selectorsLength < 4) { + revert NoSelectorsForFacet(_facet); + } + /* + * Function selectors are strictly 4 bytes. We ensure the length is a multiple of 4. + */ + if (selectorsLength % 4 != 0) { + revert IncorrectSelectorsEncoding(_facet); + } + return selectors; + } + + function at(bytes memory selectors, uint256 index) internal pure returns (bytes4 selector) { + assembly ("memory-safe") { + /** + * 1. Calculate Pointer + * add(selectors, 32) - skips the length field of the bytes array + * shl(2, index) is the same as index * 4 but cheaper + * This line executes: ptr = selectorsLength + (4 * index) + */ + let ptr := add(add(selectors, 32), shl(2, index)) + /** + * 2. Load & Return + * We load 32 bytes, but Solidity truncates to 4 bytes automatically + * upon return of this function, so masking is unnecessary. + */ + selector := mload(ptr) + } + } + + function addFacets(address[] calldata _facets) internal { + DiamondStorage storage s = getDiamondStorage(); + uint256 facetLength = _facets.length; + if (facetLength == 0) { + return; + } + FacetList memory facetList = s.facetList; + /* + * Snapshot free memory pointer. We restore this at the end of every loop + * to prevent memory expansion costs from repeated `packedSelectors` calls. + */ + uint256 freeMemPtr; + assembly ("memory-safe") { + freeMemPtr := mload(0x40) + } + /* Algorithm Description: + * The first facet is handled separately to initialize the linked list pointers in the FacetNodes. + * This allows us to avoid additional conditional checks for linked list management in the main facet loop. + * + * For the first facet, we link the first selector to the previous facet or if this is the first facet in + * the diamond then we assign the first selector to facetList.firstFacetNodeId. + * + * All the selectors (except the first one) in the first facet are then added to the diamond. + * + * In the first iteration of the main facet loop the the selectors for the next facet are retrieved. + * This makes available the nextFacetNodeId value that is needed to store the first selector of the + * first facet. So then the first selector is stored. + * + * Then the selectors which were already retrieved for the next facet are stored, except the first selector. + * Then in the next iteration the selectors of the next facet are retrieved. This makes available the nextFacetNodeId + * value that is needed to store the first selector of the previous facet. The first selector is then stored. The loop + * continues. + * + * After the main facet loop ends, the first selector from the last facet is added to the diamond. + */ + + bytes4 prevFacetNodeId = facetList.tailFacetNodeId; + address facet = _facets[0]; + bytes memory selectors = importSelectors(facet); + /* + * currentFacetNodeId is the head node of the current facet. + * We cannot write it to storage yet because we don't know the `next` pointer. + */ + bytes4 currentFacetNodeId = at(selectors, 0); + if (facetList.facetCount == 0) { + facetList.headFacetNodeId = currentFacetNodeId; + } else { + /* + * Link the previous tail of the diamond to this new batch + */ + s.facetNodes[prevFacetNodeId].nextFacetNodeId = currentFacetNodeId; + } + /* + * Shift right by 2 is the same as dividing by 4, but cheaper. + * We do this to get the number of selectors + */ + uint256 selectorsLength = selectors.length >> 2; + unchecked { + facetList.selectorCount += uint32(selectorsLength); + } + /* + * Add all selectors, except the first, to the diamond. + */ + for (uint256 selectorIndex = 1; selectorIndex < selectorsLength; selectorIndex++) { + bytes4 selector = at(selectors, selectorIndex); + if (s.facetNodes[selector].facet != address(0)) { + revert CannotAddFunctionToDiamondThatAlreadyExists(selector); + } + s.facetNodes[selector] = FacetNode(facet, bytes4(0), bytes4(0)); + } + /* + * Reset memory for the main loop. + */ + assembly ("memory-safe") { + mstore(0x40, freeMemPtr) + } + /* + * Main facet loop. + * 1. Gets the next facet's selectors. + * 2. Now that the nextFacetNodeId value for the previous facet is available, adds the previous + * facet's first selector to the diamond. + * 3. Emits FacetAdded event for the previous facet. + * 3. Updates facet values: facet = nextFacet, etc. + * 4. Adds all the selectors (except the first) to the diamond. + * 5. Repeat loop. + */ + for (uint256 i = 1; i < facetLength; i++) { + address nextFacet = _facets[i]; + selectors = importSelectors(nextFacet); + /* + * Check to see if the PENDING first selector (from previous iteration) already exists in the diamond. + */ + if (s.facetNodes[currentFacetNodeId].facet != address(0)) { + revert CannotAddFunctionToDiamondThatAlreadyExists(currentFacetNodeId); + } + /* + * Identify the link to the next facet + */ + bytes4 nextFacetNodeId = at(selectors, 0); + /* + * Store the previous facet's first selector. + */ + s.facetNodes[currentFacetNodeId] = FacetNode(facet, prevFacetNodeId, nextFacetNodeId); + emit FacetAdded(facet); + /* + * Move pointers forward. + * These assignments switch us from processing the previous facet's first selector to + * processing the next facet's selectors. + * `currentFacetNodeId` becomes the new pending first selector. + */ + facet = nextFacet; + prevFacetNodeId = currentFacetNodeId; + currentFacetNodeId = nextFacetNodeId; + /* + * Shift right by 2 is the same as dividing by 4, but cheaper. + * We do this to get the number of selectors. + */ + selectorsLength = selectors.length >> 2; + /* + * Add all the selectors of the facet to the diamond, except the first selector. + */ + for (uint256 selectorIndex = 1; selectorIndex < selectorsLength; selectorIndex++) { + bytes4 selector = at(selectors, selectorIndex); + if (s.facetNodes[selector].facet != address(0)) { + revert CannotAddFunctionToDiamondThatAlreadyExists(selector); + } + s.facetNodes[selector] = FacetNode(facet, bytes4(0), bytes4(0)); + } + unchecked { + facetList.selectorCount += uint32(selectorsLength); + } + /* + * Restore Free Memory Pointer to reuse memory from packedSelectors() calls. + */ + assembly ("memory-safe") { + mstore(0x40, freeMemPtr) + } + } + /* + * Validates and adds the first selector of the last facet to the diamond. + */ + if (s.facetNodes[currentFacetNodeId].facet != address(0)) { + revert CannotAddFunctionToDiamondThatAlreadyExists(currentFacetNodeId); + } + s.facetNodes[currentFacetNodeId] = FacetNode(facet, prevFacetNodeId, bytes4(0)); + emit FacetAdded(facet); + facetList.facetCount += uint32(facetLength); + + facetList.tailFacetNodeId = currentFacetNodeId; + s.facetList = facetList; + } + + /** + * @notice This struct is used to replace old facets with new facets. + */ + struct FacetReplacement { + address oldFacet; + address newFacet; + } + + function replaceFacets(FacetReplacement[] calldata _replaceFacets) internal { + DiamondStorage storage s = getDiamondStorage(); + FacetList memory facetList = s.facetList; + /* + * Snapshot free memory pointer. We restore this within the loop to prevent + * memory expansion costs from repeated `packedSelectors` calls. + */ + uint256 freeMemPtr; + assembly ("memory-safe") { + freeMemPtr := mload(0x40) + } + for (uint256 i; i < _replaceFacets.length; i++) { + address oldFacet = _replaceFacets[i].oldFacet; + address newFacet = _replaceFacets[i].newFacet; + if (oldFacet == newFacet) { + revert CannotReplaceFacetWithSameFacet(oldFacet); + } + bytes memory oldSelectors = importSelectors(oldFacet); + bytes memory newSelectors = importSelectors(newFacet); + /* + * Shift right by 2 is the same as dividing by 4, but cheaper. + * We do this to get the number of selectors + */ + uint256 selectorsLength = newSelectors.length >> 2; + bytes4 oldCurrentFacetNodeId = at(oldSelectors, 0); + bytes4 newCurrentFacetNodeId = at(newSelectors, 0); + + /** + * Validate old facet exists. + */ + FacetNode memory oldFacetNode = s.facetNodes[oldCurrentFacetNodeId]; + if (oldFacetNode.facet != oldFacet) { + revert FacetToReplaceDoesNotExist(oldFacet); + } + if (oldCurrentFacetNodeId != newCurrentFacetNodeId) { + /** + * Write first selector with linking info, then process remaining. + */ + address existingFacet = s.facetNodes[newCurrentFacetNodeId].facet; + if (existingFacet == address(0)) { + unchecked { + facetList.selectorCount++; + } + } else if (existingFacet != oldFacet) { + revert CannotReplaceFunctionFromNonReplacementFacet(newCurrentFacetNodeId); + } + s.facetNodes[newCurrentFacetNodeId] = + FacetNode(newFacet, oldFacetNode.prevFacetNodeId, oldFacetNode.nextFacetNodeId); + /** + * Update linked list. + */ + if (oldCurrentFacetNodeId == facetList.headFacetNodeId) { + facetList.headFacetNodeId = newCurrentFacetNodeId; + } else { + s.facetNodes[oldFacetNode.prevFacetNodeId].nextFacetNodeId = newCurrentFacetNodeId; + } + if (oldCurrentFacetNodeId == facetList.tailFacetNodeId) { + facetList.tailFacetNodeId = newCurrentFacetNodeId; + } else { + s.facetNodes[oldFacetNode.nextFacetNodeId].prevFacetNodeId = newCurrentFacetNodeId; + } + } else { + /** + * Same first selector, just replace in place. + */ + s.facetNodes[newCurrentFacetNodeId] = + FacetNode(newFacet, oldFacetNode.prevFacetNodeId, oldFacetNode.nextFacetNodeId); + /* + * If the selectors are same from both facets, then we can safely and very efficiently + * replace the old facet address with the new facet address for all the selctors. + */ + if (keccak256(oldSelectors) == keccak256(newSelectors)) { + /** + * Replace remaining selectors. + */ + for (uint256 selectorIndex = 1; selectorIndex < selectorsLength; selectorIndex++) { + bytes4 selector = at(newSelectors, selectorIndex); + s.facetNodes[selector] = FacetNode(newFacet, bytes4(0), bytes4(0)); + } + emit FacetReplaced(oldFacet, newFacet); + /* + * Restore Free Memory Pointer to reuse memory. + */ + assembly ("memory-safe") { + mstore(0x40, freeMemPtr) + } + continue; + } + } + + /** + * Add or replace new selectors. + */ + for (uint256 selectorIndex = 1; selectorIndex < selectorsLength; selectorIndex++) { + bytes4 selector = at(newSelectors, selectorIndex); + address existingFacet = s.facetNodes[selector].facet; + if (existingFacet == address(0)) { + unchecked { + facetList.selectorCount++; + } + } else if (existingFacet != oldFacet) { + revert CannotReplaceFunctionFromNonReplacementFacet(selector); + } + s.facetNodes[selector] = FacetNode(newFacet, bytes4(0), bytes4(0)); + } + /** + * Remove old selectors that were not replaced. + * + * Shift right by 2 is the same as dividing by 4, but cheaper. + * We do this to get the number of selectors. + */ + selectorsLength = oldSelectors.length >> 2; + for (uint256 selectorIndex; selectorIndex < selectorsLength; selectorIndex++) { + bytes4 selector = at(oldSelectors, selectorIndex); + address existingFacet = s.facetNodes[selector].facet; + if (existingFacet == oldFacet) { + delete s.facetNodes[selector]; + unchecked { + facetList.selectorCount--; + } + } + } + emit FacetReplaced(oldFacet, newFacet); + /* + * Restore Free Memory Pointer to reuse memory. + */ + assembly ("memory-safe") { + mstore(0x40, freeMemPtr) + } + } + s.facetList = facetList; + } + + function removeFacets(address[] calldata _facets) internal { + DiamondStorage storage s = getDiamondStorage(); + FacetList memory facetList = s.facetList; + /* + * Snapshot free memory pointer. We restore this at the end of every loop + * to prevent memory expansion costs from repeated `packedSelectors` calls. + */ + uint256 freeMemPtr; + assembly ("memory-safe") { + freeMemPtr := mload(0x40) + } + for (uint256 i = 0; i < _facets.length; i++) { + address facet = _facets[i]; + bytes memory selectors = importSelectors(facet); + bytes4 currentFacetNodeId = at(selectors, 0); + FacetNode memory facetNode = s.facetNodes[currentFacetNodeId]; + /* + * This verifies that the facet we are removing exists in the + * diamond, so we can trust the rest of the selectors from `facet`. + */ + if (facetNode.facet != facet) { + revert CannotRemoveFacetThatDoesNotExist(facet); + } + /** + * Remove the facet from the linked list. + */ + if (currentFacetNodeId == facetList.headFacetNodeId) { + facetList.headFacetNodeId = facetNode.nextFacetNodeId; + } else { + s.facetNodes[facetNode.prevFacetNodeId].nextFacetNodeId = facetNode.nextFacetNodeId; + } + if (currentFacetNodeId == facetList.tailFacetNodeId) { + facetList.tailFacetNodeId = facetNode.prevFacetNodeId; + } else { + s.facetNodes[facetNode.nextFacetNodeId].prevFacetNodeId = facetNode.prevFacetNodeId; + } + /* + * Shift right by 2 is the same as dividing by 4, but cheaper. + * We do this to get the number of selectors + */ + uint256 selectorsLength = selectors.length >> 2; + /** + * Remove facet selectors. + * Because this facet is in the diamond and the `exportSelectors()` function is pure and + * immutable from the facet and we trust that, we can safely remove the selectors without + * checking that each selector belongs to the facet being removed. We know it does. + */ + for (uint256 selectorIndex; selectorIndex < selectorsLength; selectorIndex++) { + bytes4 selector = at(selectors, selectorIndex); + delete s.facetNodes[selector]; + } + unchecked { + facetList.selectorCount -= uint32(selectorsLength); + } + emit FacetRemoved(facet); + /* + * Restore Free Memory Pointer to reuse memory. + */ + assembly ("memory-safe") { + mstore(0x40, freeMemPtr) + } + } + facetList.facetCount -= uint32(_facets.length); + + s.facetList = facetList; + } + + /** + * @notice Upgrade the diamond by adding, replacing, or removing facets. + * + * @dev + * Facets are added first, then replaced, then removed. + * + * These events are emitted to record changes to facets: + * - `FacetAdded(address indexed _facet)` + * - `FacetReplaced(address indexed _oldFacet, address indexed _newFacet)` + * - `FacetRemoved(address indexed _facet)` + * + * If `_delegate` is non-zero, the diamond performs a `delegatecall` to + * `_delegate` using `_delegateCalldata`. The `DiamondDelegateCall` event is + * emitted. + * + * The `delegatecall` is done to alter a diamond's state or to + * initialize, modify, or remove state after an upgrade. + * + * However, if `_delegate` is zero, no `delegatecall` is made and no + * `DiamondDelegateCall` event is emitted. + * + * If _tag is non-zero or if _metadata.length > 0 then the + * `DiamondMetadata` event is emitted. + * + * @param _addFacets Facets to add. + * @param _replaceFacets (oldFacet, newFacet) pairs, to replace old with new. + * @param _removeFacets Facets to remove. + * @param _delegate Optional contract to delegatecall (zero address to skip). + * @param _delegateCalldata Optional calldata to execute on `_delegate`. + * @param _tag Optional arbitrary metadata, such as release version. + * @param _metadata Optional arbitrary data. + */ + function upgradeDiamond( + address[] calldata _addFacets, + FacetReplacement[] calldata _replaceFacets, + address[] calldata _removeFacets, + address _delegate, + bytes calldata _delegateCalldata, + bytes32 _tag, + bytes calldata _metadata + ) external { + if (getOwnerStorage().owner != msg.sender) { + revert OwnerUnauthorizedAccount(); + } + addFacets(_addFacets); + replaceFacets(_replaceFacets); + removeFacets(_removeFacets); + if (_delegate != address(0)) { + if (_delegate.code.length == 0) { + revert NoBytecodeAtAddress(_delegate); + } + (bool success, bytes memory error) = _delegate.delegatecall(_delegateCalldata); + if (!success) { + if (error.length > 0) { + /* + * bubble up error + */ + assembly ("memory-safe") { + revert(add(error, 0x20), mload(error)) + } + } else { + revert DelegateCallReverted(_delegate, _delegateCalldata); + } + } + emit DiamondDelegateCall(_delegate, _delegateCalldata); + } + if (_tag != 0 || _metadata.length > 0) { + emit DiamondMetadata(_tag, _metadata); + } + } + + /** + * @notice Exports the function selectors of the DiamondUpgradeFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the DiamondUpgradeFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.upgradeDiamond.selector); + } +} diff --git a/cli/project-template/src/templates/diamond/DiamondUpgradeMod.sol b/cli/project-template/src/templates/diamond/DiamondUpgradeMod.sol new file mode 100644 index 00000000..a1892d93 --- /dev/null +++ b/cli/project-template/src/templates/diamond/DiamondUpgradeMod.sol @@ -0,0 +1,685 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title Reference implementation for upgrade function for + * ERC-8153 Facet-Based Diamonds + * + * @dev + * Facets are stored as a doubly linked list and as a mapping of selectors to facet addresses. + * + * Facets are stored as a mapping of selectors to facet addresses for efficient delegatecall + * routing to facets. + * + * Facets are stored as a doubly linked list for efficient iteration over all facets, + * and for efficiently adding, replacing, and removing them. + * + * The `FacetList` struct contains information about the linked list of facets. + * + * Only the first FacetNode of each facet contains linked list pointers. + * * prevFacetNodeId - Is the selector of the first FacetNode of the previous + * facet. + * * nextFacetNodeId - Is the selector of the first FacetNode of the next + * facet. + * + * Here is a example that shows the structure: + * + * FacetList + * facetCount = 3 + * headFacetNodeId = selector1 // facetA + * tailFacetNodeId = selector7 // facetC + * + * facetNodes mapping (selector => FacetNode) + * + * selector facet prevFacetNodeId nextFacetNodeId + * ---------------------------------------------------------------- + * selector1 facetA 0x00000000 selector4 ← facetA LIST NODE + * selector2 facetA 0x00000000 0x00000000 + * selector3 facetA 0x00000000 0x00000000 + * + * selector4 facetB selector1 selector7 ← facetB LIST NODE + * selector5 facetB 0x00000000 0x00000000 + * selector6 facetB 0x00000000 0x00000000 + * + * selector7 facetC selector4 0x00000000 ← facetC LIST NODE + * selector8 facetC 0x00000000 0x00000000 + * selector9 facetC 0x00000000 0x00000000 + * + * Linked list order of facets: + * + * facetA (selector1) + * ↓ + * facetB (selector4) + * ↓ + * facetC (selector7) + * + * Notes: + * - Only the first selector of each facet participates in the linked list. + * - The linked list connects facets, not individual selectors. + * - Any values in "prevFacetNodeId" in non-first FacetNodes are not used. + * + * Checked/unchecked math note: + * We use unchecked math with `facetList.selectorCount` because that variable does not affect the adding, + * replacing, and removing of selectors and facets. It is used by some introspection functions. Checked math + * is used with facetList.facetCount because that affects adding, replacing, and removing selectors and facets. + * Of course these variables should never overflow/underflow anyway. + * + * Security: + * This implementation relies on the assumption that the owner of the diamond that has added or replaced any + * facet in the diamond has verified that each facet is not malicious, that each facet is immutable (not upgradeable), + * and that the `exportSelectors()` function in each facet is pure (is marked as a pure function and does not access state.) + */ + +interface IFacet { + function exportSelectors() external pure returns (bytes memory); +} + +bytes32 constant DIAMOND_STORAGE_POSITION = keccak256("erc8153.diamond"); + +struct FacetNode { + address facet; + bytes4 prevFacetNodeId; + bytes4 nextFacetNodeId; +} + +struct FacetList { + bytes4 headFacetNodeId; + bytes4 tailFacetNodeId; + uint32 facetCount; + uint32 selectorCount; +} + +/** + * @custom:storage-location erc8042:erc8153.diamond + */ +struct DiamondStorage { + mapping(bytes4 functionSelector => FacetNode) facetNodes; + FacetList facetList; +} + +function getDiamondStorage() pure returns (DiamondStorage storage s) { + bytes32 position = DIAMOND_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Emitted when a facet is added to a diamond. + * @dev The function selectors this facet handles can be retrieved by calling + * `IFacet(_facet).exportSelectors()` + * + * @param _facet The address of the facet that handles function calls to the diamond. + */ +event FacetAdded(address indexed _facet); + +/** + * @notice Emitted when an existing facet is replaced with a new facet. + * @dev + * - Selectors that are present in the new facet but not in the old facet are added to the diamond. + * - Selectors that are present in both the new and old facet are updated to use the new facet. + * - Selectors that are not present in the new facet but are present in the old facet are removed from + * the diamond. + * + * The function selectors handled by these facets can be retrieved by calling: + * - `IFacet(_oldFacet).exportSelectors()` + * - `IFacet(_newFacet).exportSelectors()` + * + * @param _oldFacet The address of the facet that previously handled function calls to the diamond. + * @param _newFacet The address of the facet that now handles function calls to the diamond. + */ +event FacetReplaced(address indexed _oldFacet, address indexed _newFacet); + +/** + * @notice Emitted when a facet is removed from a diamond. + * @dev The function selectors this facet handles can be retrieved by calling + * `IFacet(_facet).exportSelectors()` + * + * @param _facet The address of the facet that previously handled function calls to the diamond. + */ +event FacetRemoved(address indexed _facet); + +/** + * @notice Emitted when a diamond's constructor function or function from a + * facet makes a `delegatecall`. + * + * @param _delegate The contract that was delegatecalled. + * @param _delegateCalldata The function call, including function selector and + * any arguments. + */ +event DiamondDelegateCall(address indexed _delegate, bytes _delegateCalldata); + +/** + * @notice Emitted to record information about a diamond. + * @dev This event records any arbitrary metadata. + * The format of `_tag` and `_data` are not specified by the + * standard. + * + * @param _tag Arbitrary metadata, such as a release version. + * @param _data Arbitrary metadata. + */ +event DiamondMetadata(bytes32 indexed _tag, bytes _data); + +/** + * @notice The upgradeDiamond function below detects and reverts + * with the following errors. + */ +error NoSelectorsForFacet(address _facet); +error NoBytecodeAtAddress(address _contractAddress); +error CannotAddFunctionToDiamondThatAlreadyExists(bytes4 _selector); +error CannotRemoveFacetThatDoesNotExist(address _facet); +error CannotReplaceFacetWithSameFacet(address _facet); +error FacetToReplaceDoesNotExist(address _oldFacet); +error DelegateCallReverted(address _delegate, bytes _delegateCalldata); +error ExportSelectorsCallFailed(address _facet); +error IncorrectSelectorsEncoding(address _facet); + +/** + * @dev This error means that a function to replace exists in a + * facet other than the facet that was given to be replaced. + */ +error CannotReplaceFunctionFromNonReplacementFacet(bytes4 _selector); + +function importSelectors(address _facet) view returns (bytes memory selectors) { + (bool success, bytes memory data) = _facet.staticcall(abi.encodeWithSelector(IFacet.exportSelectors.selector)); + if (success == false) { + revert ExportSelectorsCallFailed(_facet); + } + /* + * Ensure the data is large enough. + * Offset (32 bytes) + array length (32 bytes) + */ + if (data.length < 64) { + if (_facet.code.length == 0) { + revert NoBytecodeAtAddress(_facet); + } else { + revert IncorrectSelectorsEncoding(_facet); + } + } + + /* Validate ABI offset == 0x20 for a single dynamic return */ + uint256 offset; + assembly ("memory-safe") { + offset := mload(add(data, 0x20)) + } + if (offset != 0x20) { + revert IncorrectSelectorsEncoding(_facet); + } + /* + * ZERO-COPY DECODE + * Instead of abi.decode(wrapper, (bytes)), which copies memory, + * we use assembly to point 'selectors' to the bytes array inside 'data'. + * The length of `data` is stored at 0 and an ABI offset is located at 0x20 (32). + * We skip over those to point `selectors` to the length of the + * bytes array. + */ + assembly ("memory-safe") { + selectors := add(data, 0x40) + } + uint256 selectorsLength = selectors.length; + unchecked { + if (selectorsLength > data.length - 64) { + revert IncorrectSelectorsEncoding(_facet); + } + } + if (selectorsLength < 4) { + revert NoSelectorsForFacet(_facet); + } + /* + * Function selectors are strictly 4 bytes. We ensure the length is a multiple of 4. + */ + if (selectorsLength % 4 != 0) { + revert IncorrectSelectorsEncoding(_facet); + } + return selectors; +} + +function at(bytes memory selectors, uint256 index) pure returns (bytes4 selector) { + assembly ("memory-safe") { + /** + * 1. Calculate Pointer + * add(selectors, 32) - skips the length field of the bytes array + * shl(2, index) is the same as index * 4 but cheaper + * This line executes: ptr = selectorsLength + (4 * index) + */ + let ptr := add(add(selectors, 32), shl(2, index)) + /** + * 2. Load & Return + * We load 32 bytes, but Solidity truncates to 4 bytes automatically + * upon return of this function, so masking is unnecessary. + */ + selector := mload(ptr) + } +} + +function addFacets(address[] calldata _facets) { + DiamondStorage storage s = getDiamondStorage(); + uint256 facetLength = _facets.length; + if (facetLength == 0) { + return; + } + FacetList memory facetList = s.facetList; + /* + * Snapshot free memory pointer. We restore this at the end of every loop + * to prevent memory expansion costs from repeated `packedSelectors` calls. + */ + uint256 freeMemPtr; + assembly ("memory-safe") { + freeMemPtr := mload(0x40) + } + /* Algorithm Description: + * The first facet is handled separately to initialize the linked list pointers in the FacetNodes. + * This allows us to avoid additional conditional checks for linked list management in the main facet loop. + * + * For the first facet, we link the first selector to the previous facet or if this is the first facet in + * the diamond then we assign the first selector to facetList.firstFacetNodeId. + * + * All the selectors (except the first one) in the first facet are then added to the diamond. + * + * In the first iteration of the main facet loop the the selectors for the next facet are retrieved. + * This makes available the nextFacetNodeId value that is needed to store the first selector of the + * first facet. So then the first selector is stored. + * + * Then the selectors which were already retrieved for the next facet are stored, except the first selector. + * Then in the next iteration the selectors of the next facet are retrieved. This makes available the nextFacetNodeId + * value that is needed to store the first selector of the previous facet. The first selector is then stored. The loop + * continues. + * + * After the main facet loop ends, the first selector from the last facet is added to the diamond. + */ + + bytes4 prevFacetNodeId = facetList.tailFacetNodeId; + address facet = _facets[0]; + bytes memory selectors = importSelectors(facet); + /* + * currentFacetNodeId is the head node of the current facet. + * We cannot write it to storage yet because we don't know the `next` pointer. + */ + bytes4 currentFacetNodeId = at(selectors, 0); + if (facetList.facetCount == 0) { + facetList.headFacetNodeId = currentFacetNodeId; + } else { + /* + * Link the previous tail of the diamond to this new batch + */ + s.facetNodes[prevFacetNodeId].nextFacetNodeId = currentFacetNodeId; + } + /* + * Shift right by 2 is the same as dividing by 4, but cheaper. + * We do this to get the number of selectors + */ + uint256 selectorsLength = selectors.length >> 2; + unchecked { + facetList.selectorCount += uint32(selectorsLength); + } + /* + * Add all selectors, except the first, to the diamond. + */ + for (uint256 selectorIndex = 1; selectorIndex < selectorsLength; selectorIndex++) { + bytes4 selector = at(selectors, selectorIndex); + if (s.facetNodes[selector].facet != address(0)) { + revert CannotAddFunctionToDiamondThatAlreadyExists(selector); + } + s.facetNodes[selector] = FacetNode(facet, bytes4(0), bytes4(0)); + } + /* + * Reset memory for the main loop. + */ + assembly ("memory-safe") { + mstore(0x40, freeMemPtr) + } + /* + * Main facet loop. + * 1. Gets the next facet's selectors. + * 2. Now that the nextFacetNodeId value for the previous facet is available, adds the previous + * facet's first selector to the diamond. + * 3. Emits FacetAdded event for the previous facet. + * 3. Updates facet values: facet = nextFacet, etc. + * 4. Adds all the selectors (except the first) to the diamond. + * 5. Repeat loop. + */ + for (uint256 i = 1; i < facetLength; i++) { + address nextFacet = _facets[i]; + selectors = importSelectors(nextFacet); + /* + * Check to see if the PENDING first selector (from previous iteration) already exists in the diamond. + */ + if (s.facetNodes[currentFacetNodeId].facet != address(0)) { + revert CannotAddFunctionToDiamondThatAlreadyExists(currentFacetNodeId); + } + /* + * Identify the link to the next facet + */ + bytes4 nextFacetNodeId = at(selectors, 0); + /* + * Store the previous facet's first selector. + */ + s.facetNodes[currentFacetNodeId] = FacetNode(facet, prevFacetNodeId, nextFacetNodeId); + emit FacetAdded(facet); + /* + * Move pointers forward. + * These assignments switch us from processing the previous facet's first selector to + * processing the next facet's selectors. + * `currentFacetNodeId` becomes the new pending first selector. + */ + facet = nextFacet; + prevFacetNodeId = currentFacetNodeId; + currentFacetNodeId = nextFacetNodeId; + /* + * Shift right by 2 is the same as dividing by 4, but cheaper. + * We do this to get the number of selectors. + */ + selectorsLength = selectors.length >> 2; + /* + * Add all the selectors of the facet to the diamond, except the first selector. + */ + for (uint256 selectorIndex = 1; selectorIndex < selectorsLength; selectorIndex++) { + bytes4 selector = at(selectors, selectorIndex); + if (s.facetNodes[selector].facet != address(0)) { + revert CannotAddFunctionToDiamondThatAlreadyExists(selector); + } + s.facetNodes[selector] = FacetNode(facet, bytes4(0), bytes4(0)); + } + unchecked { + facetList.selectorCount += uint32(selectorsLength); + } + /* + * Restore Free Memory Pointer to reuse memory from packedSelectors() calls. + */ + assembly ("memory-safe") { + mstore(0x40, freeMemPtr) + } + } + /* + * Validates and adds the first selector of the last facet to the diamond. + */ + if (s.facetNodes[currentFacetNodeId].facet != address(0)) { + revert CannotAddFunctionToDiamondThatAlreadyExists(currentFacetNodeId); + } + s.facetNodes[currentFacetNodeId] = FacetNode(facet, prevFacetNodeId, bytes4(0)); + emit FacetAdded(facet); + facetList.facetCount += uint32(facetLength); + + facetList.tailFacetNodeId = currentFacetNodeId; + s.facetList = facetList; +} + +/** + * @notice This struct is used to replace old facets with new facets. + */ +struct FacetReplacement { + address oldFacet; + address newFacet; +} + +function replaceFacets(FacetReplacement[] calldata _replaceFacets) { + DiamondStorage storage s = getDiamondStorage(); + FacetList memory facetList = s.facetList; + /* + * Snapshot free memory pointer. We restore this within the loop to prevent + * memory expansion costs from repeated `packedSelectors` calls. + */ + uint256 freeMemPtr; + assembly ("memory-safe") { + freeMemPtr := mload(0x40) + } + for (uint256 i; i < _replaceFacets.length; i++) { + address oldFacet = _replaceFacets[i].oldFacet; + address newFacet = _replaceFacets[i].newFacet; + if (oldFacet == newFacet) { + revert CannotReplaceFacetWithSameFacet(oldFacet); + } + bytes memory oldSelectors = importSelectors(oldFacet); + bytes memory newSelectors = importSelectors(newFacet); + /* + * Shift right by 2 is the same as dividing by 4, but cheaper. + * We do this to get the number of selectors + */ + uint256 selectorsLength = newSelectors.length >> 2; + bytes4 oldCurrentFacetNodeId = at(oldSelectors, 0); + bytes4 newCurrentFacetNodeId = at(newSelectors, 0); + + /** + * Validate old facet exists. + */ + FacetNode memory oldFacetNode = s.facetNodes[oldCurrentFacetNodeId]; + if (oldFacetNode.facet != oldFacet) { + revert FacetToReplaceDoesNotExist(oldFacet); + } + if (oldCurrentFacetNodeId != newCurrentFacetNodeId) { + /** + * Write first selector with linking info, then process remaining. + */ + address existingFacet = s.facetNodes[newCurrentFacetNodeId].facet; + if (existingFacet == address(0)) { + unchecked { + facetList.selectorCount++; + } + } else if (existingFacet != oldFacet) { + revert CannotReplaceFunctionFromNonReplacementFacet(newCurrentFacetNodeId); + } + s.facetNodes[newCurrentFacetNodeId] = + FacetNode(newFacet, oldFacetNode.prevFacetNodeId, oldFacetNode.nextFacetNodeId); + /** + * Update linked list. + */ + if (oldCurrentFacetNodeId == facetList.headFacetNodeId) { + facetList.headFacetNodeId = newCurrentFacetNodeId; + } else { + s.facetNodes[oldFacetNode.prevFacetNodeId].nextFacetNodeId = newCurrentFacetNodeId; + } + if (oldCurrentFacetNodeId == facetList.tailFacetNodeId) { + facetList.tailFacetNodeId = newCurrentFacetNodeId; + } else { + s.facetNodes[oldFacetNode.nextFacetNodeId].prevFacetNodeId = newCurrentFacetNodeId; + } + } else { + /** + * Same first selector, just replace in place. + */ + s.facetNodes[newCurrentFacetNodeId] = + FacetNode(newFacet, oldFacetNode.prevFacetNodeId, oldFacetNode.nextFacetNodeId); + /* + * If the selectors are same from both facets, then we can safely and very efficiently + * replace the old facet address with the new facet address for all the selctors. + */ + if (keccak256(oldSelectors) == keccak256(newSelectors)) { + /** + * Replace remaining selectors. + */ + for (uint256 selectorIndex = 1; selectorIndex < selectorsLength; selectorIndex++) { + bytes4 selector = at(newSelectors, selectorIndex); + s.facetNodes[selector] = FacetNode(newFacet, bytes4(0), bytes4(0)); + } + emit FacetReplaced(oldFacet, newFacet); + /* + * Restore Free Memory Pointer to reuse memory. + */ + assembly ("memory-safe") { + mstore(0x40, freeMemPtr) + } + continue; + } + } + + /** + * Add or replace new selectors. + */ + for (uint256 selectorIndex = 1; selectorIndex < selectorsLength; selectorIndex++) { + bytes4 selector = at(newSelectors, selectorIndex); + address existingFacet = s.facetNodes[selector].facet; + if (existingFacet == address(0)) { + unchecked { + facetList.selectorCount++; + } + } else if (existingFacet != oldFacet) { + revert CannotReplaceFunctionFromNonReplacementFacet(selector); + } + s.facetNodes[selector] = FacetNode(newFacet, bytes4(0), bytes4(0)); + } + /** + * Remove old selectors that were not replaced. + * + * Shift right by 2 is the same as dividing by 4, but cheaper. + * We do this to get the number of selectors. + */ + selectorsLength = oldSelectors.length >> 2; + for (uint256 selectorIndex; selectorIndex < selectorsLength; selectorIndex++) { + bytes4 selector = at(oldSelectors, selectorIndex); + address existingFacet = s.facetNodes[selector].facet; + if (existingFacet == oldFacet) { + delete s.facetNodes[selector]; + unchecked { + facetList.selectorCount--; + } + } + } + emit FacetReplaced(oldFacet, newFacet); + /* + * Restore Free Memory Pointer to reuse memory. + */ + assembly ("memory-safe") { + mstore(0x40, freeMemPtr) + } + } + s.facetList = facetList; +} + +function removeFacets(address[] calldata _facets) { + DiamondStorage storage s = getDiamondStorage(); + FacetList memory facetList = s.facetList; + /* + * Snapshot free memory pointer. We restore this at the end of every loop + * to prevent memory expansion costs from repeated `packedSelectors` calls. + */ + uint256 freeMemPtr; + assembly ("memory-safe") { + freeMemPtr := mload(0x40) + } + for (uint256 i = 0; i < _facets.length; i++) { + address facet = _facets[i]; + bytes memory selectors = importSelectors(facet); + bytes4 currentFacetNodeId = at(selectors, 0); + FacetNode memory facetNode = s.facetNodes[currentFacetNodeId]; + /* + * This verifies that the facet we are removing exists in the + * diamond, so we can trust the rest of the selectors from `facet`. + */ + if (facetNode.facet != facet) { + revert CannotRemoveFacetThatDoesNotExist(facet); + } + /** + * Remove the facet from the linked list. + */ + if (currentFacetNodeId == facetList.headFacetNodeId) { + facetList.headFacetNodeId = facetNode.nextFacetNodeId; + } else { + s.facetNodes[facetNode.prevFacetNodeId].nextFacetNodeId = facetNode.nextFacetNodeId; + } + if (currentFacetNodeId == facetList.tailFacetNodeId) { + facetList.tailFacetNodeId = facetNode.prevFacetNodeId; + } else { + s.facetNodes[facetNode.nextFacetNodeId].prevFacetNodeId = facetNode.prevFacetNodeId; + } + /* + * Shift right by 2 is the same as dividing by 4, but cheaper. + * We do this to get the number of selectors + */ + uint256 selectorsLength = selectors.length >> 2; + /** + * Remove facet selectors. + * Because this facet is in the diamond and the `exportSelectors()` function is pure and + * immutable from the facet and we trust that, we can safely remove the selectors without + * checking that each selector belongs to the facet being removed. We know it does. + */ + for (uint256 selectorIndex; selectorIndex < selectorsLength; selectorIndex++) { + bytes4 selector = at(selectors, selectorIndex); + delete s.facetNodes[selector]; + } + unchecked { + facetList.selectorCount -= uint32(selectorsLength); + } + emit FacetRemoved(facet); + /* + * Restore Free Memory Pointer to reuse memory. + */ + assembly ("memory-safe") { + mstore(0x40, freeMemPtr) + } + } + facetList.facetCount -= uint32(_facets.length); + + s.facetList = facetList; +} + +/** + * @notice Upgrade the diamond by adding, replacing, or removing facets. + * + * @dev + * Facets are added first, then replaced, then removed. + * + * These events are emitted to record changes to facets: + * - `FacetAdded(address indexed _facet)` + * - `FacetReplaced(address indexed _oldFacet, address indexed _newFacet)` + * - `FacetRemoved(address indexed _facet)` + * + * If `_delegate` is non-zero, the diamond performs a `delegatecall` to + * `_delegate` using `_delegateCalldata`. The `DiamondDelegateCall` event is + * emitted. + * + * The `delegatecall` is done to alter a diamond's state or to + * initialize, modify, or remove state after an upgrade. + * + * However, if `_delegate` is zero, no `delegatecall` is made and no + * `DiamondDelegateCall` event is emitted. + * + * If _tag is non-zero or if _metadata.length > 0 then the + * `DiamondMetadata` event is emitted. + * + * @param _addFacets Facets to add. + * @param _replaceFacets (oldFacet, newFacet) pairs, to replace old with new. + * @param _removeFacets Facets to remove. + * @param _delegate Optional contract to delegatecall (zero address to skip). + * @param _delegateCalldata Optional calldata to execute on `_delegate`. + * @param _tag Optional arbitrary metadata, such as release version. + * @param _metadata Optional arbitrary data. + */ +function upgradeDiamond( + address[] calldata _addFacets, + FacetReplacement[] calldata _replaceFacets, + address[] calldata _removeFacets, + address _delegate, + bytes calldata _delegateCalldata, + bytes32 _tag, + bytes calldata _metadata +) { + addFacets(_addFacets); + replaceFacets(_replaceFacets); + removeFacets(_removeFacets); + if (_delegate != address(0)) { + if (_delegate.code.length == 0) { + revert NoBytecodeAtAddress(_delegate); + } + (bool success, bytes memory error) = _delegate.delegatecall(_delegateCalldata); + if (!success) { + if (error.length > 0) { + /* + * bubble up error + */ + assembly ("memory-safe") { + revert(add(error, 0x20), mload(error)) + } + } else { + revert DelegateCallReverted(_delegate, _delegateCalldata); + } + } + emit DiamondDelegateCall(_delegate, _delegateCalldata); + } + if (_tag != 0 || _metadata.length > 0) { + emit DiamondMetadata(_tag, _metadata); + } +} diff --git a/cli/project-template/src/templates/diamond/example/ExampleDiamond.sol b/cli/project-template/src/templates/diamond/example/ExampleDiamond.sol new file mode 100644 index 00000000..44a08a72 --- /dev/null +++ b/cli/project-template/src/templates/diamond/example/ExampleDiamond.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +import "../DiamondMod.sol" as DiamondMod; +import "../../access/Owner/Data/OwnerDataMod.sol" as OwnerDataMod; +import "../../token/ERC721/Metadata/ERC721MetadataMod.sol" as ERC721MetadataMod; +import "../../interfaceDetection/ERC165/ERC165Mod.sol" as ERC165Mod; +import {IERC721} from "../../interfaces/IERC721.sol"; +import {IERC721Metadata} from "../../interfaces/IERC721Metadata.sol"; + +contract ExampleDiamond { + /** + * @notice Initializes the diamond contract with facets, owner and other data. + * @dev Adds all provided facets to the diamond's function selector mapping and sets the contract owner. + * Each facet in the array will have its function selectors registered to enable delegatecall routing. + * @param _facets Array of facet addresses and their corresponding function selectors to add to the diamond. + * @param _diamondOwner Address that will be set as the owner of the diamond contract. + */ + constructor(address[] memory _facets, address _diamondOwner) { + DiamondMod.addFacets(_facets); + + /************************************* + * Initialize storage variables + ************************************/ + + /** + * Setting the contract owner + */ + OwnerDataMod.setContractOwner(_diamondOwner); + /** + * Setting ERC721 token details + */ + ERC721MetadataMod.setMetadata({ + _name: "ExampleDiamondNFT", _symbol: "EDN", _baseURI: "https://example.com/metadata/" + }); + /** + * Registering ERC165 interfaces + */ + ERC165Mod.registerInterface(type(IERC721).interfaceId); + ERC165Mod.registerInterface(type(IERC721Metadata).interfaceId); + } + + fallback() external payable { + DiamondMod.diamondFallback(); + } + + receive() external payable {} +} diff --git a/cli/project-template/src/templates/examples/ERC20IdentifierCollisionError.sol b/cli/project-template/src/templates/examples/ERC20IdentifierCollisionError.sol new file mode 100644 index 00000000..a15d67ef --- /dev/null +++ b/cli/project-template/src/templates/examples/ERC20IdentifierCollisionError.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract ERC20IdentifierCollisionError { + /** + * @dev Reuses the ERC20 diamond storage identifier with an incompatible layout. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc20"); + + /** + * @dev Intentionally collides with the required ERC20 storage layout. + * @custom:storage-location erc8042:erc20 + */ + struct ERC20Storage { + bool paused; + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; + mapping(address owner => mapping(address spender => uint256 allowance)) allowance; + } + + /** + * @notice Returns the intentionally incompatible ERC20 storage struct. + * @return s The ERC20 storage struct reference. + */ + function getStorage() internal pure returns (ERC20Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Unique probe function used to keep selector validation clean. + * @return True when the incompatible layout's paused flag is set. + */ + function identifierCollisionProbe() external view returns (bool) { + return getStorage().paused; + } + + /** + * @notice Exports a non-colliding selector so identifier validation can surface. + * @return selectors The exported function selectors. + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.identifierCollisionProbe.selector); + } +} diff --git a/cli/project-template/src/templates/examples/ERC20MissingExportSelector.sol b/cli/project-template/src/templates/examples/ERC20MissingExportSelector.sol new file mode 100644 index 00000000..bb7cf7f5 --- /dev/null +++ b/cli/project-template/src/templates/examples/ERC20MissingExportSelector.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract ERC20MissingExportSelector { + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc20.metadata"); + + /** + * @dev ERC-8042 compliant storage struct for ERC20 token data. + * @custom:storage-location erc8042:erc20.metadata + */ + struct ERC20MetadataStorage { + string name; + string symbol; + uint8 decimals; + } + + /** + * @notice Returns the ERC20 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC20 storage struct reference. + */ + function getStorage() internal pure returns (ERC20MetadataStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns the name of the token. + * @return The token name. + */ + function name() external view returns (string memory) { + return getStorage().name; + } + + /** + * @notice Returns the symbol of the token. + * @return The token symbol. + */ + function symbol() external view returns (string memory) { + return getStorage().symbol; + } + + /** + * @notice Returns the number of decimals used for token precision. + * @return The number of decimals. + */ + function decimals() external view returns (uint8) { + return getStorage().decimals; + } + + /** + * @notice Exports an intentionally incomplete selector list for validation testing. + * @dev Missing this.decimals.selector on purpose. + * @return selectors The exported function selectors. + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.name.selector, this.symbol.selector); + } +} diff --git a/cli/project-template/src/templates/examples/ERC20SelectorCollisionError.sol b/cli/project-template/src/templates/examples/ERC20SelectorCollisionError.sol new file mode 100644 index 00000000..ca401f2e --- /dev/null +++ b/cli/project-template/src/templates/examples/ERC20SelectorCollisionError.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract ERC20SelectorCollisionError { + /** + * @notice Thrown when an account has insufficient balance for a transfer or burn. + * @param _sender Address attempting the transfer. + * @param _balance Current balance of the sender. + * @param _needed Amount required to complete the operation. + */ + error ERC20InsufficientBalance(address _sender, uint256 _balance, uint256 _needed); + + /** + * @notice Thrown when the sender address is invalid (e.g., zero address). + * @param _sender Invalid sender address. + */ + error ERC20InvalidSender(address _sender); + + /** + * @notice Thrown when the receiver address is invalid (e.g., zero address). + * @param _receiver Invalid receiver address. + */ + error ERC20InvalidReceiver(address _receiver); + + /** + * @notice Thrown when a spender tries to use more than the approved allowance. + * @param _spender Address attempting to spend. + * @param _allowance Current allowance for the spender. + * @param _needed Amount required to complete the operation. + */ + error ERC20InsufficientAllowance(address _spender, uint256 _allowance, uint256 _needed); + + /** + * @notice Thrown when the spender address is invalid (e.g., zero address). + * @param _spender Invalid spender address. + */ + error ERC20InvalidSpender(address _spender); + + /** + * @notice Emitted when tokens are transferred between two addresses. + * @param _from Address sending the tokens. + * @param _to Address receiving the tokens. + * @param _value Amount of tokens transferred. + */ + event Transfer(address indexed _from, address indexed _to, uint256 _value); + + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc20"); + + /** + * @dev ERC-8042 compliant storage struct for ERC20 token data. + * @custom:storage-location erc8042:erc20 + */ + struct ERC20Storage { + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; + mapping(address owner => mapping(address spender => uint256 allowance)) allowance; + } + + /** + * @notice Returns the ERC20 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC20 storage struct reference. + */ + function getStorage() internal pure returns (ERC20Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Transfers tokens to another address. + * @dev Emits a {Transfer} event. + * @param _to The address to receive the tokens. + * @param _value The amount of tokens to transfer. + * @return True if the transfer was successful. + */ + function transfer(address _to, uint256 _value) external returns (bool) { + ERC20Storage storage s = getStorage(); + if (_to == address(0)) { + revert ERC20InvalidReceiver(address(0)); + } + uint256 fromBalance = s.balanceOf[msg.sender]; + if (fromBalance < _value) { + revert ERC20InsufficientBalance(msg.sender, fromBalance, _value); + } + unchecked { + s.balanceOf[msg.sender] = fromBalance - _value; + } + s.balanceOf[_to] += _value; + emit Transfer(msg.sender, _to, _value); + return true; + } + + /** + * @notice Transfers tokens on behalf of another account, provided sufficient allowance exists. + * @dev Emits a {Transfer} event and decreases the spender's allowance. + * @param _from The address to transfer tokens from. + * @param _to The address to transfer tokens to. + * @param _value The amount of tokens to transfer. + * @return True if the transfer was successful. + */ + function transferFrom(address _from, address _to, uint256 _value) external returns (bool) { + ERC20Storage storage s = getStorage(); + if (_from == address(0)) { + revert ERC20InvalidSender(address(0)); + } + if (_to == address(0)) { + revert ERC20InvalidReceiver(address(0)); + } + uint256 currentAllowance = s.allowance[_from][msg.sender]; + if (currentAllowance < _value) { + revert ERC20InsufficientAllowance(msg.sender, currentAllowance, _value); + } + uint256 fromBalance = s.balanceOf[_from]; + if (fromBalance < _value) { + revert ERC20InsufficientBalance(_from, fromBalance, _value); + } + unchecked { + if (currentAllowance != type(uint256).max) { + s.allowance[_from][msg.sender] = currentAllowance - _value; + } + s.balanceOf[_from] = fromBalance - _value; + } + s.balanceOf[_to] += _value; + emit Transfer(_from, _to, _value); + return true; + } + + /** + * @notice Exports selectors that intentionally collide with ERC20TransferFacet. + * @dev This local example is used to test selector collision validation. + * @return selectors The exported function selectors. + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.transfer.selector, this.transferFrom.selector); + } +} diff --git a/cli/project-template/src/templates/interfaceDetection/ERC165/ERC165Facet.sol b/cli/project-template/src/templates/interfaceDetection/ERC165/ERC165Facet.sol new file mode 100644 index 00000000..454e17e9 --- /dev/null +++ b/cli/project-template/src/templates/interfaceDetection/ERC165/ERC165Facet.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-165 Standard Interface Detection Interface + * @notice Interface for detecting what interfaces a contract implements + * @dev ERC-165 allows contracts to publish their supported interfaces + */ +interface IERC165 { + /** + * @notice Query if a contract implements an interface + * @param _interfaceId The interface identifier, as specified in ERC-165 + * @dev Interface identification is specified in ERC-165. This function + * uses less than 30,000 gas. + * @return `true` if the contract implements `_interfaceId` and + * `_interfaceId` is not 0xffffffff, `false` otherwise + */ + function supportsInterface(bytes4 _interfaceId) external view returns (bool); +} + +/** + * @title ERC165Facet — ERC-165 Standard Interface Detection Facet + * @notice Facet implementation of ERC-165 for diamond proxy pattern + * @dev Allows querying which interfaces are implemented by the diamond + * Each facet is a standalone source code file following SCOP principles. + */ +contract ERC165Facet { + /** + * @notice Storage slot identifier for ERC-165 interface detection + * @dev Defined using keccak256 hash following ERC-8042 standard + */ + bytes32 constant STORAGE_POSITION = keccak256("erc165"); + + /** + * @notice ERC-165 storage layout using the ERC-8042 standard + * @custom:storage-location erc8042:erc165 + */ + struct ERC165Storage { + /** + * @notice Mapping of interface IDs to whether they are supported + */ + mapping(bytes4 => bool) supportedInterfaces; + } + + /** + * @notice Returns a pointer to the ERC-165 storage struct + * @dev Uses inline assembly to bind the storage struct to the fixed storage position + * @return s The ERC-165 storage struct + */ + function getStorage() internal pure returns (ERC165Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Query if a contract implements an interface + * @param _interfaceId The interface identifier, as specified in ERC-165 + * @dev This function checks if the diamond supports the given interface ID + * @return `true` if the contract implements `_interfaceId` and + * `_interfaceId` is not 0xffffffff, `false` otherwise + */ + function supportsInterface(bytes4 _interfaceId) external view returns (bool) { + ERC165Storage storage s = getStorage(); + + /** + * If the ERC165 interface itself is being queried, return true + * since this facet implements ERC165 + */ + if (_interfaceId == type(IERC165).interfaceId) { + return true; + } + + return s.supportedInterfaces[_interfaceId]; + } + + /** + * @notice Exports the function selectors of the ERC165Facet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC165Facet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.supportsInterface.selector); + } +} diff --git a/cli/project-template/src/templates/interfaceDetection/ERC165/ERC165Mod.sol b/cli/project-template/src/templates/interfaceDetection/ERC165/ERC165Mod.sol new file mode 100644 index 00000000..37f86375 --- /dev/null +++ b/cli/project-template/src/templates/interfaceDetection/ERC165/ERC165Mod.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/* + * @title LibERC165 — ERC-165 Standard Interface Detection Library + * @notice Provides internal functions and storage layout for ERC-165 interface detection. + * @dev Uses ERC-8042 for storage location standardization + */ + +/* + * Storage slot identifier, defined using keccak256 hash of the library diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc165"); + +/* + * @notice ERC-165 storage layout using the ERC-8042 standard. + * @custom:storage-location erc8042:erc165 + */ +struct ERC165Storage { + /* + * @notice Mapping of interface IDs to whether they are supported + */ + mapping(bytes4 => bool) supportedInterfaces; +} + +/** + * @notice Returns a pointer to the ERC-165 storage struct. + * @dev Uses inline assembly to bind the storage struct to the fixed storage position. + * @return s The ERC-165 storage struct. + */ +function getStorage() pure returns (ERC165Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Register that a contract supports an interface + * @param _interfaceId The interface ID to register + * @dev Call this function during initialization to register supported interfaces. + * For example, in an ERC721 facet initialization, you would call: + * `LibERC165.registerInterface(type(IERC721).interfaceId)` + */ +function registerInterface(bytes4 _interfaceId) { + ERC165Storage storage s = getStorage(); + s.supportedInterfaces[_interfaceId] = true; +} diff --git a/cli/project-template/src/templates/interfaces/IERC1155.sol b/cli/project-template/src/templates/interfaces/IERC1155.sol new file mode 100644 index 00000000..4f544943 --- /dev/null +++ b/cli/project-template/src/templates/interfaces/IERC1155.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-1155 Multi Token Standard Interface + * @notice Interface for ERC-1155 token contracts with custom errors + * @dev This interface includes all custom errors used by ERC-1155 implementations (ERC-6093) + */ +interface IERC1155 { + /** + * @notice Error indicating insufficient balance for a transfer. + * @param _sender Address attempting the transfer. + * @param _balance Current balance of the sender. + * @param _needed Amount required to complete the operation. + * @param _tokenId The token ID involved. + */ + error ERC1155InsufficientBalance(address _sender, uint256 _balance, uint256 _needed, uint256 _tokenId); + + /** + * @notice Error indicating the sender address is invalid. + * @param _sender Invalid sender address. + */ + error ERC1155InvalidSender(address _sender); + + /** + * @notice Error indicating the receiver address is invalid. + * @param _receiver Invalid receiver address. + */ + error ERC1155InvalidReceiver(address _receiver); + + /** + * @notice Error indicating missing approval for an operator. + * @param _operator Address attempting the operation. + * @param _owner The token owner. + */ + error ERC1155MissingApprovalForAll(address _operator, address _owner); + + /** + * @notice Error indicating the approver address is invalid. + * @param _approver Invalid approver address. + */ + error ERC1155InvalidApprover(address _approver); + + /** + * @notice Error indicating the operator address is invalid. + * @param _operator Invalid operator address. + */ + error ERC1155InvalidOperator(address _operator); + + /** + * @notice Error indicating array length mismatch in batch operations. + * @param _idsLength Length of the ids array. + * @param _valuesLength Length of the values array. + */ + error ERC1155InvalidArrayLength(uint256 _idsLength, uint256 _valuesLength); + /** + * @notice Emitted when `value` amount of tokens of type `id` are transferred from `from` to `to` by `operator`. + * @param _operator The address which initiated the transfer. + * @param _from The address which previously owned the token. + * @param _to The address which now owns the token. + * @param _id The token type being transferred. + * @param _value The amount of tokens transferred. + */ + + event TransferSingle( + address indexed _operator, address indexed _from, address indexed _to, uint256 _id, uint256 _value + ); + + /** + * @notice Equivalent to multiple {TransferSingle} events, where `operator`, `from` and `to` are the same for all transfers. + * @param _operator The address which initiated the batch transfer. + * @param _from The address which previously owned the tokens. + * @param _to The address which now owns the tokens. + * @param _ids The token types being transferred. + * @param _values The amounts of tokens transferred. + */ + event TransferBatch( + address indexed _operator, address indexed _from, address indexed _to, uint256[] _ids, uint256[] _values + ); + + /** + * @notice Emitted when `account` grants or revokes permission to `operator` to transfer their tokens. + * @param _account The token owner granting/revoking approval. + * @param _operator The address being approved/revoked. + * @param _approved True if approval is granted, false if revoked. + */ + event ApprovalForAll(address indexed _account, address indexed _operator, bool _approved); + + /** + * @notice Emitted when the URI for token type `id` changes to `value`. + * @param _value The new URI for the token type. + * @param _id The token type whose URI changed. + */ + event URI(string _value, uint256 indexed _id); + + /** + * @notice Returns the amount of tokens of token type `id` owned by `account`. + * @param _account The address to query the balance of. + * @param _id The token type to query. + * @return The balance of the token type. + */ + function balanceOf(address _account, uint256 _id) external view returns (uint256); + + /** + * @notice Batched version of {balanceOf}. + * @param _accounts The addresses to query the balances of (order and length must match _ids array). + * @param _ids The token types to query (order and length must match _accounts array). + * @return The balances of the token types. + */ + function balanceOfBatch(address[] calldata _accounts, uint256[] calldata _ids) + external + view + returns (uint256[] memory); + + /** + * @notice Grants or revokes permission to `operator` to transfer the caller's tokens. + * @param _operator The address to grant/revoke approval to. + * @param _approved True to approve, false to revoke. + */ + function setApprovalForAll(address _operator, bool _approved) external; + + /** + * @notice Returns true if `operator` is approved to transfer `account`'s tokens. + * @param _account The token owner. + * @param _operator The operator to query. + * @return True if the operator is approved, false otherwise. + */ + function isApprovedForAll(address _account, address _operator) external view returns (bool); + + /** + * @notice Transfers `value` amount of token type `id` from `from` to `to`. + * @param _from The address to transfer from. + * @param _to The address to transfer to. + * @param _id The token type to transfer. + * @param _value The amount to transfer. + * @param _data Additional data with no specified format. + */ + function safeTransferFrom(address _from, address _to, uint256 _id, uint256 _value, bytes calldata _data) external; + + /** + * @notice Batched version of {safeTransferFrom}. + * @param _from The address to transfer from. + * @param _to The address to transfer to. + * @param _ids The token types to transfer (order and length must match _values array). + * @param _values The amounts to transfer (order and length must match _ids array). + * @param _data Additional data with no specified format. + */ + function safeBatchTransferFrom( + address _from, + address _to, + uint256[] calldata _ids, + uint256[] calldata _values, + bytes calldata _data + ) external; + + /** + * @notice Returns the URI for token type `id`. + * @param _id The token type to query. + * @return The URI for the token type. + */ + function uri(uint256 _id) external view returns (string memory); +} diff --git a/cli/project-template/src/templates/interfaces/IERC1155Receiver.sol b/cli/project-template/src/templates/interfaces/IERC1155Receiver.sol new file mode 100644 index 00000000..196668a9 --- /dev/null +++ b/cli/project-template/src/templates/interfaces/IERC1155Receiver.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-1155 Token Receiver Interface + * @notice Interface that must be implemented by smart contracts in order to receive ERC-1155 token transfers. + */ +interface IERC1155Receiver { + /** + * @notice Handles the receipt of a single ERC-1155 token type. + * @dev This function is called at the end of a `safeTransferFrom` after the balance has been updated. + * + * IMPORTANT: To accept the transfer, this must return + * `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))` + * (i.e. 0xf23a6e61, or its own function selector). + * + * @param _operator The address which initiated the transfer (i.e. msg.sender). + * @param _from The address which previously owned the token. + * @param _id The ID of the token being transferred. + * @param _value The amount of tokens being transferred. + * @param _data Additional data with no specified format. + * @return `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))` if transfer is allowed. + */ + function onERC1155Received(address _operator, address _from, uint256 _id, uint256 _value, bytes calldata _data) + external + returns (bytes4); + + /** + * @notice Handles the receipt of multiple ERC-1155 token types. + * @dev This function is called at the end of a `safeBatchTransferFrom` after the balances have been updated. + * + * IMPORTANT: To accept the transfer(s), this must return + * `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))` + * (i.e. 0xbc197c81, or its own function selector). + * + * @param _operator The address which initiated the batch transfer (i.e. msg.sender). + * @param _from The address which previously owned the token. + * @param _ids An array containing ids of each token being transferred (order and length must match _values array). + * @param _values An array containing amounts of each token being transferred (order and length must match _ids array). + * @param _data Additional data with no specified format. + * @return `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))` if transfer is allowed. + */ + function onERC1155BatchReceived( + address _operator, + address _from, + uint256[] calldata _ids, + uint256[] calldata _values, + bytes calldata _data + ) external returns (bytes4); +} diff --git a/cli/project-template/src/templates/interfaces/IERC173.sol b/cli/project-template/src/templates/interfaces/IERC173.sol new file mode 100644 index 00000000..4f5c2a63 --- /dev/null +++ b/cli/project-template/src/templates/interfaces/IERC173.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-173 Contract Ownership Standard Interface + * @notice Interface for contract ownership with custom errors + * @dev This interface includes all custom errors used by ERC-173 implementations + */ +interface IERC173 { + /** + * @notice Thrown when attempting to transfer ownership while not being the owner. + */ + error OwnableUnauthorizedAccount(); + + /** + * @notice Thrown when attempting to transfer ownership of a renounced contract. + */ + error OwnableAlreadyRenounced(); + + /** + * @dev This emits when ownership of a contract changes. + */ + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + /** + * @notice Get the address of the owner + * @return The address of the owner. + */ + function owner() external view returns (address); + + /** + * @notice Set the address of the new owner of the contract + * @dev Set _newOwner to address(0) to renounce any ownership. + * @param _newOwner The address of the new owner of the contract + */ + function transferOwnership(address _newOwner) external; +} diff --git a/cli/project-template/src/templates/interfaces/IERC20.sol b/cli/project-template/src/templates/interfaces/IERC20.sol new file mode 100644 index 00000000..d1936dd9 --- /dev/null +++ b/cli/project-template/src/templates/interfaces/IERC20.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-20 Token Standard Interface + * @notice Interface for ERC-20 token contracts with custom errors + * @dev This interface includes all custom errors used by ERC-20 implementations + */ +interface IERC20 { + /** + * @notice Thrown when an account has insufficient balance for a transfer or burn. + * @param _sender Address attempting the transfer. + * @param _balance Current balance of the sender. + * @param _needed Amount required to complete the operation. + */ + error ERC20InsufficientBalance(address _sender, uint256 _balance, uint256 _needed); + + /** + * @notice Thrown when the sender address is invalid (e.g., zero address). + * @param _sender Invalid sender address. + */ + error ERC20InvalidSender(address _sender); + + /** + * @notice Thrown when the receiver address is invalid (e.g., zero address). + * @param _receiver Invalid receiver address. + */ + error ERC20InvalidReceiver(address _receiver); + + /** + * @notice Thrown when a spender tries to use more than the approved allowance. + * @param _spender Address attempting to spend. + * @param _allowance Current allowance for the spender. + * @param _needed Amount required to complete the operation. + */ + error ERC20InsufficientAllowance(address _spender, uint256 _allowance, uint256 _needed); + + /** + * @notice Thrown when the spender address is invalid (e.g., zero address). + * @param _spender Invalid spender address. + */ + error ERC20InvalidSpender(address _spender); + + /** + * @notice Thrown when a permit signature is invalid or expired. + * @param _owner The address that signed the permit. + * @param _spender The address that was approved. + * @param _value The amount that was approved. + * @param _deadline The deadline for the permit. + * @param _v The recovery byte of the signature. + * @param _r The r value of the signature. + * @param _s The s value of the signature. + */ + error ERC2612InvalidSignature( + address _owner, address _spender, uint256 _value, uint256 _deadline, uint8 _v, bytes32 _r, bytes32 _s + ); + + /** + * @notice Emitted when tokens are transferred between two addresses. + * @param _from Address sending the tokens. + * @param _to Address receiving the tokens. + * @param _value Amount of tokens transferred. + */ + event Transfer(address indexed _from, address indexed _to, uint256 _value); + + /** + * @notice Emitted when an approval is made for a spender by an owner. + * @param _owner The address granting the allowance. + * @param _spender The address receiving the allowance. + * @param _value The amount approved. + */ + event Approval(address indexed _owner, address indexed _spender, uint256 _value); + + /** + * @notice Returns the name of the token. + * @return The token name. + */ + function name() external view returns (string memory); + + /** + * @notice Returns the symbol of the token. + * @return The token symbol. + */ + function symbol() external view returns (string memory); + + /** + * @notice Returns the number of decimals used for token precision. + * @return The number of decimals. + */ + function decimals() external view returns (uint8); + + /** + * @notice Returns the total supply of tokens. + * @return The total token supply. + */ + function totalSupply() external view returns (uint256); + + /** + * @notice Returns the balance of a specific account. + * @param _account The address of the account. + * @return The account balance. + */ + function balanceOf(address _account) external view returns (uint256); + + /** + * @notice Returns the remaining number of tokens that a spender is allowed to spend on behalf of an owner. + * @param _owner The address of the token owner. + * @param _spender The address of the spender. + * @return The remaining allowance. + */ + function allowance(address _owner, address _spender) external view returns (uint256); + + /** + * @notice Approves a spender to transfer up to a certain amount of tokens on behalf of the caller. + * @dev Emits an {Approval} event. + * @param _spender The address approved to spend tokens. + * @param _value The number of tokens to approve. + * @return True if the operation succeeded. + */ + function approve(address _spender, uint256 _value) external returns (bool); + + /** + * @notice Transfers tokens to another address. + * @dev Emits a {Transfer} event. + * @param _to The address to receive the tokens. + * @param _value The amount of tokens to transfer. + * @return True if the operation succeeded. + */ + function transfer(address _to, uint256 _value) external returns (bool); + + /** + * @notice Transfers tokens on behalf of another account, provided sufficient allowance exists. + * @dev Emits a {Transfer} event and decreases the spender's allowance. + * @param _from The address to transfer tokens from. + * @param _to The address to transfer tokens to. + * @param _value The amount of tokens to transfer. + * @return True if the operation succeeded. + */ + function transferFrom(address _from, address _to, uint256 _value) external returns (bool); + + /** + * @notice Burns (destroys) a specific amount of tokens from the caller's balance. + * @dev Emits a {Transfer} event to the zero address. + * @param _value The amount of tokens to burn. + */ + function burn(uint256 _value) external; + + /** + * @notice Burns tokens from another account, deducting from the caller's allowance. + * @dev Emits a {Transfer} event to the zero address. + * @param _account The address whose tokens will be burned. + * @param _value The amount of tokens to burn. + */ + function burnFrom(address _account, uint256 _value) external; + + /** + * @notice Returns the current nonce for an owner. + * @dev This value changes each time a permit is used. + * @param _owner The address of the owner. + * @return The current nonce. + */ + function nonces(address _owner) external view returns (uint256); + + /** + * @notice Returns the domain separator used in the encoding of the signature for {permit}. + * @dev This value is unique to a contract and chain ID combination to prevent replay attacks. + * @return The domain separator. + */ + function DOMAIN_SEPARATOR() external view returns (bytes32); + + /** + * @notice Sets the allowance for a spender via a signature. + * @dev This function implements EIP-2612 permit functionality. + * @param _owner The address of the token owner. + * @param _spender The address of the spender. + * @param _value The amount of tokens to approve. + * @param _deadline The deadline for the permit (timestamp). + * @param _v The recovery byte of the signature. + * @param _r The r value of the signature. + * @param _s The s value of the signature. + */ + function permit( + address _owner, + address _spender, + uint256 _value, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external; +} diff --git a/cli/project-template/src/templates/interfaces/IERC2981.sol b/cli/project-template/src/templates/interfaces/IERC2981.sol new file mode 100644 index 00000000..6de2c31d --- /dev/null +++ b/cli/project-template/src/templates/interfaces/IERC2981.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-2981 NFT Royalty Standard Interface + * @notice Interface for ERC-2981 royalty information with custom errors + * @dev This interface includes all custom errors used by ERC-2981 implementations + */ +interface IERC2981 { + /** + * @notice Error indicating the default royalty fee exceeds 100% (10000 basis points). + */ + error ERC2981InvalidDefaultRoyalty(uint256 _numerator, uint256 _denominator); + + /** + * @notice Error indicating the default royalty receiver is the zero address. + */ + error ERC2981InvalidDefaultRoyaltyReceiver(address _receiver); + + /** + * @notice Error indicating a token-specific royalty fee exceeds 100% (10000 basis points). + */ + error ERC2981InvalidTokenRoyalty(uint256 _tokenId, uint256 _numerator, uint256 _denominator); + + /** + * @notice Error indicating a token-specific royalty receiver is the zero address. + */ + error ERC2981InvalidTokenRoyaltyReceiver(uint256 _tokenId, address _receiver); + + /** + * @notice Returns royalty information for a given token and sale price. + * @dev Called with the sale price to determine how much royalty is owed and to whom. + * Implementations MUST calculate royalty as a percentage of the sale price. + * @param _tokenId The NFT asset queried for royalty information. + * @param _salePrice The sale price of the NFT asset specified by _tokenId. + * @return receiver The address designated to receive the royalty payment. + * @return royaltyAmount The royalty payment amount for _salePrice. + */ + function royaltyInfo(uint256 _tokenId, uint256 _salePrice) + external + view + returns (address receiver, uint256 royaltyAmount); +} diff --git a/cli/project-template/src/templates/interfaces/IERC6909.sol b/cli/project-template/src/templates/interfaces/IERC6909.sol new file mode 100644 index 00000000..d4a4dc41 --- /dev/null +++ b/cli/project-template/src/templates/interfaces/IERC6909.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-6909 Minimal Multi-Token Interface + * @notice Interface for ERC-6909 multi-token contracts with custom errors. + */ +interface IERC6909 { + /** + * @notice Thrown when the sender has insufficient balance. + */ + error ERC6909InsufficientBalance(address _sender, uint256 _balance, uint256 _needed, uint256 _id); + + /** + * @notice Thrown when the spender has insufficient allowance. + */ + error ERC6909InsufficientAllowance(address _spender, uint256 _allowance, uint256 _needed, uint256 _id); + + /** + * @notice Thrown when the approver address is invalid. + */ + error ERC6909InvalidApprover(address _approver); + + /** + * @notice Thrown when the receiver address is invalid. + */ + error ERC6909InvalidReceiver(address _receiver); + + /** + * @notice Thrown when the sender address is invalid. + */ + error ERC6909InvalidSender(address _sender); + + /** + * @notice Thrown when the spender address is invalid. + */ + error ERC6909InvalidSpender(address _spender); + + /** + * @notice Emitted when a transfer occurs. + */ + event Transfer( + address _caller, address indexed _sender, address indexed _receiver, uint256 indexed _id, uint256 _amount + ); + + /** + * @notice Emitted when an operator is set. + */ + event OperatorSet(address indexed _owner, address indexed _spender, bool _approved); + + /** + * @notice Emitted when an approval occurs. + */ + event Approval(address indexed _owner, address indexed _spender, uint256 indexed _id, uint256 _amount); + + /** + * @notice Owner balance of an id. + * @param _owner The address of the owner. + * @param _id The id of the token. + * @return The balance of the token. + */ + function balanceOf(address _owner, uint256 _id) external view returns (uint256); + + /** + * @notice Spender allowance of an id. + * @param _owner The address of the owner. + * @param _spender The address of the spender. + * @param _id The id of the token. + * @return The allowance of the token. + */ + function allowance(address _owner, address _spender, uint256 _id) external view returns (uint256); + + /** + * @notice Checks if a spender is approved by an owner as an operator. + * @param _owner The address of the owner. + * @param _spender The address of the spender. + * @return The approval status. + */ + function isOperator(address _owner, address _spender) external view returns (bool); + + /** + * @notice Approves an amount of an id to a spender. + * @param _spender The address of the spender. + * @param _id The id of the token. + * @param _amount The amount of the token. + * @return Whether the approval succeeded. + */ + function approve(address _spender, uint256 _id, uint256 _amount) external returns (bool); + + /** + * @notice Sets or removes a spender as an operator for the caller. + * @param _spender The address of the spender. + * @param _approved The approval status. + * @return Whether the operator update succeeded. + */ + function setOperator(address _spender, bool _approved) external returns (bool); + + /** + * @notice Transfers an amount of an id from the caller to a receiver. + * @param _receiver The address of the receiver. + * @param _id The id of the token. + * @param _amount The amount of the token. + * @return Whether the transfer succeeded. + */ + function transfer(address _receiver, uint256 _id, uint256 _amount) external returns (bool); + + /** + * @notice Transfers an amount of an id from a sender to a receiver. + * @param _sender The address of the sender. + * @param _receiver The address of the receiver. + * @param _id The id of the token. + * @param _amount The amount of the token. + * @return Whether the transfer succeeded. + */ + function transferFrom(address _sender, address _receiver, uint256 _id, uint256 _amount) external returns (bool); +} diff --git a/cli/project-template/src/templates/interfaces/IERC721.sol b/cli/project-template/src/templates/interfaces/IERC721.sol new file mode 100644 index 00000000..b30600fd --- /dev/null +++ b/cli/project-template/src/templates/interfaces/IERC721.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-721 Token Standard Interface + * @notice Interface for ERC-721 token contracts with custom errors + * @dev This interface includes all custom errors used by ERC-721 implementations + * Note: the ERC-165 identifier for this interface is 0x80ac58cd. + */ +interface IERC721 { + /** + * @notice Error indicating the queried owner address is invalid (zero address). + */ + error ERC721InvalidOwner(address _owner); + + /** + * @notice Error indicating that the queried token does not exist. + */ + error ERC721NonexistentToken(uint256 _tokenId); + + /** + * @notice Error indicating the sender does not match the token owner. + */ + error ERC721IncorrectOwner(address _sender, uint256 _tokenId, address _owner); + + /** + * @notice Error indicating the sender address is invalid. + */ + error ERC721InvalidSender(address _sender); + + /** + * @notice Error indicating the receiver address is invalid. + */ + error ERC721InvalidReceiver(address _receiver); + + /** + * @notice Error indicating the operator lacks approval to transfer the given token. + */ + error ERC721InsufficientApproval(address _operator, uint256 _tokenId); + + /** + * @notice Error indicating the approver address is invalid. + */ + error ERC721InvalidApprover(address _approver); + + /** + * @notice Error indicating the operator address is invalid. + */ + error ERC721InvalidOperator(address _operator); + + /** + * @notice Emitted when ownership of an NFT changes by any mechanism. + */ + event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId); + + /** + * @notice Emitted when the approved address for an NFT is changed or reaffirmed. + */ + event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId); + + /** + * @notice Emitted when an operator is enabled or disabled for an owner. + */ + event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved); + + /** + * @notice Returns the number of tokens owned by a given address. + * @param _owner The address to query the balance of. + * @return The balance (number of tokens) owned by `_owner`. + */ + function balanceOf(address _owner) external view returns (uint256); + + /** + * @notice Returns the owner of a given token ID. + * @param _tokenId The token ID to query. + * @return The address of the token owner. + */ + function ownerOf(uint256 _tokenId) external view returns (address); + + /** + * @notice Returns the approved address for a given token ID. + * @param _tokenId The token ID to query the approval of. + * @return The approved address for the token. + */ + function getApproved(uint256 _tokenId) external view returns (address); + + /** + * @notice Returns true if an operator is approved to manage all of an owner's assets. + * @param _owner The token owner. + * @param _operator The operator address. + * @return True if the operator is approved for all tokens of the owner. + */ + function isApprovedForAll(address _owner, address _operator) external view returns (bool); + + /** + * @notice Approves another address to transfer the given token ID. + * @param _approved The address to be approved. + * @param _tokenId The token ID to approve. + */ + function approve(address _approved, uint256 _tokenId) external; + + /** + * @notice Approves or revokes permission for an operator to manage all caller's assets. + * @param _operator The operator address to set approval for. + * @param _approved True to approve, false to revoke. + */ + function setApprovalForAll(address _operator, bool _approved) external; + + /** + * @notice Transfers a token from one address to another. + * @param _from The current owner of the token. + * @param _to The address to receive the token. + * @param _tokenId The token ID to transfer. + */ + function transferFrom(address _from, address _to, uint256 _tokenId) external; + + /** + * @notice Safely transfers a token, checking if the receiver can handle ERC-721 tokens. + * @param _from The current owner of the token. + * @param _to The address to receive the token. + * @param _tokenId The token ID to transfer. + */ + function safeTransferFrom(address _from, address _to, uint256 _tokenId) external; + + /** + * @notice Safely transfers a token with additional data. + * @param _from The current owner of the token. + * @param _to The address to receive the token. + * @param _tokenId The token ID to transfer. + * @param _data Additional data with no specified format. + */ + function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes calldata _data) external; +} diff --git a/cli/project-template/src/templates/interfaces/IERC721Enumerable.sol b/cli/project-template/src/templates/interfaces/IERC721Enumerable.sol new file mode 100644 index 00000000..688ed9f4 --- /dev/null +++ b/cli/project-template/src/templates/interfaces/IERC721Enumerable.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-721 Enumerable Token Standard Interface + * @notice Interface for ERC-721 token contracts with enumeration support and custom errors + * @dev This interface includes all custom errors used by ERC-721 Enumerable implementations + */ +interface IERC721Enumerable { + /** + * @notice Thrown when querying or transferring from an invalid owner address. + */ + error ERC721InvalidOwner(address _owner); + + /** + * @notice Thrown when operating on a non-existent token. + */ + error ERC721NonexistentToken(uint256 _tokenId); + + /** + * @notice Thrown when the provided owner does not match the actual owner of the token. + */ + error ERC721IncorrectOwner(address _sender, uint256 _tokenId, address _owner); + + /** + * @notice Thrown when the sender address is invalid. + */ + error ERC721InvalidSender(address _sender); + + /** + * @notice Thrown when the receiver address is invalid. + */ + error ERC721InvalidReceiver(address _receiver); + + /** + * @notice Thrown when the operator lacks sufficient approval for a transfer. + */ + error ERC721InsufficientApproval(address _operator, uint256 _tokenId); + + /** + * @notice Thrown when an invalid approver is provided. + */ + error ERC721InvalidApprover(address _approver); + + /** + * @notice Thrown when an invalid operator is provided. + */ + error ERC721InvalidOperator(address _operator); + + /** + * @notice Thrown when an index is out of bounds during enumeration. + */ + error ERC721OutOfBoundsIndex(address _owner, uint256 _index); + + /** + * @notice Emitted when a token is transferred between addresses. + */ + event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId); + + /** + * @notice Emitted when a token is approved for transfer by another address. + */ + event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId); + + /** + * @notice Emitted when an operator is approved or revoked for all tokens of an owner. + */ + event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved); + + /** + * @notice Returns the name of the token collection. + * @return The token collection name. + */ + function name() external view returns (string memory); + + /** + * @notice Returns the symbol of the token collection. + * @return The token symbol. + */ + function symbol() external view returns (string memory); + + /** + * @notice Returns the total number of tokens in existence. + * @return The total supply of tokens. + */ + function totalSupply() external view returns (uint256); + + /** + * @notice Returns the number of tokens owned by an address. + * @param _owner The address to query. + * @return The balance (number of tokens owned). + */ + function balanceOf(address _owner) external view returns (uint256); + + /** + * @notice Returns the owner of a given token ID. + * @param _tokenId The token ID to query. + * @return The address of the token owner. + */ + function ownerOf(uint256 _tokenId) external view returns (address); + + /** + * @notice Returns a token ID owned by a given address at a specific index. + * @param _owner The address to query. + * @param _index The index of the token. + * @return The token ID owned by `_owner` at `_index`. + */ + function tokenOfOwnerByIndex(address _owner, uint256 _index) external view returns (uint256); + + /** + * @notice Returns the approved address for a given token ID. + * @param _tokenId The token ID to query. + * @return The approved address for the token. + */ + function getApproved(uint256 _tokenId) external view returns (address); + + /** + * @notice Returns whether an operator is approved for all tokens of an owner. + * @param _owner The token owner. + * @param _operator The operator address. + * @return True if approved for all, false otherwise. + */ + function isApprovedForAll(address _owner, address _operator) external view returns (bool); + + /** + * @notice Approves another address to transfer a specific token ID. + * @param _approved The address being approved. + * @param _tokenId The token ID to approve. + */ + function approve(address _approved, uint256 _tokenId) external; + + /** + * @notice Approves or revokes an operator to manage all tokens of the caller. + * @param _operator The operator address. + * @param _approved True to approve, false to revoke. + */ + function setApprovalForAll(address _operator, bool _approved) external; + + /** + * @notice Transfers a token from one address to another. + * @param _from The current owner of the token. + * @param _to The recipient address. + * @param _tokenId The token ID to transfer. + */ + function transferFrom(address _from, address _to, uint256 _tokenId) external; + + /** + * @notice Safely transfers a token, checking for receiver contract compatibility. + * @param _from The current owner of the token. + * @param _to The recipient address. + * @param _tokenId The token ID to transfer. + */ + function safeTransferFrom(address _from, address _to, uint256 _tokenId) external; + + /** + * @notice Safely transfers a token with additional data. + * @param _from The current owner of the token. + * @param _to The recipient address. + * @param _tokenId The token ID to transfer. + * @param _data Additional data to send to the receiver. + */ + function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes calldata _data) external; +} diff --git a/cli/project-template/src/templates/interfaces/IERC721Metadata.sol b/cli/project-template/src/templates/interfaces/IERC721Metadata.sol new file mode 100644 index 00000000..0c8c60d9 --- /dev/null +++ b/cli/project-template/src/templates/interfaces/IERC721Metadata.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-721 Non-Fungible Token Standard, optional metadata extension + * @dev See https://eips.ethereum.org/EIPS/eip-721 + * Note: the ERC-165 identifier for this interface is 0x5b5e139f. + */ +interface IERC721Metadata { + /** + * @notice A descriptive name for a collection of NFTs in this contract + */ + function name() external view returns (string memory _name); + + /** + * @notice An abbreviated name for NFTs in this contract + */ + function symbol() external view returns (string memory _symbol); + + /** + * @notice A distinct Uniform Resource Identifier (URI) for a given asset. + * @dev Throws if `_tokenId` is not a valid NFT. URIs are defined in RFC + * 3986. The URI may point to a JSON file that conforms to the "ERC721 + * Metadata JSON Schema". + */ + function tokenURI(uint256 _tokenId) external view returns (string memory); +} diff --git a/cli/project-template/src/templates/interfaces/IERC721Receiver.sol b/cli/project-template/src/templates/interfaces/IERC721Receiver.sol new file mode 100644 index 00000000..5a9f6379 --- /dev/null +++ b/cli/project-template/src/templates/interfaces/IERC721Receiver.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-721 Token Receiver Interface + * @notice Interface for contracts that want to handle safe transfers of ERC-721 tokens. + * @dev Contracts implementing this must return the selector to confirm token receipt. + */ +interface IERC721Receiver { + /** + * @notice Handles the receipt of an NFT. + * @param _operator The address which called `safeTransferFrom`. + * @param _from The previous owner of the token. + * @param _tokenId The NFT identifier being transferred. + * @param _data Additional data with no specified format. + * @return The selector to confirm the token transfer. + */ + function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes calldata _data) + external + returns (bytes4); +} diff --git a/cli/project-template/src/templates/interfaces/IFacet.sol b/cli/project-template/src/templates/interfaces/IFacet.sol new file mode 100644 index 00000000..46e9ab73 --- /dev/null +++ b/cli/project-template/src/templates/interfaces/IFacet.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title IFacet + * @notice Interface for a facet contract + */ +interface IFacet { + /** + * @notice Exports the selectors that are exposed by the facet. + * @return Packed selectors that are exported by the facet. + */ + function exportSelectors() external pure returns (bytes memory); +} diff --git a/cli/project-template/src/templates/libraries/NonReentrancyMod.sol b/cli/project-template/src/templates/libraries/NonReentrancyMod.sol new file mode 100644 index 00000000..e219d35e --- /dev/null +++ b/cli/project-template/src/templates/libraries/NonReentrancyMod.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/* + * @title LibNonReentrancy - Non-Reentrancy Library + * @notice Provides common non-reentrant functions for Solidity contracts. + */ + +bytes32 constant NON_REENTRANT_SLOT = keccak256("compose.nonreentrant"); + +/** + * Function selector - 0x43a0d067 + */ +error Reentrancy(); + +/** + * @dev How to use as a library in user facets + */ +/* +function someFunction() external { + LibNonReentrancy.enter(); + // functions logic here... + LibNonReentrancy.exit(); +} +*/ + +/** + * @dev How to use as a modifier in user facets + */ +/* +modifier nonReentrant { + LibNonReentrancy.enter(); + _; + LibNonReentrancy.exit(); +} +*/ + +/** + * @dev This unlocks the entry into a function + */ +function enter() { + bytes32 position = NON_REENTRANT_SLOT; + assembly ("memory-safe") { + if tload(position) { + /* + * Store the selector for "Reentrancy()" (0xab143c06) at the beginning of memory. + * We shift left by 224 bits (256 - 32) to left-align the 4-byte selector in the 32-byte slot. + */ + mstore(0x00, shl(224, 0xab143c06)) + revert(0x00, 0x04) + } + tstore(position, 1) + } +} + +/** + * @dev This locks the entry into a function + */ +function exit() { + bytes32 position = NON_REENTRANT_SLOT; + assembly { + tstore(position, 0) + } +} diff --git a/cli/project-template/src/templates/libraries/Utils.sol b/cli/project-template/src/templates/libraries/Utils.sol new file mode 100644 index 00000000..3cbdc006 --- /dev/null +++ b/cli/project-template/src/templates/libraries/Utils.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title LibUtils - Utility Library + * @notice Provides common utility functions for Solidity contracts. + * @dev Implements functions for data type conversions. + */ diff --git a/cli/project-template/src/templates/token/ERC1155/Approve/ERC1155ApproveFacet.sol b/cli/project-template/src/templates/token/ERC1155/Approve/ERC1155ApproveFacet.sol new file mode 100644 index 00000000..043d12e1 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC1155/Approve/ERC1155ApproveFacet.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-1155 Approve Facet + * @notice Provides approval functionality for ERC-1155 tokens. + */ +contract ERC1155ApproveFacet { + /** + * @notice Error indicating the operator address is invalid. + * @param _operator Invalid operator address. + */ + error ERC1155InvalidOperator(address _operator); + + /** + * @notice Emitted when `account` grants or revokes permission to `operator` to transfer their tokens. + * @param _account The token owner granting/revoking approval. + * @param _operator The address being approved/revoked. + * @param _approved True if approval is granted, false if revoked. + */ + event ApprovalForAll(address indexed _account, address indexed _operator, bool _approved); + + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc1155"); + + /** + * @dev ERC-8042 compliant storage struct for ERC-1155 token data. + * @custom:storage-location erc8042:erc1155 + */ + struct ERC1155Storage { + mapping(uint256 id => mapping(address account => uint256 balance)) balanceOf; + mapping(address account => mapping(address operator => bool)) isApprovedForAll; + } + + /** + * @notice Returns the ERC-1155 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC-1155 storage struct reference. + */ + function getStorage() internal pure returns (ERC1155Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Grants or revokes permission to `operator` to transfer the caller's tokens. + * @dev Emits an {ApprovalForAll} event. + * @param _operator The address to grant/revoke approval to. + * @param _approved True to approve, false to revoke. + */ + function setApprovalForAll(address _operator, bool _approved) external { + if (_operator == address(0)) { + revert ERC1155InvalidOperator(address(0)); + } + getStorage().isApprovedForAll[msg.sender][_operator] = _approved; + emit ApprovalForAll(msg.sender, _operator, _approved); + } + + /** + * @notice Exports the function selectors of the ERC1155ApproveFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC1155ApproveFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.setApprovalForAll.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC1155/Approve/ERC1155ApproveMod.sol b/cli/project-template/src/templates/token/ERC1155/Approve/ERC1155ApproveMod.sol new file mode 100644 index 00000000..4dc592ca --- /dev/null +++ b/cli/project-template/src/templates/token/ERC1155/Approve/ERC1155ApproveMod.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-1155 Approve Module + * @notice Provides internal approval functionality for ERC-1155 tokens. + */ + +/** + * @notice Error indicating the operator address is invalid. + * @param _operator Invalid operator address. + */ +error ERC1155InvalidOperator(address _operator); + +/** + * @notice Emitted when `account` grants or revokes permission to `operator` to transfer their tokens. + * @param _account The token owner granting/revoking approval. + * @param _operator The address being approved/revoked. + * @param _approved True if approval is granted, false if revoked. + */ +event ApprovalForAll(address indexed _account, address indexed _operator, bool _approved); + +/** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc1155"); + +/** + * @dev ERC-8042 compliant storage struct for ERC-1155 token data. + * @custom:storage-location erc8042:erc1155 + */ +struct ERC1155Storage { + mapping(uint256 id => mapping(address account => uint256 balance)) balanceOf; + mapping(address account => mapping(address operator => bool)) isApprovedForAll; +} + +/** + * @notice Returns the ERC-1155 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC-1155 storage struct reference. + */ +function getStorage() pure returns (ERC1155Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Grants or revokes permission to `operator` to transfer the user's tokens. + * @dev Emits an {ApprovalForAll} event. + * @param _user The address of the token owner. + * @param _operator The address to grant/revoke approval to. + * @param _approved True to approve, false to revoke. + */ +function setApprovalForAll(address _user, address _operator, bool _approved) { + if (_operator == address(0)) { + revert ERC1155InvalidOperator(address(0)); + } + getStorage().isApprovedForAll[_user][_operator] = _approved; + emit ApprovalForAll(_user, _operator, _approved); +} diff --git a/cli/project-template/src/templates/token/ERC1155/Burn/ERC1155BurnFacet.sol b/cli/project-template/src/templates/token/ERC1155/Burn/ERC1155BurnFacet.sol new file mode 100644 index 00000000..29583513 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC1155/Burn/ERC1155BurnFacet.sol @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-1155 Burn Facet + * @notice Provides burn functionality for ERC-1155 tokens. + */ +contract ERC1155BurnFacet { + /** + * @notice Error indicating insufficient balance for a burn operation. + * @param _sender Address attempting the burn. + * @param _balance Current balance of the sender. + * @param _needed Amount required to complete the operation. + * @param _tokenId The token ID involved. + */ + error ERC1155InsufficientBalance(address _sender, uint256 _balance, uint256 _needed, uint256 _tokenId); + + /** + * @notice Error indicating the sender address is invalid. + * @param _sender Invalid sender address. + */ + error ERC1155InvalidSender(address _sender); + + /** + * @notice Error indicating array length mismatch in batch operations. + * @param _idsLength Length of the ids array. + * @param _valuesLength Length of the values array. + */ + error ERC1155InvalidArrayLength(uint256 _idsLength, uint256 _valuesLength); + + /** + * @notice Error indicating missing approval for an operator. + * @param _operator Address attempting the operation. + * @param _owner The token owner. + */ + error ERC1155MissingApprovalForAll(address _operator, address _owner); + + /** + * @notice Emitted when `value` amount of tokens of type `id` are transferred from `from` to `to` by `operator`. + * @param _operator The address which initiated the transfer. + * @param _from The address which previously owned the token. + * @param _to The address which now owns the token. + * @param _id The token type being transferred. + * @param _value The amount of tokens transferred. + */ + event TransferSingle( + address indexed _operator, address indexed _from, address indexed _to, uint256 _id, uint256 _value + ); + + /** + * @notice Equivalent to multiple {TransferSingle} events, where `operator`, `from` and `to` are the same for all transfers. + * @param _operator The address which initiated the batch transfer. + * @param _from The address which previously owned the tokens. + * @param _to The address which now owns the tokens. + * @param _ids The token types being transferred. + * @param _values The amounts of tokens transferred. + */ + event TransferBatch( + address indexed _operator, address indexed _from, address indexed _to, uint256[] _ids, uint256[] _values + ); + + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc1155"); + + /** + * @dev ERC-8042 compliant storage struct for ERC-1155 token data. + * @custom:storage-location erc8042:erc1155 + */ + struct ERC1155Storage { + mapping(uint256 id => mapping(address account => uint256 balance)) balanceOf; + mapping(address account => mapping(address operator => bool)) isApprovedForAll; + } + + /** + * @notice Returns the ERC-1155 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC-1155 storage struct reference. + */ + function getStorage() internal pure returns (ERC1155Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Burns a single token type from an address. + * @dev Emits a {TransferSingle} event. + * Caller must be the owner or an approved operator. + * @param _from The address whose tokens will be burned. + * @param _id The token type to burn. + * @param _value The amount of tokens to burn. + */ + function burn(address _from, uint256 _id, uint256 _value) external { + if (_from == address(0)) { + revert ERC1155InvalidSender(address(0)); + } + + ERC1155Storage storage s = getStorage(); + + if (_from != msg.sender && !s.isApprovedForAll[_from][msg.sender]) { + revert ERC1155MissingApprovalForAll(msg.sender, _from); + } + + uint256 fromBalance = s.balanceOf[_id][_from]; + + if (fromBalance < _value) { + revert ERC1155InsufficientBalance(_from, fromBalance, _value, _id); + } + + unchecked { + s.balanceOf[_id][_from] = fromBalance - _value; + } + + emit TransferSingle(msg.sender, _from, address(0), _id, _value); + } + + /** + * @notice Burns multiple token types from an address in a single transaction. + * @dev Emits a {TransferBatch} event. + * Caller must be the owner or an approved operator. + * @param _from The address whose tokens will be burned. + * @param _ids The token types to burn. + * @param _values The amounts of tokens to burn for each type. + */ + function burnBatch(address _from, uint256[] calldata _ids, uint256[] calldata _values) external { + if (_from == address(0)) { + revert ERC1155InvalidSender(address(0)); + } + if (_ids.length != _values.length) { + revert ERC1155InvalidArrayLength(_ids.length, _values.length); + } + + ERC1155Storage storage s = getStorage(); + + if (_from != msg.sender && !s.isApprovedForAll[_from][msg.sender]) { + revert ERC1155MissingApprovalForAll(msg.sender, _from); + } + + for (uint256 i = 0; i < _ids.length; i++) { + uint256 id = _ids[i]; + uint256 value = _values[i]; + uint256 fromBalance = s.balanceOf[id][_from]; + + if (fromBalance < value) { + revert ERC1155InsufficientBalance(_from, fromBalance, value, id); + } + + unchecked { + s.balanceOf[id][_from] = fromBalance - value; + } + } + + emit TransferBatch(msg.sender, _from, address(0), _ids, _values); + } + + /** + * @notice Exports the function selectors of the ERC1155BurnFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC1155BurnFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.burn.selector, this.burnBatch.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC1155/Burn/ERC1155BurnMod.sol b/cli/project-template/src/templates/token/ERC1155/Burn/ERC1155BurnMod.sol new file mode 100644 index 00000000..cfe42fc7 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC1155/Burn/ERC1155BurnMod.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-1155 Burn Module + * @notice Provides internal burn functionality for ERC-1155 tokens. + */ + +/** + * @notice Error indicating insufficient balance for a burn operation. + * @param _sender Address attempting the burn. + * @param _balance Current balance of the sender. + * @param _needed Amount required to complete the operation. + * @param _tokenId The token ID involved. + */ +error ERC1155InsufficientBalance(address _sender, uint256 _balance, uint256 _needed, uint256 _tokenId); + +/** + * @notice Error indicating the sender address is invalid. + * @param _sender Invalid sender address. + */ +error ERC1155InvalidSender(address _sender); + +/** + * @notice Error indicating array length mismatch in batch operations. + * @param _idsLength Length of the ids array. + * @param _valuesLength Length of the values array. + */ +error ERC1155InvalidArrayLength(uint256 _idsLength, uint256 _valuesLength); + +/** + * @notice Emitted when `value` amount of tokens of type `id` are transferred from `from` to `to` by `operator`. + * @param _operator The address which initiated the transfer. + * @param _from The address which previously owned the token. + * @param _to The address which now owns the token. + * @param _id The token type being transferred. + * @param _value The amount of tokens transferred. + */ +event TransferSingle( + address indexed _operator, address indexed _from, address indexed _to, uint256 _id, uint256 _value +); + +/** + * @notice Equivalent to multiple {TransferSingle} events, where `operator`, `from` and `to` are the same for all transfers. + * @param _operator The address which initiated the batch transfer. + * @param _from The address which previously owned the tokens. + * @param _to The address which now owns the tokens. + * @param _ids The token types being transferred. + * @param _values The amounts of tokens transferred. + */ +event TransferBatch( + address indexed _operator, address indexed _from, address indexed _to, uint256[] _ids, uint256[] _values +); + +/** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc1155"); + +/** + * @dev ERC-8042 compliant storage struct for ERC-1155 token data. + * @custom:storage-location erc8042:erc1155 + */ +struct ERC1155Storage { + mapping(uint256 id => mapping(address account => uint256 balance)) balanceOf; + mapping(address account => mapping(address operator => bool)) isApprovedForAll; +} + +/** + * @notice Returns the ERC-1155 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC-1155 storage struct reference. + */ +function getStorage() pure returns (ERC1155Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Burns a single token type from an address. + * @dev Decreases the balance and emits a TransferSingle event. + * Reverts if the account has insufficient balance. + * This module does not perform approval checks. Ensure proper ownership or approval validation before calling this function. + * @param _from The address whose tokens will be burned. + * @param _id The token type to burn. + * @param _value The amount of tokens to burn. + */ +function burn(address _from, uint256 _id, uint256 _value) { + if (_from == address(0)) { + revert ERC1155InvalidSender(address(0)); + } + + ERC1155Storage storage s = getStorage(); + uint256 fromBalance = s.balanceOf[_id][_from]; + + if (fromBalance < _value) { + revert ERC1155InsufficientBalance(_from, fromBalance, _value, _id); + } + + unchecked { + s.balanceOf[_id][_from] = fromBalance - _value; + } + + emit TransferSingle(msg.sender, _from, address(0), _id, _value); +} + +/** + * @notice Burns multiple token types from an address in a single transaction. + * @dev Decreases balances for each token type and emits a TransferBatch event. + * Reverts if the account has insufficient balance for any token type. + * This module does not perform approval checks. Ensure proper ownership or approval validation before calling this function. + * @param _from The address whose tokens will be burned. + * @param _ids The token types to burn. + * @param _values The amounts of tokens to burn for each type. + */ +function burnBatch(address _from, uint256[] memory _ids, uint256[] memory _values) { + if (_from == address(0)) { + revert ERC1155InvalidSender(address(0)); + } + if (_ids.length != _values.length) { + revert ERC1155InvalidArrayLength(_ids.length, _values.length); + } + + ERC1155Storage storage s = getStorage(); + + for (uint256 i = 0; i < _ids.length; i++) { + uint256 id = _ids[i]; + uint256 value = _values[i]; + uint256 fromBalance = s.balanceOf[id][_from]; + + if (fromBalance < value) { + revert ERC1155InsufficientBalance(_from, fromBalance, value, id); + } + + unchecked { + s.balanceOf[id][_from] = fromBalance - value; + } + } + + emit TransferBatch(msg.sender, _from, address(0), _ids, _values); +} diff --git a/cli/project-template/src/templates/token/ERC1155/Data/ERC1155DataFacet.sol b/cli/project-template/src/templates/token/ERC1155/Data/ERC1155DataFacet.sol new file mode 100644 index 00000000..7547f2ee --- /dev/null +++ b/cli/project-template/src/templates/token/ERC1155/Data/ERC1155DataFacet.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-1155 Data Facet + */ +contract ERC1155DataFacet { + /** + * @notice Error indicating array length mismatch in batch operations. + * @param _idsLength Length of the ids array. + * @param _valuesLength Length of the values array. + */ + error ERC1155InvalidArrayLength(uint256 _idsLength, uint256 _valuesLength); + + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc1155"); + + /** + * @dev ERC-8042 compliant storage struct for ERC-1155 token data. + * @custom:storage-location erc8042:erc1155 + */ + struct ERC1155Storage { + mapping(uint256 id => mapping(address account => uint256 balance)) balanceOf; + mapping(address account => mapping(address operator => bool)) isApprovedForAll; + } + + /** + * @notice Returns the ERC-1155 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC-1155 storage struct reference. + */ + function getStorage() internal pure returns (ERC1155Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns the amount of tokens of token type `id` owned by `account`. + * @param _account The address to query the balance of. + * @param _id The token type to query. + * @return The balance of the token type. + */ + function balanceOf(address _account, uint256 _id) external view returns (uint256) { + return getStorage().balanceOf[_id][_account]; + } + + /** + * @notice Batched version of {balanceOf}. + * @param _accounts The addresses to query the balances of. + * @param _ids The token types to query. + * @return balances The balances of the token types. + */ + function balanceOfBatch(address[] calldata _accounts, uint256[] calldata _ids) + external + view + returns (uint256[] memory balances) + { + if (_accounts.length != _ids.length) { + revert ERC1155InvalidArrayLength(_ids.length, _accounts.length); + } + + ERC1155Storage storage s = getStorage(); + balances = new uint256[](_accounts.length); + + for (uint256 i = 0; i < _accounts.length; i++) { + balances[i] = s.balanceOf[_ids[i]][_accounts[i]]; + } + } + + /** + * @notice Returns true if `operator` is approved to transfer `account`'s tokens. + * @param _account The token owner. + * @param _operator The operator to query. + * @return True if the operator is approved, false otherwise. + */ + function isApprovedForAll(address _account, address _operator) external view returns (bool) { + return getStorage().isApprovedForAll[_account][_operator]; + } + + /** + * @notice Exports the function selectors of the ERC1155DataFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC1155DataFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.balanceOf.selector, this.balanceOfBatch.selector, this.isApprovedForAll.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC1155/Metadata/ERC1155MetadataFacet.sol b/cli/project-template/src/templates/token/ERC1155/Metadata/ERC1155MetadataFacet.sol new file mode 100644 index 00000000..382919fa --- /dev/null +++ b/cli/project-template/src/templates/token/ERC1155/Metadata/ERC1155MetadataFacet.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-1155 Metadata Facet + * @notice Provides URI metadata functionality for ERC-1155 tokens. + */ +contract ERC1155MetadataFacet { + /** + * @notice Emitted when the URI for token type `_id` changes to `_value`. + * @param _value The new URI for the token type. + * @param _id The token type whose URI changed. + */ + event URI(string _value, uint256 indexed _id); + + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc1155.metadata"); + + /** + * @dev ERC-8042 compliant storage struct for ERC-1155 metadata. + * @custom:storage-location erc8042:erc1155.metadata + */ + struct ERC1155MetadataStorage { + string uri; + string baseURI; + mapping(uint256 tokenId => string) tokenURIs; + } + + /** + * @notice Returns the ERC-1155 metadata storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC-1155 metadata storage struct reference. + */ + function getStorage() internal pure returns (ERC1155MetadataStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns the URI for token type `_id`. + * @dev If a token-specific URI is set in tokenURIs[_id], returns the concatenation of baseURI and tokenURIs[_id]. + * Note that baseURI is empty by default and must be set explicitly if concatenation is desired. + * If no token-specific URI is set, returns the default URI which applies to all token types. + * The default URI may contain the substring `{id}` which clients should replace with the actual token ID. + * @param _id The token ID to query. + * @return The URI for the token type. + */ + function uri(uint256 _id) external view returns (string memory) { + ERC1155MetadataStorage storage s = getStorage(); + string memory tokenURI = s.tokenURIs[_id]; + + return bytes(tokenURI).length > 0 ? string.concat(s.baseURI, tokenURI) : s.uri; + } + + /** + * @notice Exports the function selectors of the ERC1155MetadataFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC1155MetadataFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.uri.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC1155/Metadata/ERC1155MetadataMod.sol b/cli/project-template/src/templates/token/ERC1155/Metadata/ERC1155MetadataMod.sol new file mode 100644 index 00000000..76997eb6 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC1155/Metadata/ERC1155MetadataMod.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-1155 Metadata Module + * @notice Provides internal metadata functionality for ERC-1155 tokens. + */ + +/** + * @notice Emitted when the URI for token type `_id` changes to `_value`. + * @param _value The new URI for the token type. + * @param _id The token type whose URI changed. + */ +event URI(string _value, uint256 indexed _id); + +/** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc1155.metadata"); + +/** + * @dev ERC-8042 compliant storage struct for ERC-1155 metadata. + * @custom:storage-location erc8042:erc1155.metadata + */ +struct ERC1155MetadataStorage { + string uri; + string baseURI; + mapping(uint256 tokenId => string) tokenURIs; +} + +/** + * @notice Returns the ERC-1155 metadata storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC-1155 metadata storage struct reference. + */ +function getStorage() pure returns (ERC1155MetadataStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Sets the token-specific URI for a given token ID. + * @dev Sets tokenURIs[_tokenId] to the provided string and emits a URI event with the full computed URI. + * The emitted URI is the concatenation of baseURI and the token-specific URI. + * @param _tokenId The token ID to set the URI for. + * @param _tokenURI The token-specific URI string to be concatenated with baseURI. + */ +function setTokenURI(uint256 _tokenId, string memory _tokenURI) { + ERC1155MetadataStorage storage s = getStorage(); + s.tokenURIs[_tokenId] = _tokenURI; + + string memory fullURI = bytes(_tokenURI).length > 0 ? string.concat(s.baseURI, _tokenURI) : s.uri; + emit URI(fullURI, _tokenId); +} + +/** + * @notice Sets the base URI prefix for token-specific URIs. + * @dev The base URI is concatenated with token-specific URIs set via setTokenURI. + * Does not affect the default URI used when no token-specific URI is set. + * @param _baseURI The base URI string to prepend to token-specific URIs. + */ +function setBaseURI(string memory _baseURI) { + ERC1155MetadataStorage storage s = getStorage(); + s.baseURI = _baseURI; +} + +/** + * @notice Sets the default URI for all token types. + * @dev This URI is used when no token-specific URI is set. + * @param _uri The default URI string. + */ +function setURI(string memory _uri) { + ERC1155MetadataStorage storage s = getStorage(); + s.uri = _uri; +} diff --git a/cli/project-template/src/templates/token/ERC1155/Mint/ERC1155MintMod.sol b/cli/project-template/src/templates/token/ERC1155/Mint/ERC1155MintMod.sol new file mode 100644 index 00000000..2392536a --- /dev/null +++ b/cli/project-template/src/templates/token/ERC1155/Mint/ERC1155MintMod.sol @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-1155 Token Receiver Interface + * @notice Interface that must be implemented by smart contracts in order to receive ERC-1155 token transfers. + */ +interface IERC1155Receiver { + /** + * @notice Handles the receipt of a single ERC-1155 token type. + * @dev This function is called at the end of a `safeTransferFrom` after the balance has been updated. + * + * IMPORTANT: To accept the transfer, this must return + * `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))` + * (i.e. 0xf23a6e61, or its own function selector). + * + * @param _operator The address which initiated the transfer (i.e. msg.sender). + * @param _from The address which previously owned the token. + * @param _id The ID of the token being transferred. + * @param _value The amount of tokens being transferred. + * @param _data Additional data with no specified format. + * @return `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))` if transfer is allowed. + */ + function onERC1155Received(address _operator, address _from, uint256 _id, uint256 _value, bytes calldata _data) + external + returns (bytes4); + + /** + * @notice Handles the receipt of multiple ERC-1155 token types. + * @dev This function is called at the end of a `safeBatchTransferFrom` after the balances have been updated. + * + * IMPORTANT: To accept the transfer(s), this must return + * `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))` + * (i.e. 0xbc197c81, or its own function selector). + * + * @param _operator The address which initiated the batch transfer (i.e. msg.sender). + * @param _from The address which previously owned the token. + * @param _ids An array containing ids of each token being transferred (order and length must match _values array). + * @param _values An array containing amounts of each token being transferred (order and length must match _ids array). + * @param _data Additional data with no specified format. + * @return `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))` if transfer is allowed. + */ + function onERC1155BatchReceived( + address _operator, + address _from, + uint256[] calldata _ids, + uint256[] calldata _values, + bytes calldata _data + ) external returns (bytes4); +} + +/** + * @title ERC-1155 Mint Module + * @notice Provides internal mint functionality for ERC-1155 tokens. + */ + +/** + * @notice Error indicating the receiver address is invalid. + * @param _receiver Invalid receiver address. + */ +error ERC1155InvalidReceiver(address _receiver); + +/** + * @notice Error indicating array length mismatch in batch operations. + * @param _idsLength Length of the ids array. + * @param _valuesLength Length of the values array. + */ +error ERC1155InvalidArrayLength(uint256 _idsLength, uint256 _valuesLength); + +/** + * @notice Emitted when `value` amount of tokens of type `id` are transferred from `from` to `to` by `operator`. + * @param _operator The address which initiated the transfer. + * @param _from The address which previously owned the token. + * @param _to The address which now owns the token. + * @param _id The token type being transferred. + * @param _value The amount of tokens transferred. + */ +event TransferSingle( + address indexed _operator, address indexed _from, address indexed _to, uint256 _id, uint256 _value +); + +/** + * @notice Equivalent to multiple {TransferSingle} events, where `operator`, `from` and `to` are the same for all transfers. + * @param _operator The address which initiated the batch transfer. + * @param _from The address which previously owned the tokens. + * @param _to The address which now owns the tokens. + * @param _ids The token types being transferred. + * @param _values The amounts of tokens transferred. + */ +event TransferBatch( + address indexed _operator, address indexed _from, address indexed _to, uint256[] _ids, uint256[] _values +); + +/** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc1155"); + +/** + * @dev ERC-8042 compliant storage struct for ERC-1155 token data. + * @custom:storage-location erc8042:erc1155 + */ +struct ERC1155Storage { + mapping(uint256 id => mapping(address account => uint256 balance)) balanceOf; + mapping(address account => mapping(address operator => bool)) isApprovedForAll; +} + +/** + * @notice Returns the ERC-1155 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC-1155 storage struct reference. + */ +function getStorage() pure returns (ERC1155Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Mints a single token type to an address. + * @dev Increases the balance and emits a TransferSingle event. + * Performs receiver validation if recipient is a contract. + * @param _to The address that will receive the tokens. + * @param _id The token type to mint. + * @param _value The amount of tokens to mint. + * @param _data Additional data with no specified format. + */ +function mint(address _to, uint256 _id, uint256 _value, bytes memory _data) { + if (_to == address(0)) { + revert ERC1155InvalidReceiver(address(0)); + } + + ERC1155Storage storage s = getStorage(); + s.balanceOf[_id][_to] += _value; + + emit TransferSingle(msg.sender, address(0), _to, _id, _value); + + if (_to.code.length > 0) { + try IERC1155Receiver(_to).onERC1155Received(msg.sender, address(0), _id, _value, _data) returns ( + bytes4 response + ) { + if (response != IERC1155Receiver.onERC1155Received.selector) { + revert ERC1155InvalidReceiver(_to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + revert ERC1155InvalidReceiver(_to); + } else { + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } + } +} + +/** + * @notice Mints multiple token types to an address in a single transaction. + * @dev Increases balances for each token type and emits a TransferBatch event. + * Performs receiver validation if recipient is a contract. + * @param _to The address that will receive the tokens. + * @param _ids The token types to mint. + * @param _values The amounts of tokens to mint for each type. + * @param _data Additional data with no specified format. + */ +function mintBatch(address _to, uint256[] memory _ids, uint256[] memory _values, bytes memory _data) { + if (_to == address(0)) { + revert ERC1155InvalidReceiver(address(0)); + } + if (_ids.length != _values.length) { + revert ERC1155InvalidArrayLength(_ids.length, _values.length); + } + + ERC1155Storage storage s = getStorage(); + + for (uint256 i = 0; i < _ids.length; i++) { + s.balanceOf[_ids[i]][_to] += _values[i]; + } + + emit TransferBatch(msg.sender, address(0), _to, _ids, _values); + + if (_to.code.length > 0) { + try IERC1155Receiver(_to).onERC1155BatchReceived(msg.sender, address(0), _ids, _values, _data) returns ( + bytes4 response + ) { + if (response != IERC1155Receiver.onERC1155BatchReceived.selector) { + revert ERC1155InvalidReceiver(_to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + revert ERC1155InvalidReceiver(_to); + } else { + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } + } +} diff --git a/cli/project-template/src/templates/token/ERC1155/Transfer/ERC1155TransferFacet.sol b/cli/project-template/src/templates/token/ERC1155/Transfer/ERC1155TransferFacet.sol new file mode 100644 index 00000000..ccd1129b --- /dev/null +++ b/cli/project-template/src/templates/token/ERC1155/Transfer/ERC1155TransferFacet.sol @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-1155 Token Receiver Interface + * @notice Interface that must be implemented by smart contracts in order to receive ERC-1155 token transfers. + */ +interface IERC1155Receiver { + /** + * @notice Handles the receipt of a single ERC-1155 token type. + * @dev This function is called at the end of a `safeTransferFrom` after the balance has been updated. + * + * IMPORTANT: To accept the transfer, this must return + * `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))` + * (i.e. 0xf23a6e61, or its own function selector). + * + * @param _operator The address which initiated the transfer (i.e. msg.sender). + * @param _from The address which previously owned the token. + * @param _id The ID of the token being transferred. + * @param _value The amount of tokens being transferred. + * @param _data Additional data with no specified format. + * @return `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))` if transfer is allowed. + */ + function onERC1155Received(address _operator, address _from, uint256 _id, uint256 _value, bytes calldata _data) + external + returns (bytes4); + + /** + * @notice Handles the receipt of multiple ERC-1155 token types. + * @dev This function is called at the end of a `safeBatchTransferFrom` after the balances have been updated. + * + * IMPORTANT: To accept the transfer(s), this must return + * `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))` + * (i.e. 0xbc197c81, or its own function selector). + * + * @param _operator The address which initiated the batch transfer (i.e. msg.sender). + * @param _from The address which previously owned the token. + * @param _ids An array containing ids of each token being transferred (order and length must match _values array). + * @param _values An array containing amounts of each token being transferred (order and length must match _ids array). + * @param _data Additional data with no specified format. + * @return `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))` if transfer is allowed. + */ + function onERC1155BatchReceived( + address _operator, + address _from, + uint256[] calldata _ids, + uint256[] calldata _values, + bytes calldata _data + ) external returns (bytes4); +} + +/** + * @title ERC-1155 Transfer Facet + * @notice Provides transfer functionality for ERC-1155 tokens. + */ +contract ERC1155TransferFacet { + /** + * @notice Error indicating insufficient balance for a transfer. + * @param _sender Address attempting the transfer. + * @param _balance Current balance of the sender. + * @param _needed Amount required to complete the operation. + * @param _tokenId The token ID involved. + */ + error ERC1155InsufficientBalance(address _sender, uint256 _balance, uint256 _needed, uint256 _tokenId); + + /** + * @notice Error indicating the sender address is invalid. + * @param _sender Invalid sender address. + */ + error ERC1155InvalidSender(address _sender); + + /** + * @notice Error indicating the receiver address is invalid. + * @param _receiver Invalid receiver address. + */ + error ERC1155InvalidReceiver(address _receiver); + + /** + * @notice Error indicating missing approval for an operator. + * @param _operator Address attempting the operation. + * @param _owner The token owner. + */ + error ERC1155MissingApprovalForAll(address _operator, address _owner); + + /** + * @notice Error indicating array length mismatch in batch operations. + * @param _idsLength Length of the ids array. + * @param _valuesLength Length of the values array. + */ + error ERC1155InvalidArrayLength(uint256 _idsLength, uint256 _valuesLength); + + /** + * @notice Emitted when `value` amount of tokens of type `id` are transferred from `from` to `to` by `operator`. + * @param _operator The address which initiated the transfer. + * @param _from The address which previously owned the token. + * @param _to The address which now owns the token. + * @param _id The token type being transferred. + * @param _value The amount of tokens transferred. + */ + event TransferSingle( + address indexed _operator, address indexed _from, address indexed _to, uint256 _id, uint256 _value + ); + + /** + * @notice Equivalent to multiple {TransferSingle} events, where `operator`, `from` and `to` are the same for all transfers. + * @param _operator The address which initiated the batch transfer. + * @param _from The address which previously owned the tokens. + * @param _to The address which now owns the tokens. + * @param _ids The token types being transferred. + * @param _values The amounts of tokens transferred. + */ + event TransferBatch( + address indexed _operator, address indexed _from, address indexed _to, uint256[] _ids, uint256[] _values + ); + + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc1155"); + + /** + * @dev ERC-8042 compliant storage struct for ERC-1155 token data. + * @custom:storage-location erc8042:erc1155 + */ + struct ERC1155Storage { + mapping(uint256 id => mapping(address account => uint256 balance)) balanceOf; + mapping(address account => mapping(address operator => bool)) isApprovedForAll; + } + + /** + * @notice Returns the ERC-1155 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC-1155 storage struct reference. + */ + function getStorage() internal pure returns (ERC1155Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Transfers `value` amount of token type `id` from `from` to `to`. + * @dev Emits a {TransferSingle} event. + * @param _from The address to transfer from. + * @param _to The address to transfer to. + * @param _id The token type to transfer. + * @param _value The amount to transfer. + * @param _data Additional data with no specified format. + */ + function safeTransferFrom(address _from, address _to, uint256 _id, uint256 _value, bytes calldata _data) external { + if (_to == address(0)) { + revert ERC1155InvalidReceiver(address(0)); + } + if (_from == address(0)) { + revert ERC1155InvalidSender(address(0)); + } + + ERC1155Storage storage s = getStorage(); + + /** + * Check authorization + */ + if (_from != msg.sender && !s.isApprovedForAll[_from][msg.sender]) { + revert ERC1155MissingApprovalForAll(msg.sender, _from); + } + + uint256 fromBalance = s.balanceOf[_id][_from]; + + if (fromBalance < _value) { + revert ERC1155InsufficientBalance(_from, fromBalance, _value, _id); + } + + unchecked { + s.balanceOf[_id][_from] = fromBalance - _value; + } + s.balanceOf[_id][_to] += _value; + + emit TransferSingle(msg.sender, _from, _to, _id, _value); + + if (_to.code.length > 0) { + try IERC1155Receiver(_to).onERC1155Received(msg.sender, _from, _id, _value, _data) returns ( + bytes4 response + ) { + if (response != IERC1155Receiver.onERC1155Received.selector) { + revert ERC1155InvalidReceiver(_to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + revert ERC1155InvalidReceiver(_to); + } else { + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } + } + } + + /** + * @notice Batched version of {safeTransferFrom}. + * @dev Emits a {TransferBatch} event. + * @param _from The address to transfer from. + * @param _to The address to transfer to. + * @param _ids The token types to transfer. + * @param _values The amounts to transfer. + * @param _data Additional data with no specified format. + */ + function safeBatchTransferFrom( + address _from, + address _to, + uint256[] calldata _ids, + uint256[] calldata _values, + bytes calldata _data + ) external { + if (_to == address(0)) { + revert ERC1155InvalidReceiver(address(0)); + } + if (_from == address(0)) { + revert ERC1155InvalidSender(address(0)); + } + if (_ids.length != _values.length) { + revert ERC1155InvalidArrayLength(_ids.length, _values.length); + } + + ERC1155Storage storage s = getStorage(); + + /** + * Check authorization + */ + if (_from != msg.sender && !s.isApprovedForAll[_from][msg.sender]) { + revert ERC1155MissingApprovalForAll(msg.sender, _from); + } + + for (uint256 i = 0; i < _ids.length; i++) { + uint256 id = _ids[i]; + uint256 value = _values[i]; + uint256 fromBalance = s.balanceOf[id][_from]; + + if (fromBalance < value) { + revert ERC1155InsufficientBalance(_from, fromBalance, value, id); + } + + unchecked { + s.balanceOf[id][_from] = fromBalance - value; + } + s.balanceOf[id][_to] += value; + } + + emit TransferBatch(msg.sender, _from, _to, _ids, _values); + + if (_to.code.length > 0) { + try IERC1155Receiver(_to).onERC1155BatchReceived(msg.sender, _from, _ids, _values, _data) returns ( + bytes4 response + ) { + if (response != IERC1155Receiver.onERC1155BatchReceived.selector) { + revert ERC1155InvalidReceiver(_to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + revert ERC1155InvalidReceiver(_to); + } else { + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } + } + } + + /** + * @notice Exports the function selectors of the ERC1155TransferFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC1155TransferFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.safeTransferFrom.selector, this.safeBatchTransferFrom.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC1155/Transfer/ERC1155TransferMod.sol b/cli/project-template/src/templates/token/ERC1155/Transfer/ERC1155TransferMod.sol new file mode 100644 index 00000000..bcfb0e70 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC1155/Transfer/ERC1155TransferMod.sol @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-1155 Token Receiver Interface + * @notice Interface that must be implemented by smart contracts in order to receive ERC-1155 token transfers. + */ +interface IERC1155Receiver { + /** + * @notice Handles the receipt of a single ERC-1155 token type. + * @dev This function is called at the end of a `safeTransferFrom` after the balance has been updated. + * + * IMPORTANT: To accept the transfer, this must return + * `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))` + * (i.e. 0xf23a6e61, or its own function selector). + * + * @param _operator The address which initiated the transfer (i.e. msg.sender). + * @param _from The address which previously owned the token. + * @param _id The ID of the token being transferred. + * @param _value The amount of tokens being transferred. + * @param _data Additional data with no specified format. + * @return `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))` if transfer is allowed. + */ + function onERC1155Received(address _operator, address _from, uint256 _id, uint256 _value, bytes calldata _data) + external + returns (bytes4); + + /** + * @notice Handles the receipt of multiple ERC-1155 token types. + * @dev This function is called at the end of a `safeBatchTransferFrom` after the balances have been updated. + * + * IMPORTANT: To accept the transfer(s), this must return + * `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))` + * (i.e. 0xbc197c81, or its own function selector). + * + * @param _operator The address which initiated the batch transfer (i.e. msg.sender). + * @param _from The address which previously owned the token. + * @param _ids An array containing ids of each token being transferred (order and length must match _values array). + * @param _values An array containing amounts of each token being transferred (order and length must match _ids array). + * @param _data Additional data with no specified format. + * @return `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))` if transfer is allowed. + */ + function onERC1155BatchReceived( + address _operator, + address _from, + uint256[] calldata _ids, + uint256[] calldata _values, + bytes calldata _data + ) external returns (bytes4); +} + +/** + * @title ERC-1155 Transfer Module + * @notice Provides internal transfer functionality for ERC-1155 tokens. + */ + +/** + * @notice Error indicating insufficient balance for a transfer. + * @param _sender Address attempting the transfer. + * @param _balance Current balance of the sender. + * @param _needed Amount required to complete the operation. + * @param _tokenId The token ID involved. + */ +error ERC1155InsufficientBalance(address _sender, uint256 _balance, uint256 _needed, uint256 _tokenId); + +/** + * @notice Error indicating the sender address is invalid. + * @param _sender Invalid sender address. + */ +error ERC1155InvalidSender(address _sender); + +/** + * @notice Error indicating the receiver address is invalid. + * @param _receiver Invalid receiver address. + */ +error ERC1155InvalidReceiver(address _receiver); + +/** + * @notice Error indicating missing approval for an operator. + * @param _operator Address attempting the operation. + * @param _owner The token owner. + */ +error ERC1155MissingApprovalForAll(address _operator, address _owner); + +/** + * @notice Error indicating array length mismatch in batch operations. + * @param _idsLength Length of the ids array. + * @param _valuesLength Length of the values array. + */ +error ERC1155InvalidArrayLength(uint256 _idsLength, uint256 _valuesLength); + +/** + * @notice Emitted when `value` amount of tokens of type `id` are transferred from `from` to `to` by `operator`. + * @param _operator The address which initiated the transfer. + * @param _from The address which previously owned the token. + * @param _to The address which now owns the token. + * @param _id The token type being transferred. + * @param _value The amount of tokens transferred. + */ +event TransferSingle( + address indexed _operator, address indexed _from, address indexed _to, uint256 _id, uint256 _value +); + +/** + * @notice Equivalent to multiple {TransferSingle} events, where `operator`, `from` and `to` are the same for all transfers. + * @param _operator The address which initiated the batch transfer. + * @param _from The address which previously owned the tokens. + * @param _to The address which now owns the tokens. + * @param _ids The token types being transferred. + * @param _values The amounts of tokens transferred. + */ +event TransferBatch( + address indexed _operator, address indexed _from, address indexed _to, uint256[] _ids, uint256[] _values +); + +/** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc1155"); + +/** + * @dev ERC-8042 compliant storage struct for ERC-1155 token data. + * @custom:storage-location erc8042:erc1155 + */ +struct ERC1155Storage { + mapping(uint256 id => mapping(address account => uint256 balance)) balanceOf; + mapping(address account => mapping(address operator => bool)) isApprovedForAll; +} + +/** + * @notice Returns the ERC-1155 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC-1155 storage struct reference. + */ +function getStorage() pure returns (ERC1155Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Safely transfers a single token type from one address to another. + * @dev Validates ownership, approval, and receiver address before updating balances. + * Performs ERC1155Receiver validation if recipient is a contract (safe transfer). + * Complies with EIP-1155 safe transfer requirements. + * @param _from The address to transfer from. + * @param _to The address to transfer to. + * @param _id The token type to transfer. + * @param _value The amount of tokens to transfer. + * @param _operator The address initiating the transfer (may be owner or approved operator). + */ +function safeTransferFrom(address _from, address _to, uint256 _id, uint256 _value, address _operator) { + if (_from == address(0)) { + revert ERC1155InvalidSender(address(0)); + } + if (_to == address(0)) { + revert ERC1155InvalidReceiver(address(0)); + } + + ERC1155Storage storage s = getStorage(); + + /** + * Check authorization + */ + if (_from != _operator && !s.isApprovedForAll[_from][_operator]) { + revert ERC1155MissingApprovalForAll(_operator, _from); + } + + uint256 fromBalance = s.balanceOf[_id][_from]; + + if (fromBalance < _value) { + revert ERC1155InsufficientBalance(_from, fromBalance, _value, _id); + } + + unchecked { + s.balanceOf[_id][_from] = fromBalance - _value; + } + s.balanceOf[_id][_to] += _value; + + emit TransferSingle(_operator, _from, _to, _id, _value); + + if (_to.code.length > 0) { + try IERC1155Receiver(_to).onERC1155Received(_operator, _from, _id, _value, "") returns (bytes4 response) { + if (response != IERC1155Receiver.onERC1155Received.selector) { + revert ERC1155InvalidReceiver(_to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + revert ERC1155InvalidReceiver(_to); + } else { + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } + } +} + +/** + * @notice Safely transfers multiple token types from one address to another in a single transaction. + * @dev Validates ownership, approval, and receiver address before updating balances for each token type. + * Performs ERC1155Receiver validation if recipient is a contract (safe transfer). + * Complies with EIP-1155 safe transfer requirements. + * @param _from The address to transfer from. + * @param _to The address to transfer to. + * @param _ids The token types to transfer. + * @param _values The amounts of tokens to transfer for each type. + * @param _operator The address initiating the transfer (may be owner or approved operator). + */ +function safeBatchTransferFrom( + address _from, + address _to, + uint256[] memory _ids, + uint256[] memory _values, + address _operator +) { + if (_from == address(0)) { + revert ERC1155InvalidSender(address(0)); + } + if (_to == address(0)) { + revert ERC1155InvalidReceiver(address(0)); + } + if (_ids.length != _values.length) { + revert ERC1155InvalidArrayLength(_ids.length, _values.length); + } + + ERC1155Storage storage s = getStorage(); + + /** + * Check authorization + */ + if (_from != _operator && !s.isApprovedForAll[_from][_operator]) { + revert ERC1155MissingApprovalForAll(_operator, _from); + } + + for (uint256 i = 0; i < _ids.length; i++) { + uint256 id = _ids[i]; + uint256 value = _values[i]; + uint256 fromBalance = s.balanceOf[id][_from]; + + if (fromBalance < value) { + revert ERC1155InsufficientBalance(_from, fromBalance, value, id); + } + + unchecked { + s.balanceOf[id][_from] = fromBalance - value; + } + s.balanceOf[id][_to] += value; + } + + emit TransferBatch(_operator, _from, _to, _ids, _values); + + if (_to.code.length > 0) { + try IERC1155Receiver(_to).onERC1155BatchReceived(_operator, _from, _ids, _values, "") returns ( + bytes4 response + ) { + if (response != IERC1155Receiver.onERC1155BatchReceived.selector) { + revert ERC1155InvalidReceiver(_to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + revert ERC1155InvalidReceiver(_to); + } else { + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } + } +} diff --git a/cli/project-template/src/templates/token/ERC20/Approve/ERC20ApproveFacet.sol b/cli/project-template/src/templates/token/ERC20/Approve/ERC20ApproveFacet.sol new file mode 100644 index 00000000..a4914192 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC20/Approve/ERC20ApproveFacet.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract ERC20ApproveFacet { + /** + * @notice Thrown when the spender address is invalid (e.g., zero address). + * @param _spender Invalid spender address. + */ + error ERC20InvalidSpender(address _spender); + + /** + * @notice Emitted when an approval is made for a spender by an owner. + * @param _owner The address granting the allowance. + * @param _spender The address receiving the allowance. + * @param _value The amount approved. + */ + event Approval(address indexed _owner, address indexed _spender, uint256 _value); + + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc20"); + + /** + * @dev ERC-8042 compliant storage struct for ERC20 token data. + * @custom:storage-location erc8042:erc20 + */ + struct ERC20Storage { + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; + mapping(address owner => mapping(address spender => uint256 allowance)) allowance; + } + + /** + * @notice Returns the ERC20 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC20 storage struct reference. + */ + function getStorage() internal pure returns (ERC20Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Approves a spender to transfer up to a certain amount of tokens on behalf of the caller. + * @dev Emits an {Approval} event. + * @param _spender The address approved to spend tokens. + * @param _value The number of tokens to approve. + * @return True if the approval was successful. + */ + function approve(address _spender, uint256 _value) external returns (bool) { + ERC20Storage storage s = getStorage(); + if (_spender == address(0)) { + revert ERC20InvalidSpender(address(0)); + } + s.allowance[msg.sender][_spender] = _value; + emit Approval(msg.sender, _spender, _value); + return true; + } + + /** + * @notice Exports the function selectors of the ERC20ApproveFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC20ApproveFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.approve.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC20/Approve/ERC20ApproveMod.sol b/cli/project-template/src/templates/token/ERC20/Approve/ERC20ApproveMod.sol new file mode 100644 index 00000000..de4b310e --- /dev/null +++ b/cli/project-template/src/templates/token/ERC20/Approve/ERC20ApproveMod.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Thrown when the spender address is invalid (e.g., zero address). + * @param _spender Invalid spender address. + */ +error ERC20InvalidSpender(address _spender); + +/** + * @notice Emitted when an approval is made for a spender by an owner. + * @param _owner The address granting the allowance. + * @param _spender The address receiving the allowance. + * @param _value The amount approved. + */ +event Approval(address indexed _owner, address indexed _spender, uint256 _value); + +/** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc20"); + +/** + * @dev ERC-8042 compliant storage struct for ERC20 token data. + * @custom:storage-location erc8042:erc20 + */ +struct ERC20Storage { + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; + mapping(address owner => mapping(address spender => uint256 allowance)) allowance; +} + +/** + * @notice Returns the ERC20 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC20 storage struct reference. + */ +function getStorage() pure returns (ERC20Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Approves a spender to transfer tokens on behalf of the caller. + * @dev Sets the allowance for the spender. + * @param _spender The address to approve for spending. + * @param _value The amount of tokens to approve. + * @return True if the approval was successful. + */ +function approve(address _spender, uint256 _value) returns (bool) { + if (_spender == address(0)) { + revert ERC20InvalidSpender(address(0)); + } + ERC20Storage storage s = getStorage(); + s.allowance[msg.sender][_spender] = _value; + emit Approval(msg.sender, _spender, _value); + return true; +} diff --git a/cli/project-template/src/templates/token/ERC20/Bridgeable/ERC20BridgeableFacet.sol b/cli/project-template/src/templates/token/ERC20/Bridgeable/ERC20BridgeableFacet.sol new file mode 100644 index 00000000..634113e1 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC20/Bridgeable/ERC20BridgeableFacet.sol @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ +/** + * @title ERC20Bridgeable — ERC-7802 Implementation Facet + * @notice Provides functions and storage layout for ERC20-Bridgeable token logic. + * @dev Uses ERC-8042 for storage location standardization and ERC-6093 for error conventions + */ + +contract ERC20BridgeableFacet { + /** + * @notice Revert when a provided receiver is invalid(e.g,zero address) . + * @param _receiver The invalid receiver address. + */ + error ERC20InvalidReceiver(address _receiver); + + /** + * @notice Thrown when the sender address is invalid (e.g., zero address). + * @param _sender The invalid sender address. + */ + error ERC20InvalidSender(address _sender); + + /** + * @notice Revert when caller is not a trusted bridge. + * @param _caller The unauthorized caller. + */ + error ERC20InvalidBridgeAccount(address _caller); + + /** + * @notice Revert when caller address is invalid. + */ + /** + * @param _caller is the invalid address. + */ + error ERC20InvalidCallerAddress(address _caller); + + /** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ + error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + + error ERC20InsufficientBalance(address _from, uint256 _accountBalance, uint256 _value); + + /** + * @notice Emitted when tokens are minted via a cross-chain bridge. + * @param _to The recipient of minted tokens. + * @param _amount The amount minted. + * @param _sender The bridge account that triggered the mint (msg.sender). + */ + event CrosschainMint(address indexed _to, uint256 _amount, address indexed _sender); + + /** + * @notice Emitted when a crosschain transfer burns tokens. + * @param _from Address of the account tokens are being burned from. + * @param _amount Amount of tokens burned. + * @param _sender Address of the caller (msg.sender) who invoked crosschainBurn. + */ + event CrosschainBurn(address indexed _from, uint256 _amount, address indexed _sender); + + /** + * @notice Emitted when tokens are transferred between two addresses. + * @param _from Address sending the tokens. + * @param _to Address receiving the tokens. + * @param _value Amount of tokens transferred. + */ + event Transfer(address indexed _from, address indexed _to, uint256 _value); + + /** + * ----------------------------------------------------------------------- + * ERC20 integration (re-uses ERC20Facet storage layout) + * ----------------------------------------------------------------------- + */ + + /** + * @notice Storage slot for ERC-20 token using ERC8042 for storage location standardization + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant ERC20_STORAGE_POSITION = keccak256("erc20"); + + /** + * @dev ERC-8042 compliant storage struct for ERC20 token data. + * @custom:storage-location erc8042:erc20 + */ + struct ERC20Storage { + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; + } + /** + * @notice Returns the ERC20 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC20 storage struct reference. + */ + + function getERC20Storage() internal pure returns (ERC20Storage storage s) { + bytes32 position = ERC20_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * ----------------------------------------------------------------------- + * AccessControl integration (re-uses AccessControlFacet storage layout) + * ----------------------------------------------------------------------- + */ + + /** + * @notice Storage slot identifier. + */ + bytes32 constant ACCESS_STORAGE_POSITION = keccak256("compose.accesscontrol"); + + /** + * @notice storage struct for the AccessControl. + * @custom:storage-location erc8042:compose.accesscontrol + */ + struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + } + + /** + * @notice helper to return AccessControlStorage at its diamond slot + */ + function getAccessControlStorage() internal pure returns (AccessControlStorage storage s) { + bytes32 position = ACCESS_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Cross-chain mint — callable only by an address having the `trusted-bridge` role. + * @param _account The account to mint tokens to. + * @param _value The amount to mint. + */ + function crosschainMint(address _account, uint256 _value) external { + ERC20Storage storage erc20Storage = getERC20Storage(); + + AccessControlStorage storage acs = getAccessControlStorage(); + + /** + * authorize: caller must have the trusted-bridge role + */ + if (!acs.hasRole[msg.sender]["trusted-bridge"]) { + revert AccessControlUnauthorizedAccount(msg.sender, "trusted-bridge"); + } + + if (_account == address(0)) { + revert ERC20InvalidReceiver(address(0)); + } + + unchecked { + erc20Storage.totalSupply += _value; + erc20Storage.balanceOf[_account] += _value; + } + emit Transfer(address(0), _account, _value); + emit CrosschainMint(_account, _value, msg.sender); + } + + /** + * @notice Cross-chain burn — callable only by an address having the `trusted-bridge` role. + * @param _from The account to burn tokens from. + * @param _value The amount to burn. + */ + function crosschainBurn(address _from, uint256 _value) external { + ERC20Storage storage erc20Storage = getERC20Storage(); + + AccessControlStorage storage acs = getAccessControlStorage(); + + /** + * authorize: caller must have the trusted-bridge role + */ + if (!acs.hasRole[msg.sender]["trusted-bridge"]) { + revert AccessControlUnauthorizedAccount(msg.sender, "trusted-bridge"); + } + if (_from == address(0)) { + revert ERC20InvalidReceiver(address(0)); + } + + uint256 accountBalance = erc20Storage.balanceOf[_from]; + + if (accountBalance < _value) { + revert ERC20InsufficientBalance(_from, accountBalance, _value); + } + + unchecked { + erc20Storage.totalSupply -= _value; + erc20Storage.balanceOf[_from] -= _value; + } + + emit Transfer(_from, address(0), _value); + emit CrosschainBurn(_from, _value, msg.sender); + } + + /** + * @notice Internal check to check if the bridge (caller) is trusted. + * @dev Reverts if caller is zero or not in the AccessControl `trusted-bridge` role. + * @param _caller The address to validate + */ + function checkTokenBridge(address _caller) external view { + AccessControlStorage storage acs = getAccessControlStorage(); + + if (_caller == address(0)) { + revert ERC20InvalidBridgeAccount(address(0)); + } + + if (!acs.hasRole[_caller]["trusted-bridge"]) { + revert ERC20InvalidBridgeAccount(_caller); + } + } + + /** + * @notice Exports the function selectors of the ERC20BridgeableFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC20BridgeableFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.crosschainMint.selector, this.crosschainBurn.selector, this.checkTokenBridge.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC20/Bridgeable/ERC20BridgeableMod.sol b/cli/project-template/src/templates/token/ERC20/Bridgeable/ERC20BridgeableMod.sol new file mode 100644 index 00000000..2ebf36ba --- /dev/null +++ b/cli/project-template/src/templates/token/ERC20/Bridgeable/ERC20BridgeableMod.sol @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/* + * @title LibERC20Bridgeable — ERC-7802 Library + * @notice Provides internal functions and storage layout for ERC-7802 token logic. + * @dev Uses ERC-8042 for storage location standardization and ERC-6093 for error conventions + */ + +/** + * @notice Revert when a provided receiver is invalid(e.g,zero address) . + * @param _receiver The invalid receiver address. + */ +error ERC20InvalidReceiver(address _receiver); + +/** + * @notice Thrown when the sender address is invalid (e.g., zero address). + * @param _sender The invalid sender address. + */ +error ERC20InvalidSender(address _sender); + +/** + * @notice Revert when caller is not a trusted bridge. + * @param _caller The unauthorized caller. + */ +error ERC20InvalidBridgeAccount(address _caller); + +/** + * @notice Revert when caller address is invalid. + */ +/** + * @param _caller is the invalid address. + */ +error ERC20InvalidCallerAddress(address _caller); + +/** + * @notice Thrown when the account does not have a specific role. + * @param _role The role that the account does not have. + * @param _account The account that does not have the role. + */ +error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + +error ERC20InsufficientBalance(address _from, uint256 _accountBalance, uint256 _value); + +/** + * @notice Emitted when tokens are minted via a cross-chain bridge. + * @param _to The recipient of minted tokens. + * @param _amount The amount minted. + * @param _sender The bridge account that triggered the mint (msg.sender). + */ +event CrosschainMint(address indexed _to, uint256 _amount, address indexed _sender); + +/** + * @notice Emitted when a crosschain transfer burns tokens. + * @param _from Address of the account tokens are being burned from. + * @param _amount Amount of tokens burned. + * @param _sender Address of the caller (msg.sender) who invoked crosschainBurn. + */ +event CrosschainBurn(address indexed _from, uint256 _amount, address indexed _sender); + +/** + * @notice Emitted when tokens are transferred between two addresses. + * @param _from Address sending the tokens. + * @param _to Address receiving the tokens. + * @param _value Amount of tokens transferred. + */ +event Transfer(address indexed _from, address indexed _to, uint256 _value); + +/* + * ----------------------------------------------------------------------- + * ERC20 integration (re-uses ERC20Facet storage layout) + * ----------------------------------------------------------------------- + */ + +/* + * @notice Storage slot for ERC-20 token using ERC8042 for storage location standardization + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ +bytes32 constant ERC20_STORAGE_POSITION = keccak256("erc20"); + +/** + * @dev ERC-8042 compliant storage struct for ERC20 token data. + * @custom:storage-location erc8042:erc20 + */ +struct ERC20Storage { + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; +} +/** + * @notice Returns the ERC20 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC20 storage struct reference. + */ + +function getERC20Storage() pure returns (ERC20Storage storage s) { + bytes32 position = ERC20_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/* + * ----------------------------------------------------------------------- + * AccessControl integration (re-uses AccessControlFacet storage layout) + * ----------------------------------------------------------------------- + */ + +/* + * @notice Storage slot identifier. + */ +bytes32 constant ACCESS_STORAGE_POSITION = keccak256("compose.accesscontrol"); + +/** + * @notice storage struct for the AccessControl. + * @custom:storage-location erc8042:compose.accesscontrol + */ +struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; +} + +/** + * @notice helper to return AccessControlStorage at its diamond slot + */ +function getAccessControlStorage() pure returns (AccessControlStorage storage s) { + bytes32 position = ACCESS_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Cross-chain mint — callable only by an address having the `trusted-bridge` role. + * @param _account The account to mint tokens to. + * @param _value The amount to mint. + */ +function crosschainMint(address _account, uint256 _value) { + ERC20Storage storage erc20Storage = getERC20Storage(); + + AccessControlStorage storage acs = getAccessControlStorage(); + + /** + * authorize: caller must have the trusted-bridge role + */ + if (!acs.hasRole[msg.sender]["trusted-bridge"]) { + revert AccessControlUnauthorizedAccount(msg.sender, "trusted-bridge"); + } + + if (_account == address(0)) { + revert ERC20InvalidReceiver(address(0)); + } + + unchecked { + erc20Storage.totalSupply += _value; + erc20Storage.balanceOf[_account] += _value; + } + + emit Transfer(address(0), _account, _value); + emit CrosschainMint(_account, _value, msg.sender); +} + +/** + * @notice Cross-chain burn — callable only by an address having the `trusted-bridge` role. + * @param _from The account to burn tokens from. + * @param _value The amount to burn. + */ +function crosschainBurn(address _from, uint256 _value) { + ERC20Storage storage erc20Storage = getERC20Storage(); + + AccessControlStorage storage acs = getAccessControlStorage(); + + /** + * authorize: caller must have the trusted-bridge role + */ + if (!acs.hasRole[msg.sender]["trusted-bridge"]) { + revert AccessControlUnauthorizedAccount(msg.sender, "trusted-bridge"); + } + + if (_from == address(0)) { + revert ERC20InvalidReceiver(address(0)); + } + + uint256 accountBalance = erc20Storage.balanceOf[_from]; + + if (accountBalance < _value) { + revert ERC20InsufficientBalance(_from, accountBalance, _value); + } + + unchecked { + erc20Storage.totalSupply -= _value; + erc20Storage.balanceOf[_from] -= _value; + } + emit Transfer(_from, address(0), _value); + emit CrosschainBurn(_from, _value, msg.sender); +} + +/** + * @notice Internal check to check if the bridge (caller) is trusted. + * @dev Reverts if caller is zero or not in the AccessControl `trusted-bridge` role. + * @param _caller The address to validate + */ +function checkTokenBridge(address _caller) view { + AccessControlStorage storage acs = getAccessControlStorage(); + + if (_caller == address(0)) { + revert ERC20InvalidBridgeAccount(address(0)); + } + + if (!acs.hasRole[_caller]["trusted-bridge"]) { + revert ERC20InvalidBridgeAccount(_caller); + } +} diff --git a/cli/project-template/src/templates/token/ERC20/Burn/ERC20BurnFacet.sol b/cli/project-template/src/templates/token/ERC20/Burn/ERC20BurnFacet.sol new file mode 100644 index 00000000..610a0b02 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC20/Burn/ERC20BurnFacet.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/** + * Compose + * https://compose.diamonds + */ + +contract ERC20BurnFacet { + /** + * @notice Thrown when an account has insufficient balance for a transfer or burn. + * @param _sender Address attempting the transfer. + * @param _balance Current balance of the sender. + * @param _needed Amount required to complete the operation. + */ + error ERC20InsufficientBalance(address _sender, uint256 _balance, uint256 _needed); + + /** + * @notice Thrown when a spender tries to use more than the approved allowance. + * @param _spender Address attempting to spend. + * @param _allowance Current allowance for the spender. + * @param _needed Amount required to complete the operation. + */ + error ERC20InsufficientAllowance(address _spender, uint256 _allowance, uint256 _needed); + + /** + * @notice Emitted when tokens are transferred between two addresses. + * @param _from Address sending the tokens. + * @param _to Address receiving the tokens. + * @param _value Amount of tokens transferred. + */ + event Transfer(address indexed _from, address indexed _to, uint256 _value); + + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc20"); + + /** + * @dev ERC-8042 compliant storage struct for ERC20 token data. + * @custom:storage-location erc8042:erc20 + */ + struct ERC20Storage { + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; + mapping(address owner => mapping(address spender => uint256 allowance)) allowance; + } + + /** + * @notice Returns the ERC20 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC20 storage struct reference. + */ + function getStorage() internal pure returns (ERC20Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Burns (destroys) a specific amount of tokens from the caller's balance. + * @dev Emits a {Transfer} event to the zero address. + * @param _value The amount of tokens to burn. + */ + function burn(uint256 _value) external { + ERC20Storage storage s = getStorage(); + uint256 balance = s.balanceOf[msg.sender]; + if (balance < _value) { + revert ERC20InsufficientBalance(msg.sender, balance, _value); + } + unchecked { + s.balanceOf[msg.sender] = balance - _value; + s.totalSupply -= _value; + } + emit Transfer(msg.sender, address(0), _value); + } + + /** + * @notice Burns tokens from another account, deducting from the caller's allowance. + * @dev Emits a {Transfer} event to the zero address. + * @param _account The address whose tokens will be burned. + * @param _value The amount of tokens to burn. + */ + function burnFrom(address _account, uint256 _value) external { + ERC20Storage storage s = getStorage(); + uint256 currentAllowance = s.allowance[_account][msg.sender]; + if (currentAllowance < _value) { + revert ERC20InsufficientAllowance(msg.sender, currentAllowance, _value); + } + uint256 balance = s.balanceOf[_account]; + if (balance < _value) { + revert ERC20InsufficientBalance(_account, balance, _value); + } + unchecked { + if (currentAllowance != type(uint256).max) { + s.allowance[_account][msg.sender] = currentAllowance - _value; + } + s.balanceOf[_account] = balance - _value; + s.totalSupply -= _value; + } + emit Transfer(_account, address(0), _value); + } + + /** + * @notice Exports the function selectors of the ERC20BurnFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC20BurnFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.burn.selector, this.burnFrom.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC20/Burn/ERC20BurnMod.sol b/cli/project-template/src/templates/token/ERC20/Burn/ERC20BurnMod.sol new file mode 100644 index 00000000..f3f15c42 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC20/Burn/ERC20BurnMod.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/* + * @title ERC20BurnMod + * @notice Provides internal functions for burning ERC-20 tokens. + * @dev Uses ERC-8042 for storage location standardization and ERC-6093 for error conventions. + */ + +/** + * @notice Thrown when a sender attempts to transfer or burn more tokens than their balance. + * @param _sender The address attempting the transfer or burn. + * @param _balance The sender's current balance. + * @param _needed The amount required to complete the operation. + */ +error ERC20InsufficientBalance(address _sender, uint256 _balance, uint256 _needed); + +/** + * @notice Thrown when the sender address is invalid (e.g., zero address). + * @param _sender The invalid sender address. + */ +error ERC20InvalidSender(address _sender); + +/** + * @notice Emitted when tokens are transferred between addresses. + * @param _from The address tokens are transferred from. + * @param _to The address tokens are transferred to. + * @param _value The amount of tokens transferred. + */ +event Transfer(address indexed _from, address indexed _to, uint256 _value); + +/* + * @notice Storage slot identifier, defined using keccak256 hash of the library diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc20"); + +/** + * @notice ERC-20 storage layout using the ERC-8042 standard. + * @custom:storage-location erc8042:erc20 + */ +struct ERC20Storage { + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; +} + +/** + * @notice Returns a pointer to the ERC-20 storage struct. + * @dev Uses inline assembly to bind the storage struct to the fixed storage position. + * @return s The ERC-20 storage struct. + */ +function getStorage() pure returns (ERC20Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Burns tokens from a specified address. + * @dev Decreases both total supply and the sender's balance. + * This module does not perform allowance checks. Ensure proper allowance or authorization validation before calling this function. + * @param _account The address whose tokens will be burned. + * @param _value The number of tokens to burn. + */ +function burn(address _account, uint256 _value) { + ERC20Storage storage s = getStorage(); + if (_account == address(0)) { + revert ERC20InvalidSender(address(0)); + } + uint256 accountBalance = s.balanceOf[_account]; + if (accountBalance < _value) { + revert ERC20InsufficientBalance(_account, accountBalance, _value); + } + unchecked { + s.balanceOf[_account] = accountBalance - _value; + s.totalSupply -= _value; + } + emit Transfer(_account, address(0), _value); +} diff --git a/cli/project-template/src/templates/token/ERC20/Data/ERC20DataFacet.sol b/cli/project-template/src/templates/token/ERC20/Data/ERC20DataFacet.sol new file mode 100644 index 00000000..ad2b7560 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC20/Data/ERC20DataFacet.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract ERC20DataFacet { + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc20"); + + /** + * @dev ERC-8042 compliant storage struct for ERC20 token data. + * @custom:storage-location erc8042:erc20 + */ + struct ERC20Storage { + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; + mapping(address owner => mapping(address spender => uint256 allowance)) allowance; + } + + /** + * @notice Returns the ERC20 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC20 storage struct reference. + */ + function getStorage() internal pure returns (ERC20Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns the total supply of tokens. + * @return The total token supply. + */ + function totalSupply() external view returns (uint256) { + return getStorage().totalSupply; + } + + /** + * @notice Returns the balance of a specific account. + * @param _account The address of the account. + * @return The account balance. + */ + function balanceOf(address _account) external view returns (uint256) { + return getStorage().balanceOf[_account]; + } + + /** + * @notice Returns the remaining number of tokens that a spender is allowed to spend on behalf of an owner. + * @param _owner The address of the token owner. + * @param _spender The address of the spender. + * @return The remaining allowance. + */ + function allowance(address _owner, address _spender) external view returns (uint256) { + return getStorage().allowance[_owner][_spender]; + } + + /** + * @notice Exports the function selectors of the ERC20Data facet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC20DataFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.totalSupply.selector, this.balanceOf.selector, this.allowance.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC20/Metadata/ERC20MetadataFacet.sol b/cli/project-template/src/templates/token/ERC20/Metadata/ERC20MetadataFacet.sol new file mode 100644 index 00000000..1c3cb660 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC20/Metadata/ERC20MetadataFacet.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract ERC20MetadataFacet { + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc20.metadata"); + + /** + * @dev ERC-8042 compliant storage struct for ERC20 token data. + * @custom:storage-location erc8042:erc20.metadata + */ + struct ERC20MetadataStorage { + string name; + string symbol; + uint8 decimals; + } + + /** + * @notice Returns the ERC20 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC20 storage struct reference. + */ + function getStorage() internal pure returns (ERC20MetadataStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns the name of the token. + * @return The token name. + */ + function name() external view returns (string memory) { + return getStorage().name; + } + + /** + * @notice Returns the symbol of the token. + * @return The token symbol. + */ + function symbol() external view returns (string memory) { + return getStorage().symbol; + } + + /** + * @notice Returns the number of decimals used for token precision. + * @return The number of decimals. + */ + function decimals() external view returns (uint8) { + return getStorage().decimals; + } + + /** + * @notice Exports the function selectors of the ERC20Metadata facet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC20MetadataFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.name.selector, this.symbol.selector, this.decimals.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC20/Metadata/ERC20MetadataMod.sol b/cli/project-template/src/templates/token/ERC20/Metadata/ERC20MetadataMod.sol new file mode 100644 index 00000000..d7c879bb --- /dev/null +++ b/cli/project-template/src/templates/token/ERC20/Metadata/ERC20MetadataMod.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc20.metadata"); + +/** + * @dev ERC-8042 compliant storage struct for ERC20 token data. + * @custom:storage-location erc8042:erc20.metadata + */ +struct ERC20MetadataStorage { + string name; + string symbol; + uint8 decimals; +} + +/** + * @notice Returns the ERC20 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC20 storage struct reference. + */ +function getStorage() pure returns (ERC20MetadataStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Sets the metadata for the ERC20 token. + * @param _name The name of the token. + * @param _symbol The symbol of the token. + * @param _decimals The number of decimals used for token precision. + */ +function setMetadata(string memory _name, string memory _symbol, uint8 _decimals) { + ERC20MetadataStorage storage s = getStorage(); + s.name = _name; + s.symbol = _symbol; + s.decimals = _decimals; +} + diff --git a/cli/project-template/src/templates/token/ERC20/Mint/ERC20MintMod.sol b/cli/project-template/src/templates/token/ERC20/Mint/ERC20MintMod.sol new file mode 100644 index 00000000..5e0ed456 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC20/Mint/ERC20MintMod.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/* + * @title ERC20MintMod + * @notice Provides internal functions for minting ERC-20 tokens. + * @dev Uses ERC-8042 for storage location standardization and ERC-6093 for error conventions. + */ + +/** + * @notice Thrown when the receiver address is invalid (e.g., zero address). + * @param _receiver The invalid receiver address. + */ +error ERC20InvalidReceiver(address _receiver); + +/** + * @notice Emitted when tokens are transferred between addresses. + * @param _from The address tokens are transferred from. + * @param _to The address tokens are transferred to. + * @param _value The amount of tokens transferred. + */ +event Transfer(address indexed _from, address indexed _to, uint256 _value); + +/* + * @notice Storage slot identifier, defined using keccak256 hash of the library diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc20"); + +/* + * @notice ERC-20 storage layout using the ERC-8042 standard. + * @custom:storage-location erc8042:erc20 + */ +struct ERC20Storage { + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; +} + +/** + * @notice Returns a pointer to the ERC-20 storage struct. + * @dev Uses inline assembly to bind the storage struct to the fixed storage position. + * @return s The ERC-20 storage struct. + */ +function getStorage() pure returns (ERC20Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Mints new tokens to a specified address. + * @dev Increases both total supply and the recipient's balance. + * @param _account The address receiving the newly minted tokens. + * @param _value The number of tokens to mint. + */ +function mint(address _account, uint256 _value) { + ERC20Storage storage s = getStorage(); + if (_account == address(0)) { + revert ERC20InvalidReceiver(address(0)); + } + s.totalSupply += _value; + s.balanceOf[_account] += _value; + emit Transfer(address(0), _account, _value); +} diff --git a/cli/project-template/src/templates/token/ERC20/Permit/ERC20PermitFacet.sol b/cli/project-template/src/templates/token/ERC20/Permit/ERC20PermitFacet.sol new file mode 100644 index 00000000..cba8b74d --- /dev/null +++ b/cli/project-template/src/templates/token/ERC20/Permit/ERC20PermitFacet.sol @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC20PermitFacet + * @notice Facet for ERC20 permit functionality + * @dev Implements EIP-2612: https://eips.ethereum.org/EIPS/eip-2612 + */ +contract ERC20PermitFacet { + /** + * @notice Thrown when a permit signature is invalid or expired. + * @param _owner The address that signed the permit. + * @param _spender The address that was approved. + * @param _value The amount that was approved. + * @param _deadline The deadline for the permit. + * @param _v The recovery byte of the signature. + * @param _r The r value of the signature. + * @param _s The s value of the signature. + */ + error ERC2612InvalidSignature( + address _owner, address _spender, uint256 _value, uint256 _deadline, uint8 _v, bytes32 _r, bytes32 _s + ); + + /** + * @notice Thrown when the spender address is invalid (e.g., zero address). + * @param _spender Invalid spender address. + */ + error ERC20InvalidSpender(address _spender); + + /** + * @notice Emitted when an approval is made for a spender by an owner. + * @param _owner The address granting the allowance. + * @param _spender The address receiving the allowance. + * @param _value The amount approved. + */ + event Approval(address indexed _owner, address indexed _spender, uint256 _value); + + bytes32 constant ERC20_METADATA_STORAGE_POSITION = keccak256("erc20.metadata"); + + /** + * @custom:storage-location erc8042:erc20.metadata + */ + struct ERC20MetadataStorage { + string name; + } + + function getERC20MetadataStorage() internal pure returns (ERC20MetadataStorage storage s) { + bytes32 position = ERC20_METADATA_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + bytes32 constant ERC20_STORAGE_POSITION = keccak256("erc20"); + + /** + * @custom:storage-location erc8042:erc20 + */ + struct ERC20Storage { + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; + mapping(address owner => mapping(address spender => uint256 allowance)) allowance; + } + + function getERC20Storage() internal pure returns (ERC20Storage storage s) { + bytes32 position = ERC20_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + bytes32 constant STORAGE_POSITION = keccak256("nonces"); + + /** + * @custom:storage-location erc8042:nonces + */ + struct NoncesStorage { + mapping(address owner => uint256) nonces; + } + + function getStorage() internal pure returns (NoncesStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns the current nonce for an owner. + * @dev This value changes each time a permit is used. + * @param _owner The address of the owner. + * @return The current nonce. + */ + function nonces(address _owner) external view returns (uint256) { + return getStorage().nonces[_owner]; + } + + /** + * @notice Returns the domain separator used in the encoding of the signature for {permit}. + * @dev This value is unique to a contract and chain ID combination to prevent replay attacks. + * @return The domain separator. + */ + function DOMAIN_SEPARATOR() external view returns (bytes32) { + return keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(getERC20MetadataStorage().name)), + keccak256("1"), + block.chainid, + address(this) + ) + ); + } + + /** + * @notice Sets the allowance for a spender via a signature. + * @dev This function implements EIP-2612 permit functionality. + * @param _owner The address of the token owner. + * @param _spender The address of the spender. + * @param _value The amount of tokens to approve. + * @param _deadline The deadline for the permit (timestamp). + * @param _v The recovery byte of the signature. + * @param _r The r value of the signature. + * @param _s The s value of the signature. + */ + function permit( + address _owner, + address _spender, + uint256 _value, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external { + if (_spender == address(0)) { + revert ERC20InvalidSpender(address(0)); + } + if (block.timestamp > _deadline) { + revert ERC2612InvalidSignature(_owner, _spender, _value, _deadline, _v, _r, _s); + } + + NoncesStorage storage s = getStorage(); + ERC20Storage storage erc20Storage = getERC20Storage(); + uint256 currentNonce = s.nonces[_owner]; + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + _owner, + _spender, + _value, + currentNonce, + _deadline + ) + ); + + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(getERC20MetadataStorage().name)), + keccak256("1"), + block.chainid, + address(this) + ) + ), + structHash + ) + ); + + address signer = ecrecover(hash, _v, _r, _s); + if (signer != _owner || signer == address(0)) { + revert ERC2612InvalidSignature(_owner, _spender, _value, _deadline, _v, _r, _s); + } + + erc20Storage.allowance[_owner][_spender] = _value; + s.nonces[_owner] = currentNonce + 1; + emit Approval(_owner, _spender, _value); + } + + /** + * @notice Exports the function selectors of the ERC20PermitFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC20PermitFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.nonces.selector, this.DOMAIN_SEPARATOR.selector, this.permit.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC20/Permit/ERC20PermitMod.sol b/cli/project-template/src/templates/token/ERC20/Permit/ERC20PermitMod.sol new file mode 100644 index 00000000..8f7a633c --- /dev/null +++ b/cli/project-template/src/templates/token/ERC20/Permit/ERC20PermitMod.sol @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/* + * @title LibERC20Permit — Library for ERC-2612 Permit Logic + * @notice Library for self-contained ERC-2612 permit and domain separator logic and storage + * @dev Does not import anything. Designed to be used by facets handling ERC20 permit functionality. + */ + +/** + * @notice Thrown when a permit signature is invalid or expired. + * @param _owner The address that signed the permit. + * @param _spender The address that was approved. + * @param _value The amount that was approved. + * @param _deadline The deadline for the permit. + * @param _v The recovery byte of the signature. + * @param _r The r value of the signature. + * @param _s The s value of the signature. + */ +error ERC2612InvalidSignature( + address _owner, address _spender, uint256 _value, uint256 _deadline, uint8 _v, bytes32 _r, bytes32 _s +); + +/** + * @notice Thrown when the spender address is invalid (e.g., zero address). + * @param _spender Invalid spender address. + */ +error ERC20InvalidSpender(address _spender); + +/** + * @notice Emitted when an approval is made for a spender by an owner. + * @param _owner The address granting the allowance. + * @param _spender The address receiving the allowance. + * @param _value The amount approved. + */ +event Approval(address indexed _owner, address indexed _spender, uint256 _value); + +bytes32 constant ERC20_METADATA_STORAGE_POSITION = keccak256("erc20.metadata"); + +/** + * @custom:storage-location erc8042:erc20.metadata + */ +struct ERC20MetadataStorage { + string name; +} + +function getERC20MetadataStorage() pure returns (ERC20MetadataStorage storage s) { + bytes32 position = ERC20_METADATA_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +bytes32 constant ERC20_STORAGE_POSITION = keccak256("erc20"); + +/** + * @custom:storage-location erc8042:erc20 + */ +struct ERC20Storage { + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; + mapping(address owner => mapping(address spender => uint256 allowance)) allowance; +} + +function getERC20Storage() pure returns (ERC20Storage storage s) { + bytes32 position = ERC20_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +bytes32 constant STORAGE_POSITION = keccak256("nonces"); + +/** + * @custom:storage-location erc8042:nonces + */ +struct NoncesStorage { + mapping(address owner => uint256) nonces; +} + +function getPermitStorage() pure returns (NoncesStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Returns the domain separator used in the encoding of the signature for {permit}. + * @dev This value is unique to a contract and chain ID combination to prevent replay attacks. + * @return The domain separator. + */ +function DOMAIN_SEPARATOR() view returns (bytes32) { + address thisAddress; + assembly { + thisAddress := address() + } + return keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(getERC20MetadataStorage().name)), + keccak256("1"), + block.chainid, + thisAddress + ) + ); +} + +/** + * @notice Validates a permit signature and sets allowance. + * @dev Emits Approval event; must be emitted by the calling facet/contract. + * @param _owner Token owner. + * @param _spender Token spender. + * @param _value Allowance value. + * @param _deadline Permit's time deadline. + * @param _v, _r, _s Signature fields. + */ +function permit(address _owner, address _spender, uint256 _value, uint256 _deadline, uint8 _v, bytes32 _r, bytes32 _s) { + if (_spender == address(0)) { + revert ERC20InvalidSpender(address(0)); + } + if (block.timestamp > _deadline) { + revert ERC2612InvalidSignature(_owner, _spender, _value, _deadline, _v, _r, _s); + } + + NoncesStorage storage s = getPermitStorage(); + ERC20Storage storage erc20Storage = getERC20Storage(); + uint256 currentNonce = s.nonces[_owner]; + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + _owner, + _spender, + _value, + currentNonce, + _deadline + ) + ); + address thisAddress; + assembly { + thisAddress := address() + } + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(getERC20MetadataStorage().name)), + keccak256("1"), + block.chainid, + thisAddress + ) + ), + structHash + ) + ); + + address signer = ecrecover(hash, _v, _r, _s); + if (signer != _owner || signer == address(0)) { + revert ERC2612InvalidSignature(_owner, _spender, _value, _deadline, _v, _r, _s); + } + + erc20Storage.allowance[_owner][_spender] = _value; + s.nonces[_owner] = currentNonce + 1; + emit Approval(_owner, _spender, _value); +} diff --git a/cli/project-template/src/templates/token/ERC20/Transfer/ERC20TransferFacet.sol b/cli/project-template/src/templates/token/ERC20/Transfer/ERC20TransferFacet.sol new file mode 100644 index 00000000..19bb51b6 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC20/Transfer/ERC20TransferFacet.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract ERC20TransferFacet { + /** + * @notice Thrown when an account has insufficient balance for a transfer or burn. + * @param _sender Address attempting the transfer. + * @param _balance Current balance of the sender. + * @param _needed Amount required to complete the operation. + */ + error ERC20InsufficientBalance(address _sender, uint256 _balance, uint256 _needed); + + /** + * @notice Thrown when the sender address is invalid (e.g., zero address). + * @param _sender Invalid sender address. + */ + error ERC20InvalidSender(address _sender); + + /** + * @notice Thrown when the receiver address is invalid (e.g., zero address). + * @param _receiver Invalid receiver address. + */ + error ERC20InvalidReceiver(address _receiver); + + /** + * @notice Thrown when a spender tries to use more than the approved allowance. + * @param _spender Address attempting to spend. + * @param _allowance Current allowance for the spender. + * @param _needed Amount required to complete the operation. + */ + error ERC20InsufficientAllowance(address _spender, uint256 _allowance, uint256 _needed); + + /** + * @notice Thrown when the spender address is invalid (e.g., zero address). + * @param _spender Invalid spender address. + */ + error ERC20InvalidSpender(address _spender); + + /** + * @notice Emitted when tokens are transferred between two addresses. + * @param _from Address sending the tokens. + * @param _to Address receiving the tokens. + * @param _value Amount of tokens transferred. + */ + event Transfer(address indexed _from, address indexed _to, uint256 _value); + + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc20"); + + /** + * @dev ERC-8042 compliant storage struct for ERC20 token data. + * @custom:storage-location erc8042:erc20 + */ + struct ERC20Storage { + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; + mapping(address owner => mapping(address spender => uint256 allowance)) allowance; + } + + /** + * @notice Returns the ERC20 storage struct from the predefined diamond storage slot. + * @dev Uses inline assembly to set the storage slot reference. + * @return s The ERC20 storage struct reference. + */ + function getStorage() internal pure returns (ERC20Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Transfers tokens to another address. + * @dev Emits a {Transfer} event. + * @param _to The address to receive the tokens. + * @param _value The amount of tokens to transfer. + * @return True if the transfer was successful. + */ + function transfer(address _to, uint256 _value) external returns (bool) { + ERC20Storage storage s = getStorage(); + if (_to == address(0)) { + revert ERC20InvalidReceiver(address(0)); + } + uint256 fromBalance = s.balanceOf[msg.sender]; + if (fromBalance < _value) { + revert ERC20InsufficientBalance(msg.sender, fromBalance, _value); + } + unchecked { + s.balanceOf[msg.sender] = fromBalance - _value; + } + s.balanceOf[_to] += _value; + emit Transfer(msg.sender, _to, _value); + return true; + } + + /** + * @notice Transfers tokens on behalf of another account, provided sufficient allowance exists. + * @dev Emits a {Transfer} event and decreases the spender's allowance. + * @param _from The address to transfer tokens from. + * @param _to The address to transfer tokens to. + * @param _value The amount of tokens to transfer. + * @return True if the transfer was successful. + */ + function transferFrom(address _from, address _to, uint256 _value) external returns (bool) { + ERC20Storage storage s = getStorage(); + if (_from == address(0)) { + revert ERC20InvalidSender(address(0)); + } + if (_to == address(0)) { + revert ERC20InvalidReceiver(address(0)); + } + uint256 currentAllowance = s.allowance[_from][msg.sender]; + if (currentAllowance < _value) { + revert ERC20InsufficientAllowance(msg.sender, currentAllowance, _value); + } + uint256 fromBalance = s.balanceOf[_from]; + if (fromBalance < _value) { + revert ERC20InsufficientBalance(_from, fromBalance, _value); + } + unchecked { + if (currentAllowance != type(uint256).max) { + s.allowance[_from][msg.sender] = currentAllowance - _value; + } + s.balanceOf[_from] = fromBalance - _value; + } + s.balanceOf[_to] += _value; + emit Transfer(_from, _to, _value); + return true; + } + + /** + * @notice Exports the function selectors of the ERC20TransferFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC20TransferFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.transfer.selector, this.transferFrom.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC20/Transfer/ERC20TransferMod.sol b/cli/project-template/src/templates/token/ERC20/Transfer/ERC20TransferMod.sol new file mode 100644 index 00000000..0d446317 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC20/Transfer/ERC20TransferMod.sol @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/* + * @title ERC20TransferMod + * @notice Provides transfer internal functions for ERC-20 tokens. + * @dev Uses ERC-8042 for storage location standardization and ERC-6093 for error conventions. + */ + +/** + * @notice Thrown when a sender attempts to transfer or burn more tokens than their balance. + * @param _sender The address attempting the transfer or burn. + * @param _balance The sender's current balance. + * @param _needed The amount required to complete the operation. + */ +error ERC20InsufficientBalance(address _sender, uint256 _balance, uint256 _needed); + +/** + * @notice Thrown when the sender address is invalid (e.g., zero address). + * @param _sender The invalid sender address. + */ +error ERC20InvalidSender(address _sender); + +/** + * @notice Thrown when the receiver address is invalid (e.g., zero address). + * @param _receiver The invalid receiver address. + */ +error ERC20InvalidReceiver(address _receiver); + +/** + * @notice Thrown when a spender tries to spend more than their allowance. + * @param _spender The address attempting to spend. + * @param _allowance The current allowance. + * @param _needed The required amount to complete the transfer. + */ +error ERC20InsufficientAllowance(address _spender, uint256 _allowance, uint256 _needed); + +/** + * @notice Thrown when the spender address is invalid (e.g., zero address). + * @param _spender The invalid spender address. + */ +error ERC20InvalidSpender(address _spender); + +/** + * @notice Emitted when tokens are transferred between addresses. + * @param _from The address tokens are transferred from. + * @param _to The address tokens are transferred to. + * @param _value The amount of tokens transferred. + */ +event Transfer(address indexed _from, address indexed _to, uint256 _value); + +/** + * @notice Emitted when an approval is made for a spender by an owner. + * @param _owner The address granting the allowance. + * @param _spender The address receiving the allowance. + * @param _value The amount approved. + */ +event Approval(address indexed _owner, address indexed _spender, uint256 _value); + +/* + * @notice Storage slot identifier, defined using keccak256 hash of the library diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc20"); + +/* + * @notice ERC-20 storage layout using the ERC-8042 standard. + * @custom:storage-location erc8042:erc20 + */ +struct ERC20Storage { + mapping(address owner => uint256 balance) balanceOf; + uint256 totalSupply; + mapping(address owner => mapping(address spender => uint256 allowance)) allowance; +} + +/** + * @notice Returns a pointer to the ERC-20 storage struct. + * @dev Uses inline assembly to bind the storage struct to the fixed storage position. + * @return s The ERC-20 storage struct. + */ +function getStorage() pure returns (ERC20Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Transfers tokens from one address to another using an allowance. + * @dev Deducts the spender's allowance and updates balances. + * @param _from The address to send tokens from. + * @param _to The address to send tokens to. + * @param _value The number of tokens to transfer. + * @return True if the transfer was successful. + */ +function transferFrom(address _from, address _to, uint256 _value) returns (bool) { + ERC20Storage storage s = getStorage(); + if (_from == address(0)) { + revert ERC20InvalidSender(address(0)); + } + if (_to == address(0)) { + revert ERC20InvalidReceiver(address(0)); + } + uint256 currentAllowance = s.allowance[_from][msg.sender]; + if (currentAllowance < _value) { + revert ERC20InsufficientAllowance(msg.sender, currentAllowance, _value); + } + uint256 fromBalance = s.balanceOf[_from]; + if (fromBalance < _value) { + revert ERC20InsufficientBalance(_from, fromBalance, _value); + } + unchecked { + if (currentAllowance != type(uint256).max) { + s.allowance[_from][msg.sender] = currentAllowance - _value; + } + s.balanceOf[_from] = fromBalance - _value; + } + s.balanceOf[_to] += _value; + emit Transfer(_from, _to, _value); + return true; +} + +/** + * @notice Transfers tokens from the caller to another address. + * @dev Updates balances directly without allowance mechanism. + * @param _to The address to send tokens to. + * @param _value The number of tokens to transfer. + * @return True if the transfer was successful. + */ +function transfer(address _to, uint256 _value) returns (bool) { + ERC20Storage storage s = getStorage(); + if (_to == address(0)) { + revert ERC20InvalidReceiver(address(0)); + } + uint256 fromBalance = s.balanceOf[msg.sender]; + if (fromBalance < _value) { + revert ERC20InsufficientBalance(msg.sender, fromBalance, _value); + } + unchecked { + s.balanceOf[msg.sender] = fromBalance - _value; + } + s.balanceOf[_to] += _value; + emit Transfer(msg.sender, _to, _value); + return true; +} + diff --git a/cli/project-template/src/templates/token/ERC6909/Approve/ERC6909ApproveFacet.sol b/cli/project-template/src/templates/token/ERC6909/Approve/ERC6909ApproveFacet.sol new file mode 100644 index 00000000..6bc82700 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC6909/Approve/ERC6909ApproveFacet.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract ERC6909ApproveFacet { + /** + * @notice Thrown when the spender address is invalid. + */ + error ERC6909InvalidSpender(address _spender); + + /** + * @notice Emitted when an approval occurs. + */ + event Approval(address indexed _owner, address indexed _spender, uint256 indexed _id, uint256 _amount); + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc6909"); + + /** + * @custom:storage-location erc8042:erc6909 + */ + struct ERC6909Storage { + mapping(address owner => mapping(uint256 id => uint256 amount)) balanceOf; + mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) allowance; + mapping(address owner => mapping(address spender => bool)) isOperator; + } + + /** + * @notice Returns a pointer to the ERC-6909 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC6909Storage struct in storage. + */ + function getStorage() internal pure returns (ERC6909Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Approves an amount of an id to a spender. + * @param _spender The address of the spender. + * @param _id The id of the token. + * @param _amount The amount of the token. + * @return Whether the approval succeeded. + */ + function approve(address _spender, uint256 _id, uint256 _amount) external returns (bool) { + if (_spender == address(0)) { + revert ERC6909InvalidSpender(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + s.allowance[msg.sender][_spender][_id] = _amount; + + emit Approval(msg.sender, _spender, _id, _amount); + + return true; + } + + /** + * @notice Exports the function selectors of the ERC6909ApproveFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC6909ApproveFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.approve.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC6909/Approve/ERC6909ApproveMod.sol b/cli/project-template/src/templates/token/ERC6909/Approve/ERC6909ApproveMod.sol new file mode 100644 index 00000000..bfa14282 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC6909/Approve/ERC6909ApproveMod.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Thrown when the spender address is invalid. + */ +error ERC6909InvalidSpender(address _spender); + +/** + * @notice Emitted when an approval occurs. + */ +event Approval(address indexed _owner, address indexed _spender, uint256 indexed _id, uint256 _amount); +/** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc6909"); + +/** + * @custom:storage-location erc8042:erc6909 + */ +struct ERC6909Storage { + mapping(address owner => mapping(uint256 id => uint256 amount)) balanceOf; + mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) allowance; + mapping(address owner => mapping(address spender => bool)) isOperator; +} + +/** + * @notice Returns a pointer to the ERC-6909 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC6909Storage struct in storage. + */ +function getStorage() pure returns (ERC6909Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Approves an amount of an id to a spender. + * @param _spender The address of the spender. + * @param _id The id of the token. + * @param _amount The amount of the token. + * @return Whether the approval succeeded. + */ +function approve(address _spender, uint256 _id, uint256 _amount) returns (bool) { + if (_spender == address(0)) { + revert ERC6909InvalidSpender(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + s.allowance[msg.sender][_spender][_id] = _amount; + + emit Approval(msg.sender, _spender, _id, _amount); + + return true; +} diff --git a/cli/project-template/src/templates/token/ERC6909/Burn/ERC6909BurnFacet.sol b/cli/project-template/src/templates/token/ERC6909/Burn/ERC6909BurnFacet.sol new file mode 100644 index 00000000..2edb4ac4 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC6909/Burn/ERC6909BurnFacet.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract ERC6909BurnFacet { + /** + * @notice Thrown when the sender has insufficient balance. + */ + error ERC6909InsufficientBalance(address _sender, uint256 _balance, uint256 _needed, uint256 _id); + + /** + * @notice Thrown when the spender has insufficient allowance. + */ + error ERC6909InsufficientAllowance(address _spender, uint256 _allowance, uint256 _needed, uint256 _id); + + /** + * @notice Thrown when the sender address is invalid. + */ + error ERC6909InvalidSender(address _sender); + + /** + * @notice Emitted when a transfer occurs. + */ + + event Transfer( + address _caller, address indexed _sender, address indexed _receiver, uint256 indexed _id, uint256 _amount + ); + + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc6909"); + + /** + * @custom:storage-location erc8042:erc6909 + */ + struct ERC6909Storage { + mapping(address owner => mapping(uint256 id => uint256 amount)) balanceOf; + mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) allowance; + mapping(address owner => mapping(address spender => bool)) isOperator; + } + + /** + * @notice Returns a pointer to the ERC-6909 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC6909Storage struct in storage. + */ + function getStorage() internal pure returns (ERC6909Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Burns (destroys) a specific amount of tokens from the caller's balance. + * @dev Emits a {Transfer} event to the zero address. + * @param _amount The amount of tokens to burn. + */ + function burn(uint256 _id, uint256 _amount) external { + ERC6909Storage storage s = getStorage(); + + uint256 fromBalance = s.balanceOf[msg.sender][_id]; + + if (fromBalance < _amount) { + revert ERC6909InsufficientBalance(msg.sender, fromBalance, _amount, _id); + } + + unchecked { + s.balanceOf[msg.sender][_id] = fromBalance - _amount; + } + + emit Transfer(msg.sender, msg.sender, address(0), _id, _amount); + } + + /** + * @notice Burns tokens from another account, deducting from the caller's allowance. + * @dev Emits a {Transfer} event to the zero address. + * @param _from The address whose tokens will be burned. + * @param _amount The amount of tokens to burn. + */ + function burnFrom(address _from, uint256 _id, uint256 _amount) external { + if (_from == address(0)) { + revert ERC6909InvalidSender(_from); + } + + ERC6909Storage storage s = getStorage(); + + uint256 currentAllowance = s.allowance[_from][msg.sender][_id]; + + if (msg.sender != _from && currentAllowance < type(uint256).max) { + if (currentAllowance < _amount) { + revert ERC6909InsufficientAllowance(msg.sender, currentAllowance, _amount, _id); + } + unchecked { + s.allowance[_from][msg.sender][_id] = currentAllowance - _amount; + } + } + uint256 fromBalance = s.balanceOf[_from][_id]; + if (fromBalance < _amount) { + revert ERC6909InsufficientBalance(_from, fromBalance, _amount, _id); + } + unchecked { + s.balanceOf[_from][_id] = fromBalance - _amount; + } + + emit Transfer(msg.sender, _from, address(0), _id, _amount); + } + + /** + * @notice Exports the function selectors of the ERC6909BurnFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC6909BurnFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.burn.selector, this.burnFrom.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC6909/Burn/ERC6909BurnMod.sol b/cli/project-template/src/templates/token/ERC6909/Burn/ERC6909BurnMod.sol new file mode 100644 index 00000000..00ecde94 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC6909/Burn/ERC6909BurnMod.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ +/** + * @notice Thrown when the sender has insufficient balance. + */ +error ERC6909InsufficientBalance(address _sender, uint256 _balance, uint256 _needed, uint256 _id); + +/** + * @notice Thrown when the spender has insufficient allowance. + */ +error ERC6909InsufficientAllowance(address _spender, uint256 _allowance, uint256 _needed, uint256 _id); + +/** + * @notice Thrown when the sender address is invalid. + */ +error ERC6909InvalidSender(address _sender); + +/** + * @notice Emitted when a transfer occurs. + */ +event Transfer( + address _caller, address indexed _sender, address indexed _receiver, uint256 indexed _id, uint256 _amount +); + +/** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc6909"); + +/** + * @custom:storage-location erc8042:erc6909 + */ +struct ERC6909Storage { + mapping(address owner => mapping(uint256 id => uint256 amount)) balanceOf; + mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) allowance; + mapping(address owner => mapping(address spender => bool)) isOperator; +} + +/** + * @notice Returns a pointer to the ERC-6909 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC6909Storage struct in storage. + */ +function getStorage() pure returns (ERC6909Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Burns (destroys) a specific amount of tokens from the caller's balance. + * @dev Emits a {Transfer} event to the zero address. + * @param _amount The amount of tokens to burn. + */ +function burn(uint256 _id, uint256 _amount) { + ERC6909Storage storage s = getStorage(); + + uint256 fromBalance = s.balanceOf[msg.sender][_id]; + + if (fromBalance < _amount) { + revert ERC6909InsufficientBalance(msg.sender, fromBalance, _amount, _id); + } + + unchecked { + s.balanceOf[msg.sender][_id] = fromBalance - _amount; + } + + emit Transfer(msg.sender, msg.sender, address(0), _id, _amount); +} + +/** + * @notice Burns tokens from another account, deducting from the caller's allowance. + * @dev Emits a {Transfer} event to the zero address. + * @param _from The address whose tokens will be burned. + * @param _amount The amount of tokens to burn. + */ +function burnFrom(address _from, uint256 _id, uint256 _amount) { + if (_from == address(0)) { + revert ERC6909InvalidSender(_from); + } + + ERC6909Storage storage s = getStorage(); + + uint256 currentAllowance = s.allowance[_from][msg.sender][_id]; + + if (msg.sender != _from && currentAllowance < type(uint256).max) { + if (currentAllowance < _amount) { + revert ERC6909InsufficientAllowance(msg.sender, currentAllowance, _amount, _id); + } + unchecked { + s.allowance[_from][msg.sender][_id] = currentAllowance - _amount; + } + } + uint256 fromBalance = s.balanceOf[_from][_id]; + if (fromBalance < _amount) { + revert ERC6909InsufficientBalance(_from, fromBalance, _amount, _id); + } + unchecked { + s.balanceOf[_from][_id] = fromBalance - _amount; + } + + emit Transfer(msg.sender, _from, address(0), _id, _amount); +} diff --git a/cli/project-template/src/templates/token/ERC6909/Data/ERC6909DataFacet.sol b/cli/project-template/src/templates/token/ERC6909/Data/ERC6909DataFacet.sol new file mode 100644 index 00000000..6cdd6252 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC6909/Data/ERC6909DataFacet.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract ERC6909DataFacet { + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc6909"); + + /** + * @custom:storage-location erc8042:erc6909 + */ + struct ERC6909Storage { + mapping(address owner => mapping(uint256 id => uint256 amount)) balanceOf; + mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) allowance; + mapping(address owner => mapping(address spender => bool)) isOperator; + } + + /** + * @notice Returns a pointer to the ERC-6909 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC6909Storage struct in storage. + */ + function getStorage() internal pure returns (ERC6909Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Owner balance of an id. + * @param _owner The address of the owner. + * @param _id The id of the token. + * @return The balance of the token. + */ + function balanceOf(address _owner, uint256 _id) external view returns (uint256) { + return getStorage().balanceOf[_owner][_id]; + } + + /** + * @notice Spender allowance of an id. + * @param _owner The address of the owner. + * @param _spender The address of the spender. + * @param _id The id of the token. + * @return The allowance of the token. + */ + function allowance(address _owner, address _spender, uint256 _id) external view returns (uint256) { + return getStorage().allowance[_owner][_spender][_id]; + } + + /** + * @notice Checks if a spender is approved by an owner as an operator. + * @param _owner The address of the owner. + * @param _spender The address of the spender. + * @return The approval status. + */ + function isOperator(address _owner, address _spender) external view returns (bool) { + return getStorage().isOperator[_owner][_spender]; + } + + /** + * @notice Exports the function selectors of the ERC6909DataFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC6909DataFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.balanceOf.selector, this.allowance.selector, this.isOperator.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC6909/Mint/ERC6909MintMod.sol b/cli/project-template/src/templates/token/ERC6909/Mint/ERC6909MintMod.sol new file mode 100644 index 00000000..728479b8 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC6909/Mint/ERC6909MintMod.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Thrown when the receiver address is invalid. + */ +error ERC6909InvalidReceiver(address _receiver); +/** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + +/** + * @notice Emitted when a transfer occurs. + */ +event Transfer( + address _caller, address indexed _sender, address indexed _receiver, uint256 indexed _id, uint256 _amount +); + +bytes32 constant STORAGE_POSITION = keccak256("erc6909"); + +/** + * @custom:storage-location erc8042:erc6909 + */ +struct ERC6909Storage { + mapping(address owner => mapping(uint256 id => uint256 amount)) balanceOf; + mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) allowance; + mapping(address owner => mapping(address spender => bool)) isOperator; +} + +/** + * @notice Returns a pointer to the ERC-6909 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC6909Storage struct in storage. + */ +function getStorage() pure returns (ERC6909Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Mints new tokens to a specified address. + * @dev Increases both total supply and the recipient's balance. + * @param _account The address receiving the newly minted tokens. + * @param _value The number of tokens to mint. + */ +function mint(address _account, uint256 _id, uint256 _value) { + if (_account == address(0)) { + revert ERC6909InvalidReceiver(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + s.balanceOf[_account][_id] += _value; + emit Transfer(msg.sender, address(0), _account, _id, _value); +} + diff --git a/cli/project-template/src/templates/token/ERC6909/Operator/ERC6909OperatorFacet.sol b/cli/project-template/src/templates/token/ERC6909/Operator/ERC6909OperatorFacet.sol new file mode 100644 index 00000000..736ad27e --- /dev/null +++ b/cli/project-template/src/templates/token/ERC6909/Operator/ERC6909OperatorFacet.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-6909 Minimal Multi-Token Interface + * @notice A complete, dependency-free ERC-6909 implementation using the diamond storage pattern. + */ +contract ERC6909OperatorFacet { + /** + * @notice Thrown when the spender address is invalid. + */ + error ERC6909InvalidSpender(address _spender); + + /** + * @notice Emitted when an operator is set. + */ + event OperatorSet(address indexed _owner, address indexed _spender, bool _approved); + + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc6909"); + + /** + * @custom:storage-location erc8042:erc6909 + */ + struct ERC6909Storage { + mapping(address owner => mapping(uint256 id => uint256 amount)) balanceOf; + mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) allowance; + mapping(address owner => mapping(address spender => bool)) isOperator; + } + + /** + * @notice Returns a pointer to the ERC-6909 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC6909Storage struct in storage. + */ + function getStorage() internal pure returns (ERC6909Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Sets or removes a spender as an operator for the caller. + * @param _spender The address of the spender. + * @param _approved The approval status. + * @return Whether the operator update succeeded. + */ + function setOperator(address _spender, bool _approved) external returns (bool) { + if (_spender == address(0)) { + revert ERC6909InvalidSpender(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + s.isOperator[msg.sender][_spender] = _approved; + + emit OperatorSet(msg.sender, _spender, _approved); + + return true; + } + + /** + * @notice Exports the function selectors of the ERC6909OPeratorFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC6909OPeratorFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.setOperator.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC6909/Operator/ERC6909OperatorMod.sol b/cli/project-template/src/templates/token/ERC6909/Operator/ERC6909OperatorMod.sol new file mode 100644 index 00000000..eb237150 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC6909/Operator/ERC6909OperatorMod.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-6909 Minimal Multi-Token Interface + * @notice A complete, dependency-free ERC-6909 implementation using the diamond storage pattern. + */ +/** + * @notice Thrown when the spender address is invalid. + */ +error ERC6909InvalidSpender(address _spender); + +/** + * @notice Emitted when an operator is set. + */ +event OperatorSet(address indexed _owner, address indexed _spender, bool _approved); + +/** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc6909"); + +/** + * @custom:storage-location erc8042:erc6909 + */ +struct ERC6909Storage { + mapping(address owner => mapping(uint256 id => uint256 amount)) balanceOf; + mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) allowance; + mapping(address owner => mapping(address spender => bool)) isOperator; +} + +/** + * @notice Returns a pointer to the ERC-6909 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC6909Storage struct in storage. + */ +function getStorage() pure returns (ERC6909Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Sets or removes a spender as an operator for the caller. + * @param _spender The address of the spender. + * @param _approved The approval status. + * @return Whether the operator update succeeded. + */ +function setOperator(address _spender, bool _approved) returns (bool) { + if (_spender == address(0)) { + revert ERC6909InvalidSpender(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + s.isOperator[msg.sender][_spender] = _approved; + + emit OperatorSet(msg.sender, _spender, _approved); + + return true; +} + diff --git a/cli/project-template/src/templates/token/ERC6909/Transfer/ERC6909TransferFacet.sol b/cli/project-template/src/templates/token/ERC6909/Transfer/ERC6909TransferFacet.sol new file mode 100644 index 00000000..1e3ce474 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC6909/Transfer/ERC6909TransferFacet.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract ERC6909TransferFacet { + /** + * @notice Thrown when the sender has insufficient balance. + */ + error ERC6909InsufficientBalance(address _sender, uint256 _balance, uint256 _needed, uint256 _id); + + /** + * @notice Thrown when the spender has insufficient allowance. + */ + error ERC6909InsufficientAllowance(address _spender, uint256 _allowance, uint256 _needed, uint256 _id); + + /** + * @notice Thrown when the receiver address is invalid. + */ + error ERC6909InvalidReceiver(address _receiver); + + /** + * @notice Thrown when the sender address is invalid. + */ + error ERC6909InvalidSender(address _sender); + /** + * @notice Emitted when a transfer occurs. + */ + event Transfer( + address _caller, address indexed _sender, address indexed _receiver, uint256 indexed _id, uint256 _amount + ); + + /** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc6909"); + + /** + * @custom:storage-location erc8042:erc6909 + */ + struct ERC6909Storage { + mapping(address owner => mapping(uint256 id => uint256 amount)) balanceOf; + mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) allowance; + mapping(address owner => mapping(address spender => bool)) isOperator; + } + + /** + * @notice Returns a pointer to the ERC-6909 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC6909Storage struct in storage. + */ + function getStorage() internal pure returns (ERC6909Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Transfers an amount of an id from the caller to a receiver. + * @param _receiver The address of the receiver. + * @param _id The id of the token. + * @param _amount The amount of the token. + * @return Whether the transfer succeeded. + */ + function transfer(address _receiver, uint256 _id, uint256 _amount) external returns (bool) { + if (_receiver == address(0)) { + revert ERC6909InvalidReceiver(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + uint256 fromBalance = s.balanceOf[msg.sender][_id]; + + if (fromBalance < _amount) { + revert ERC6909InsufficientBalance(msg.sender, fromBalance, _amount, _id); + } + + unchecked { + s.balanceOf[msg.sender][_id] = fromBalance - _amount; + } + + s.balanceOf[_receiver][_id] += _amount; + + emit Transfer(msg.sender, msg.sender, _receiver, _id, _amount); + + return true; + } + + /** + * @notice Transfers an amount of an id from a sender to a receiver. + * @param _sender The address of the sender. + * @param _receiver The address of the receiver. + * @param _id The id of the token. + * @param _amount The amount of the token. + * @return Whether the transfer succeeded. + */ + function transferFrom(address _sender, address _receiver, uint256 _id, uint256 _amount) external returns (bool) { + if (_sender == address(0)) { + revert ERC6909InvalidSender(address(0)); + } + + if (_receiver == address(0)) { + revert ERC6909InvalidReceiver(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + if (msg.sender != _sender && !s.isOperator[_sender][msg.sender]) { + uint256 currentAllowance = s.allowance[_sender][msg.sender][_id]; + if (currentAllowance < type(uint256).max) { + if (currentAllowance < _amount) { + revert ERC6909InsufficientAllowance(msg.sender, currentAllowance, _amount, _id); + } + unchecked { + s.allowance[_sender][msg.sender][_id] = currentAllowance - _amount; + } + } + } + + uint256 fromBalance = s.balanceOf[_sender][_id]; + if (fromBalance < _amount) { + revert ERC6909InsufficientBalance(_sender, fromBalance, _amount, _id); + } + unchecked { + s.balanceOf[_sender][_id] = fromBalance - _amount; + } + + s.balanceOf[_receiver][_id] += _amount; + + emit Transfer(msg.sender, _sender, _receiver, _id, _amount); + + return true; + } + + /** + * @notice Exports the function selectors of the ERC6909TransferFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC6909TransferFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.transfer.selector, this.transferFrom.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC6909/Transfer/ERC6909TransferMod.sol b/cli/project-template/src/templates/token/ERC6909/Transfer/ERC6909TransferMod.sol new file mode 100644 index 00000000..d2a1c2e6 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC6909/Transfer/ERC6909TransferMod.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/* + * @title ERC6909ransferMod + * @notice Provides transfer internal functions for ERC-6909 tokens. + * @dev Uses ERC-8042 for storage location standardization and ERC-6093 for error conventions. + */ + +/** + * @notice Thrown when the sender has insufficient balance. + */ +error ERC6909InsufficientBalance(address _sender, uint256 _balance, uint256 _needed, uint256 _id); + +/** + * @notice Thrown when the spender has insufficient allowance. + */ +error ERC6909InsufficientAllowance(address _spender, uint256 _allowance, uint256 _needed, uint256 _id); + +/** + * @notice Thrown when the receiver address is invalid. + */ +error ERC6909InvalidReceiver(address _receiver); + +/** + * @notice Thrown when the sender address is invalid. + */ +error ERC6909InvalidSender(address _sender); +/** + * @notice Emitted when a transfer occurs. + */ +event Transfer( + address _caller, address indexed _sender, address indexed _receiver, uint256 indexed _id, uint256 _amount +); + +/** + * @dev Storage position determined by the keccak256 hash of the diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc6909"); + +/** + * @custom:storage-location erc8042:erc6909 + */ +struct ERC6909Storage { + mapping(address owner => mapping(uint256 id => uint256 amount)) balanceOf; + mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) allowance; + mapping(address owner => mapping(address spender => bool)) isOperator; +} + +/** + * @notice Returns a pointer to the ERC-6909 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC6909Storage struct in storage. + */ +function getStorage() pure returns (ERC6909Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Transfers an amount of an id from the caller to a receiver. + * @param _receiver The address of the receiver. + * @param _id The id of the token. + * @param _amount The amount of the token. + * @return Whether the transfer succeeded. + */ +function transfer(address _receiver, uint256 _id, uint256 _amount) returns (bool) { + if (_receiver == address(0)) { + revert ERC6909InvalidReceiver(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + uint256 fromBalance = s.balanceOf[msg.sender][_id]; + + if (fromBalance < _amount) { + revert ERC6909InsufficientBalance(msg.sender, fromBalance, _amount, _id); + } + + unchecked { + s.balanceOf[msg.sender][_id] = fromBalance - _amount; + } + + s.balanceOf[_receiver][_id] += _amount; + + emit Transfer(msg.sender, msg.sender, _receiver, _id, _amount); + + return true; +} + +/** + * @notice Transfers an amount of an id from a sender to a receiver. + * @param _sender The address of the sender. + * @param _receiver The address of the receiver. + * @param _id The id of the token. + * @param _amount The amount of the token. + * @return Whether the transfer succeeded. + */ +function transferFrom(address _sender, address _receiver, uint256 _id, uint256 _amount) returns (bool) { + if (_sender == address(0)) { + revert ERC6909InvalidSender(address(0)); + } + + if (_receiver == address(0)) { + revert ERC6909InvalidReceiver(address(0)); + } + + ERC6909Storage storage s = getStorage(); + + if (msg.sender != _sender && !s.isOperator[_sender][msg.sender]) { + uint256 currentAllowance = s.allowance[_sender][msg.sender][_id]; + if (currentAllowance < type(uint256).max) { + if (currentAllowance < _amount) { + revert ERC6909InsufficientAllowance(msg.sender, currentAllowance, _amount, _id); + } + unchecked { + s.allowance[_sender][msg.sender][_id] = currentAllowance - _amount; + } + } + } + + uint256 fromBalance = s.balanceOf[_sender][_id]; + if (fromBalance < _amount) { + revert ERC6909InsufficientBalance(_sender, fromBalance, _amount, _id); + } + unchecked { + s.balanceOf[_sender][_id] = fromBalance - _amount; + } + + s.balanceOf[_receiver][_id] += _amount; + + emit Transfer(msg.sender, _sender, _receiver, _id, _amount); + + return true; +} diff --git a/cli/project-template/src/templates/token/ERC721/Approve/ERC721ApproveFacet.sol b/cli/project-template/src/templates/token/ERC721/Approve/ERC721ApproveFacet.sol new file mode 100644 index 00000000..a9c79d4c --- /dev/null +++ b/cli/project-template/src/templates/token/ERC721/Approve/ERC721ApproveFacet.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-721 Approve + */ +contract ERC721ApproveFacet { + /** + * @notice Error indicating that the queried token does not exist. + */ + error ERC721NonexistentToken(uint256 _tokenId); + + /** + * @notice Error indicating the approver address is invalid. + */ + error ERC721InvalidApprover(address _approver); + + /** + * @notice Error indicating the operator address is invalid. + */ + error ERC721InvalidOperator(address _operator); + + /** + * @notice Emitted when the approved address for an NFT is changed or reaffirmed. + */ + event Approval(address indexed _owner, address indexed _to, uint256 indexed _tokenId); + + /** + * @notice Emitted when an operator is enabled or disabled for an owner. + */ + event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved); + + bytes32 constant STORAGE_POSITION = keccak256("erc721"); + + /** + * @custom:storage-location erc8042:erc721 + */ + struct ERC721Storage { + mapping(uint256 tokenId => address owner) ownerOf; + mapping(address owner => uint256 balance) balanceOf; + mapping(address owner => mapping(address operator => bool approved)) isApprovedForAll; + mapping(uint256 tokenId => address approved) approved; + } + + /** + * @notice Returns a pointer to the ERC-721 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC721Storage struct in storage. + */ + function getStorage() internal pure returns (ERC721Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Approves another address to transfer the given token ID. + * @param _to The address to be approved. + * @param _tokenId The token ID to approve. + */ + function approve(address _to, uint256 _tokenId) external { + ERC721Storage storage s = getStorage(); + address owner = s.ownerOf[_tokenId]; + if (owner == address(0)) { + revert ERC721NonexistentToken(_tokenId); + } + if (msg.sender != owner && !s.isApprovedForAll[owner][msg.sender]) { + revert ERC721InvalidApprover(msg.sender); + } + s.approved[_tokenId] = _to; + emit Approval(owner, _to, _tokenId); + } + + /** + * @notice Approves or revokes permission for an operator to manage all caller's assets. + * @param _operator The operator address to set approval for. + * @param _approved True to approve, false to revoke. + */ + function setApprovalForAll(address _operator, bool _approved) external { + if (_operator == address(0)) { + revert ERC721InvalidOperator(_operator); + } + getStorage().isApprovedForAll[msg.sender][_operator] = _approved; + emit ApprovalForAll(msg.sender, _operator, _approved); + } + + /** + * @notice Exports the function selectors of the ERC721ApproveFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC721ApproveFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.approve.selector, this.setApprovalForAll.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC721/Approve/ERC721ApproveMod.sol b/cli/project-template/src/templates/token/ERC721/Approve/ERC721ApproveMod.sol new file mode 100644 index 00000000..012eff79 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC721/Approve/ERC721ApproveMod.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-721 Approve Module + */ + +/** + * @notice Error indicating that the queried token does not exist. + */ +error ERC721NonexistentToken(uint256 _tokenId); +/** + * @notice Error indicating the operator address is invalid. + */ +error ERC721InvalidOperator(address _operator); + +/** + * @notice Emitted when the approved address for an NFT is changed or reaffirmed. + */ +event Approval(address indexed _owner, address indexed _to, uint256 indexed _tokenId); + +/** + * @notice Emitted when an operator is enabled or disabled for an owner. + */ +event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved); + +bytes32 constant STORAGE_POSITION = keccak256("erc721"); + +/** + * @custom:storage-location erc8042:erc721 + */ +struct ERC721Storage { + mapping(uint256 tokenId => address owner) ownerOf; + mapping(address owner => uint256 balance) balanceOf; + mapping(address owner => mapping(address operator => bool approved)) isApprovedForAll; + mapping(uint256 tokenId => address approved) approved; +} + +/** + * @notice Returns a pointer to the ERC-721 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC721Storage struct in storage. + */ +function getStorage() pure returns (ERC721Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Approves another address to transfer the given token ID. + * @param _to The address to be approved. + * @param _tokenId The token ID to approve. + */ +function approve(address _to, uint256 _tokenId) { + ERC721Storage storage s = getStorage(); + address owner = s.ownerOf[_tokenId]; + if (owner == address(0)) { + revert ERC721NonexistentToken(_tokenId); + } + s.approved[_tokenId] = _to; + emit Approval(owner, _to, _tokenId); +} + +/** + * @notice Approves or revokes permission for an operator to manage all users's assets. + * @param _operator The operator address to set approval for. + * @param _approved True to approve, false to revoke. + */ +function setApprovalForAll(address user, address _operator, bool _approved) { + if (_operator == address(0)) { + revert ERC721InvalidOperator(_operator); + } + getStorage().isApprovedForAll[user][_operator] = _approved; + emit ApprovalForAll(user, _operator, _approved); +} + diff --git a/cli/project-template/src/templates/token/ERC721/Burn/ERC721BurnFacet.sol b/cli/project-template/src/templates/token/ERC721/Burn/ERC721BurnFacet.sol new file mode 100644 index 00000000..1a9cefb6 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC721/Burn/ERC721BurnFacet.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-721 Token + * @notice A complete, dependency-free ERC-721 implementation using the diamond storage pattern. + * @dev This facet provides metadata, ownership, approvals, safe transfers, minting, burning, and helpers. + */ +contract ERC721BurnFacet { + /** + * @notice Error indicating that the queried token does not exist. + */ + error ERC721NonexistentToken(uint256 _tokenId); + + /** + * @notice Error indicating the operator lacks approval to transfer the given token. + */ + error ERC721InsufficientApproval(address _operator, uint256 _tokenId); + + /** + * @notice Emitted when ownership of an NFT changes by any mechanism. + */ + event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId); + + bytes32 constant STORAGE_POSITION = keccak256("erc721"); + + /** + * @custom:storage-location erc8042:erc721 + */ + struct ERC721Storage { + mapping(uint256 tokenId => address owner) ownerOf; + mapping(address owner => uint256 balance) balanceOf; + mapping(address owner => mapping(address operator => bool approved)) isApprovedForAll; + mapping(uint256 tokenId => address approved) approved; + } + + /** + * @notice Returns a pointer to the ERC-721 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC721Storage struct in storage. + */ + function getStorage() internal pure returns (ERC721Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Burns (destroys) a token, removing it from enumeration tracking. + * @param _tokenId The ID of the token to burn. + */ + function burn(uint256 _tokenId) external { + ERC721Storage storage s = getStorage(); + address owner = s.ownerOf[_tokenId]; + if (owner == address(0)) { + revert ERC721NonexistentToken(_tokenId); + } + if (msg.sender != owner) { + if (!s.isApprovedForAll[owner][msg.sender] && msg.sender != s.approved[_tokenId]) { + revert ERC721InsufficientApproval(msg.sender, _tokenId); + } + } + unchecked { + s.balanceOf[owner]--; + } + delete s.ownerOf[_tokenId]; + delete s.approved[_tokenId]; + emit Transfer(owner, address(0), _tokenId); + } + + /** + * @notice Burns (destroys) a token, removing it from enumeration tracking. + * @param _tokenIds The ID of the token to burn. + */ + function burnBatch(uint256[] memory _tokenIds) external { + ERC721Storage storage s = getStorage(); + for (uint256 i; i < _tokenIds.length; i++) { + uint256 tokenId = _tokenIds[i]; + address owner = s.ownerOf[tokenId]; + if (owner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } + if (msg.sender != owner) { + if (!s.isApprovedForAll[owner][msg.sender] && msg.sender != s.approved[tokenId]) { + revert ERC721InsufficientApproval(msg.sender, tokenId); + } + } + unchecked { + s.balanceOf[owner]--; + } + delete s.ownerOf[tokenId]; + delete s.approved[tokenId]; + emit Transfer(owner, address(0), tokenId); + } + } + + /** + * @notice Exports the function selectors of the ERC721BurnFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC721BurnFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.burn.selector, this.burnBatch.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC721/Burn/ERC721BurnMod.sol b/cli/project-template/src/templates/token/ERC721/Burn/ERC721BurnMod.sol new file mode 100644 index 00000000..d84481ae --- /dev/null +++ b/cli/project-template/src/templates/token/ERC721/Burn/ERC721BurnMod.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Thrown when attempting to interact with a non-existent token. + * @param _tokenId The ID of the token that does not exist. + */ +error ERC721NonexistentToken(uint256 _tokenId); + +/** + * @notice Emitted when ownership of a token changes, including minting and burning. + * @param _from The address transferring the token, or zero for minting. + * @param _to The address receiving the token, or zero for burning. + * @param _tokenId The ID of the token being transferred. + */ +event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId); + +/** + * @dev Storage position constant defined via keccak256 hash of diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc721"); + +/** + * @notice Storage layout for ERC-721 token management. + * @dev Defines ownership, balances, approvals, and operator mappings per ERC-721 standard. + * @custom:storage-location erc8042:erc721 + */ +struct ERC721Storage { + mapping(uint256 tokenId => address owner) ownerOf; + mapping(address owner => uint256 balance) balanceOf; + mapping(address owner => mapping(address operator => bool approved)) isApprovedForAll; + mapping(uint256 tokenId => address approved) approved; +} + +/** + * @notice Returns the ERC-721 storage struct from its predefined slot. + * @dev Uses inline assembly to access diamond storage location. + * @return s The storage reference for ERC-721 state variables. + */ +function getStorage() pure returns (ERC721Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Burns (destroys) a specific ERC-721 token. + * @dev Reverts if the token does not exist. Clears ownership and approval. + * This module does not perform approval checks. Ensure proper ownership or approval validation before calling this function. + * @param _tokenId The ID of the token to burn. + */ +function burn(uint256 _tokenId) { + ERC721Storage storage s = getStorage(); + address owner = s.ownerOf[_tokenId]; + if (owner == address(0)) { + revert ERC721NonexistentToken(_tokenId); + } + delete s.ownerOf[_tokenId]; + delete s.approved[_tokenId]; + unchecked { + s.balanceOf[owner]--; + } + emit Transfer(owner, address(0), _tokenId); +} diff --git a/cli/project-template/src/templates/token/ERC721/Data/ERC721DataFacet.sol b/cli/project-template/src/templates/token/ERC721/Data/ERC721DataFacet.sol new file mode 100644 index 00000000..b8d81aad --- /dev/null +++ b/cli/project-template/src/templates/token/ERC721/Data/ERC721DataFacet.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-721 Data Facet + */ +contract ERC721DataFacet { + /** + * @notice Error indicating the queried owner address is invalid (zero address). + */ + error ERC721InvalidOwner(address _owner); + + /** + * @notice Error indicating that the queried token does not exist. + */ + error ERC721NonexistentToken(uint256 _tokenId); + + bytes32 constant STORAGE_POSITION = keccak256("erc721"); + + /** + * @custom:storage-location erc8042:erc721 + */ + struct ERC721Storage { + mapping(uint256 tokenId => address owner) ownerOf; + mapping(address owner => uint256 balance) balanceOf; + mapping(address owner => mapping(address operator => bool approved)) isApprovedForAll; + mapping(uint256 tokenId => address approved) approved; + } + + /** + * @notice Returns a pointer to the ERC-721 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC721Storage struct in storage. + */ + function getStorage() internal pure returns (ERC721Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns the number of tokens owned by a given address. + * @param _owner The address to query the balance of. + * @return The balance (number of tokens) owned by `_owner`. + */ + function balanceOf(address _owner) external view returns (uint256) { + if (_owner == address(0)) { + revert ERC721InvalidOwner(_owner); + } + return getStorage().balanceOf[_owner]; + } + + /** + * @notice Returns the owner of a given token ID. + * @param _tokenId The token ID to query. + * @return The address of the token owner. + */ + function ownerOf(uint256 _tokenId) public view returns (address) { + address owner = getStorage().ownerOf[_tokenId]; + if (owner == address(0)) { + revert ERC721NonexistentToken(_tokenId); + } + return owner; + } + + /** + * @notice Returns the approved address for a given token ID. + * @param _tokenId The token ID to query the approval of. + * @return The approved address for the token. + */ + function getApproved(uint256 _tokenId) external view returns (address) { + address owner = getStorage().ownerOf[_tokenId]; + if (owner == address(0)) { + revert ERC721NonexistentToken(_tokenId); + } + return getStorage().approved[_tokenId]; + } + + /** + * @notice Returns true if an operator is approved to manage all of an owner's assets. + * @param _owner The token owner. + * @param _operator The operator address. + * @return True if the operator is approved for all tokens of the owner. + */ + function isApprovedForAll(address _owner, address _operator) external view returns (bool) { + return getStorage().isApprovedForAll[_owner][_operator]; + } + + /** + * @notice Exports the function selectors of the ERC721DataFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC721DataFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat( + this.balanceOf.selector, this.ownerOf.selector, this.getApproved.selector, this.isApprovedForAll.selector + ); + } +} diff --git a/cli/project-template/src/templates/token/ERC721/Enumerable/Burn/ERC721EnumerableBurnFacet.sol b/cli/project-template/src/templates/token/ERC721/Enumerable/Burn/ERC721EnumerableBurnFacet.sol new file mode 100644 index 00000000..c5d99eea --- /dev/null +++ b/cli/project-template/src/templates/token/ERC721/Enumerable/Burn/ERC721EnumerableBurnFacet.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC-721 Enumerable Token + * @notice A complete, dependency-free ERC-721 implementation with enumeration support using a custom storage layout. + * @dev Provides metadata, ownership, approvals, enumeration, safe transfers, minting, and burning features. + */ +contract ERC721EnumerableBurnFacet { + /** + * @notice Thrown when operating on a non-existent token. + */ + error ERC721NonexistentToken(uint256 _tokenId); + /** + * @notice Thrown when the operator lacks sufficient approval for a transfer. + */ + error ERC721InsufficientApproval(address _operator, uint256 _tokenId); + + /** + * @notice Emitted when a token is transferred between addresses. + */ + event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId); + + bytes32 constant STORAGE_POSITION = keccak256("erc721.enumerable"); + + /** + * @custom:storage-location erc8042:erc721.enumerable + */ + struct ERC721EnumerableStorage { + mapping(address owner => mapping(uint256 index => uint256 tokenId)) ownerTokens; + mapping(uint256 tokenId => uint256 ownerTokensIndex) ownerTokensIndex; + uint256[] allTokens; + mapping(uint256 tokenId => uint256 allTokensIndex) allTokensIndex; + } + + /** + * @notice Returns the storage struct used by this facet. + * @return s The ERC721Enumerable storage struct. + */ + function getStorage() internal pure returns (ERC721EnumerableStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + bytes32 constant ERC721_STORAGE_POSITION = keccak256("erc721"); + + /** + * @custom:storage-location erc8042:erc721 + */ + struct ERC721Storage { + mapping(uint256 tokenId => address owner) ownerOf; + mapping(address owner => uint256 balance) balanceOf; + mapping(address owner => mapping(address operator => bool approved)) isApprovedForAll; + mapping(uint256 tokenId => address approved) approved; + } + + /** + * @notice Returns a pointer to the ERC-721 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC721Storage struct in storage. + */ + function getERC721Storage() internal pure returns (ERC721Storage storage s) { + bytes32 position = ERC721_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Burns (destroys) a token, removing it from enumeration tracking. + * @param _tokenId The ID of the token to burn. + */ + function burn(uint256 _tokenId) external { + ERC721EnumerableStorage storage s = getStorage(); + ERC721Storage storage erc721Storage = getERC721Storage(); + address owner = erc721Storage.ownerOf[_tokenId]; + if (owner == address(0)) { + revert ERC721NonexistentToken(_tokenId); + } + + if (msg.sender != owner) { + if (!erc721Storage.isApprovedForAll[owner][msg.sender] && msg.sender != erc721Storage.approved[_tokenId]) { + revert ERC721InsufficientApproval(msg.sender, _tokenId); + } + } + + delete erc721Storage.ownerOf[_tokenId]; + delete erc721Storage.approved[_tokenId]; + + unchecked { + /** + * Remove from owner's list + */ + uint256 tokenIndex = s.ownerTokensIndex[_tokenId]; + uint256 lastTokenIndex = erc721Storage.balanceOf[owner] - 1; + if (tokenIndex != lastTokenIndex) { + uint256 lastTokenId = s.ownerTokens[owner][lastTokenIndex]; + s.ownerTokens[owner][tokenIndex] = lastTokenId; + s.ownerTokensIndex[lastTokenId] = tokenIndex; + } + erc721Storage.balanceOf[owner]--; + + /** + * Remove from all tokens list + */ + tokenIndex = s.allTokensIndex[_tokenId]; + lastTokenIndex = s.allTokens.length - 1; + + if (tokenIndex != lastTokenIndex) { + uint256 lastTokenId = s.allTokens[lastTokenIndex]; + s.allTokens[tokenIndex] = lastTokenId; + s.allTokensIndex[lastTokenId] = tokenIndex; + } + s.allTokens.pop(); + } + + emit Transfer(owner, address(0), _tokenId); + } + + /** + * @notice Exports the function selectors of the ERC721EnumerableBurnFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC721EnumerableBurnFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.burn.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC721/Enumerable/Burn/ERC721EnumerableBurnMod.sol b/cli/project-template/src/templates/token/ERC721/Enumerable/Burn/ERC721EnumerableBurnMod.sol new file mode 100644 index 00000000..8857c978 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC721/Enumerable/Burn/ERC721EnumerableBurnMod.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Thrown when operating on a non-existent token. + */ +error ERC721NonexistentToken(uint256 _tokenId); + +/** + * @notice Emitted when a token is transferred between addresses. + */ +event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId); + +bytes32 constant STORAGE_POSITION = keccak256("erc721.enumerable"); + +/** + * @custom:storage-location erc8042:erc721.enumerable + */ +struct ERC721EnumerableStorage { + mapping(address owner => mapping(uint256 index => uint256 tokenId)) ownerTokens; + mapping(uint256 tokenId => uint256 ownerTokensIndex) ownerTokensIndex; + uint256[] allTokens; + mapping(uint256 tokenId => uint256 allTokensIndex) allTokensIndex; +} + +/** + * @notice Returns the storage struct used by this facet. + * @return s The ERC721Enumerable storage struct. + */ +function getStorage() pure returns (ERC721EnumerableStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +bytes32 constant ERC721_STORAGE_POSITION = keccak256("erc721"); + +/** + * @custom:storage-location erc8042:erc721 + */ +struct ERC721Storage { + mapping(uint256 tokenId => address owner) ownerOf; + mapping(address owner => uint256 balance) balanceOf; + mapping(address owner => mapping(address operator => bool approved)) isApprovedForAll; + mapping(uint256 tokenId => address approved) approved; +} + +/** + * @notice Returns a pointer to the ERC-721 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC721Storage struct in storage. + */ +function getERC721Storage() pure returns (ERC721Storage storage s) { + bytes32 position = ERC721_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Burns (destroys) a token, removing it from enumeration tracking. + * @dev This module does not check for approval. Use the facet for approval-checked burns. + * @param _tokenId The ID of the token to burn. + */ +function burn(uint256 _tokenId) { + ERC721EnumerableStorage storage s = getStorage(); + ERC721Storage storage erc721Storage = getERC721Storage(); + address owner = erc721Storage.ownerOf[_tokenId]; + if (owner == address(0)) { + revert ERC721NonexistentToken(_tokenId); + } + + delete erc721Storage.ownerOf[_tokenId]; + delete erc721Storage.approved[_tokenId]; + + unchecked { + /** + * Remove from owner's list + */ + uint256 tokenIndex = s.ownerTokensIndex[_tokenId]; + uint256 lastTokenIndex = erc721Storage.balanceOf[owner] - 1; + if (tokenIndex != lastTokenIndex) { + uint256 lastTokenId = s.ownerTokens[owner][lastTokenIndex]; + s.ownerTokens[owner][tokenIndex] = lastTokenId; + s.ownerTokensIndex[lastTokenId] = tokenIndex; + } + erc721Storage.balanceOf[owner]--; + + /** + * Remove from all tokens list + */ + tokenIndex = s.allTokensIndex[_tokenId]; + lastTokenIndex = s.allTokens.length - 1; + if (tokenIndex != lastTokenIndex) { + uint256 lastTokenId = s.allTokens[lastTokenIndex]; + s.allTokens[tokenIndex] = lastTokenId; + s.allTokensIndex[lastTokenId] = tokenIndex; + } + s.allTokens.pop(); + } + + emit Transfer(owner, address(0), _tokenId); +} + diff --git a/cli/project-template/src/templates/token/ERC721/Enumerable/Data/ERC721EnumerableDataFacet.sol b/cli/project-template/src/templates/token/ERC721/Enumerable/Data/ERC721EnumerableDataFacet.sol new file mode 100644 index 00000000..8aa2ba58 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC721/Enumerable/Data/ERC721EnumerableDataFacet.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +contract ERC721EnumerableDataFacet { + /** + * @notice Thrown when an index is out of bounds during enumeration. + */ + error ERC721OutOfBoundsIndex(address _owner, uint256 _index); + + bytes32 constant STORAGE_POSITION = keccak256("erc721.enumerable"); + + /** + * @custom:storage-location erc8042:erc721.enumerable + */ + struct ERC721EnumerableStorage { + mapping(address owner => mapping(uint256 index => uint256 tokenId)) ownerTokens; + mapping(uint256 tokenId => uint256 ownerTokensIndex) ownerTokensIndex; + uint256[] allTokens; + } + + /** + * @notice Returns the storage struct used by this facet. + * @return s The ERC721Enumerable storage struct. + */ + function getStorage() internal pure returns (ERC721EnumerableStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + bytes32 constant ERC721_STORAGE_POSITION = keccak256("erc721"); + + /** + * @custom:storage-location erc8042:erc721 + */ + struct ERC721Storage { + mapping(uint256 tokenId => address owner) ownerOf; + mapping(address owner => uint256 balance) balanceOf; + } + + /** + * @notice Returns a pointer to the ERC-721 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC721Storage struct in storage. + */ + function getERC721Storage() internal pure returns (ERC721Storage storage s) { + bytes32 position = ERC721_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns the total number of tokens in existence. + * @return The total supply of tokens. + */ + function totalSupply() external view returns (uint256) { + return getStorage().allTokens.length; + } + + /** + * @notice Returns a token ID owned by a given address at a specific index. + * @param _owner The address to query. + * @param _index The index of the token. + * @return The token ID owned by `_owner` at `_index`. + */ + function tokenOfOwnerByIndex(address _owner, uint256 _index) external view returns (uint256) { + ERC721EnumerableStorage storage s = getStorage(); + ERC721Storage storage erc721Storage = getERC721Storage(); + if (_index >= erc721Storage.balanceOf[_owner]) { + revert ERC721OutOfBoundsIndex(_owner, _index); + } + return s.ownerTokens[_owner][_index]; + } + + /** + * @notice Enumerate valid NFTs + * @dev Throws if `_index` >= `totalSupply()`. + * @param _index A counter less than `totalSupply()` + * @return The token identifier for the `_index`th NFT, + */ + function tokenByIndex(uint256 _index) external view returns (uint256) { + ERC721EnumerableStorage storage s = getStorage(); + if (_index >= s.allTokens.length) { + revert ERC721OutOfBoundsIndex(address(0), _index); + } + return s.allTokens[_index]; + } + + /** + * @notice Exports the function selectors of the ERC721EnumerableDataFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC721EnumerableDataFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.totalSupply.selector, this.tokenOfOwnerByIndex.selector, this.tokenByIndex.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC721/Enumerable/Mint/ERC721EnumerableMintMod.sol b/cli/project-template/src/templates/token/ERC721/Enumerable/Mint/ERC721EnumerableMintMod.sol new file mode 100644 index 00000000..28e74f9f --- /dev/null +++ b/cli/project-template/src/templates/token/ERC721/Enumerable/Mint/ERC721EnumerableMintMod.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Thrown when the sender address is invalid. + * @param _sender The invalid sender address. + */ +error ERC721InvalidSender(address _sender); + +/** + * @notice Thrown when the receiver address is invalid. + * @param _receiver The invalid receiver address. + */ +error ERC721InvalidReceiver(address _receiver); + +/** + * @notice Emitted when a token is transferred between addresses. + */ +event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId); + +bytes32 constant STORAGE_POSITION = keccak256("erc721.enumerable"); + +/** + * @custom:storage-location erc8042:erc721.enumerable + */ +struct ERC721EnumerableStorage { + mapping(address owner => mapping(uint256 index => uint256 tokenId)) ownerTokens; + mapping(uint256 tokenId => uint256 ownerTokensIndex) ownerTokensIndex; + uint256[] allTokens; + mapping(uint256 tokenId => uint256 allTokensIndex) allTokensIndex; +} + +/** + * @notice Returns the storage struct used by this facet. + * @return s The ERC721Enumerable storage struct. + */ +function getStorage() pure returns (ERC721EnumerableStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +bytes32 constant ERC721_STORAGE_POSITION = keccak256("erc721"); + +/** + * @custom:storage-location erc8042:erc721 + */ +struct ERC721Storage { + mapping(uint256 tokenId => address owner) ownerOf; + mapping(address owner => uint256 balance) balanceOf; + mapping(address owner => mapping(address operator => bool approved)) isApprovedForAll; + mapping(uint256 tokenId => address approved) approved; +} + +/** + * @notice Returns a pointer to the ERC-721 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC721Storage struct in storage. + */ +function getERC721Storage() pure returns (ERC721Storage storage s) { + bytes32 position = ERC721_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Mints a new ERC-721 token to the specified address, adding it to enumeration lists. + * @dev Reverts if the receiver address is zero or if the token already exists. + * @param _to The address that will own the newly minted token. + * @param _tokenId The ID of the token to mint. + */ +function mint(address _to, uint256 _tokenId) { + ERC721EnumerableStorage storage s = getStorage(); + ERC721Storage storage erc721Storage = getERC721Storage(); + if (_to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + if (erc721Storage.ownerOf[_tokenId] != address(0)) { + revert ERC721InvalidSender(address(0)); + } + + erc721Storage.ownerOf[_tokenId] = _to; + uint256 tokenIndex = erc721Storage.balanceOf[_to]; + s.ownerTokensIndex[_tokenId] = tokenIndex; + s.ownerTokens[_to][tokenIndex] = _tokenId; + unchecked { + erc721Storage.balanceOf[_to] = tokenIndex + 1; + } + s.allTokensIndex[_tokenId] = s.allTokens.length; + s.allTokens.push(_tokenId); + emit Transfer(address(0), _to, _tokenId); +} + diff --git a/cli/project-template/src/templates/token/ERC721/Enumerable/Transfer/ERC721EnumerableTransferFacet.sol b/cli/project-template/src/templates/token/ERC721/Enumerable/Transfer/ERC721EnumerableTransferFacet.sol new file mode 100644 index 00000000..f2919a11 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC721/Enumerable/Transfer/ERC721EnumerableTransferFacet.sol @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC721 Receiver Interface + * @notice Interface for contracts that want to support safe ERC721 token transfers. + * @dev Implementers must return the function selector to confirm token receipt. + */ +interface IERC721Receiver { + /** + * @notice Handles the receipt of an NFT. + * @param _operator The address which initiated the transfer. + * @param _from The previous owner of the token. + * @param _tokenId The NFT identifier being transferred. + * @param _data Additional data with no specified format. + * @return A bytes4 value indicating acceptance of the transfer. + */ + function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes calldata _data) + external + returns (bytes4); +} + +/** + * @title ERC-721 Enumerable Token + * @notice A complete, dependency-free ERC-721 implementation with enumeration support using a custom storage layout. + * @dev Provides metadata, ownership, approvals, enumeration, safe transfers, minting, and burning features. + */ +contract ERC721EnumerableTransferFacet { + /** + * @notice Thrown when operating on a non-existent token. + */ + error ERC721NonexistentToken(uint256 _tokenId); + /** + * @notice Thrown when the provided owner does not match the actual owner of the token. + */ + error ERC721IncorrectOwner(address _sender, uint256 _tokenId, address _owner); + /** + * @notice Thrown when the receiver address is invalid. + */ + error ERC721InvalidReceiver(address _receiver); + /** + * @notice Thrown when the operator lacks sufficient approval for a transfer. + */ + error ERC721InsufficientApproval(address _operator, uint256 _tokenId); + + /** + * @notice Emitted when a token is transferred between addresses. + */ + event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId); + + bytes32 constant STORAGE_POSITION = keccak256("erc721.enumerable"); + + /** + * @custom:storage-location erc8042:erc721.enumerable + */ + struct ERC721EnumerableStorage { + mapping(address owner => mapping(uint256 index => uint256 tokenId)) ownerTokens; + mapping(uint256 tokenId => uint256 ownerTokensIndex) ownerTokensIndex; + uint256[] allTokens; + mapping(uint256 tokenId => uint256 allTokensIndex) allTokensIndex; + } + + /** + * @notice Returns the storage struct used by this facet. + * @return s The ERC721Enumerable storage struct. + */ + function getStorage() internal pure returns (ERC721EnumerableStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + bytes32 constant ERC721_STORAGE_POSITION = keccak256("erc721"); + + /** + * @custom:storage-location erc8042:erc721 + */ + struct ERC721Storage { + mapping(uint256 tokenId => address owner) ownerOf; + mapping(address owner => uint256 balance) balanceOf; + mapping(address owner => mapping(address operator => bool approved)) isApprovedForAll; + mapping(uint256 tokenId => address approved) approved; + } + + /** + * @notice Returns a pointer to the ERC-721 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC721Storage struct in storage. + */ + function getERC721Storage() internal pure returns (ERC721Storage storage s) { + bytes32 position = ERC721_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Internal function to transfer ownership of a token ID. + * @param _from The address sending the token. + * @param _to The address receiving the token. + * @param _tokenId The token ID being transferred. + */ + function internalTransferFrom(address _from, address _to, uint256 _tokenId) internal { + ERC721EnumerableStorage storage s = getStorage(); + ERC721Storage storage erc721Storage = getERC721Storage(); + if (_to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + address owner = erc721Storage.ownerOf[_tokenId]; + if (owner == address(0)) { + revert ERC721NonexistentToken(_tokenId); + } + if (owner != _from) { + revert ERC721IncorrectOwner(_from, _tokenId, owner); + } + if (msg.sender != _from) { + if (!erc721Storage.isApprovedForAll[_from][msg.sender] && msg.sender != erc721Storage.approved[_tokenId]) { + revert ERC721InsufficientApproval(msg.sender, _tokenId); + } + } + delete erc721Storage.approved[_tokenId]; + unchecked { + uint256 tokenIndex = s.ownerTokensIndex[_tokenId]; + uint256 lastTokenIndex = erc721Storage.balanceOf[_from] - 1; + if (tokenIndex != lastTokenIndex) { + uint256 lastTokenId = s.ownerTokens[_from][lastTokenIndex]; + s.ownerTokens[_from][tokenIndex] = lastTokenId; + s.ownerTokensIndex[lastTokenId] = tokenIndex; + } + erc721Storage.balanceOf[_from]--; + + tokenIndex = erc721Storage.balanceOf[_to]; + s.ownerTokensIndex[_tokenId] = tokenIndex; + s.ownerTokens[_to][tokenIndex] = _tokenId; + erc721Storage.balanceOf[_to] = tokenIndex + 1; + erc721Storage.ownerOf[_tokenId] = _to; + } + emit Transfer(_from, _to, _tokenId); + } + + /** + * @notice Transfers a token from one address to another. + * @param _from The current owner of the token. + * @param _to The recipient address. + * @param _tokenId The token ID to transfer. + */ + function transferFrom(address _from, address _to, uint256 _tokenId) external { + internalTransferFrom(_from, _to, _tokenId); + } + + /** + * @notice Safely transfers a token, checking for receiver contract compatibility. + * @param _from The current owner of the token. + * @param _to The recipient address. + * @param _tokenId The token ID to transfer. + */ + function safeTransferFrom(address _from, address _to, uint256 _tokenId) external { + internalTransferFrom(_from, _to, _tokenId); + if (_to.code.length > 0) { + try IERC721Receiver(_to).onERC721Received(msg.sender, _from, _tokenId, "") returns (bytes4 returnValue) { + if (returnValue != IERC721Receiver.onERC721Received.selector) { + revert ERC721InvalidReceiver(_to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + revert ERC721InvalidReceiver(_to); + } + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } + } + + /** + * @notice Safely transfers a token with additional data. + * @param _from The current owner of the token. + * @param _to The recipient address. + * @param _tokenId The token ID to transfer. + * @param _data Additional data to send to the receiver contract. + */ + function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes calldata _data) external { + internalTransferFrom(_from, _to, _tokenId); + if (_to.code.length > 0) { + try IERC721Receiver(_to).onERC721Received(msg.sender, _from, _tokenId, _data) returns (bytes4 returnValue) { + if (returnValue != IERC721Receiver.onERC721Received.selector) { + revert ERC721InvalidReceiver(_to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + revert ERC721InvalidReceiver(_to); + } + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } + } + + /** + * @notice Exports the function selectors of the ERC721EnumerableTransferFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC721EnumerableTransferFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat( + this.transferFrom.selector, + bytes4(keccak256("safeTransferFrom(address,address,uint256)")), + bytes4(keccak256("safeTransferFrom(address,address,uint256,bytes)")) + ); + } +} diff --git a/cli/project-template/src/templates/token/ERC721/Enumerable/Transfer/ERC721EnumerableTransferMod.sol b/cli/project-template/src/templates/token/ERC721/Enumerable/Transfer/ERC721EnumerableTransferMod.sol new file mode 100644 index 00000000..6e5b2f5b --- /dev/null +++ b/cli/project-template/src/templates/token/ERC721/Enumerable/Transfer/ERC721EnumerableTransferMod.sol @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title ERC721 Receiver Interface + * @notice Interface for contracts that want to support safe ERC721 token transfers. + * @dev Implementers must return the function selector to confirm token receipt. + */ +interface IERC721Receiver { + /** + * @notice Handles the receipt of an NFT. + * @param _operator The address which initiated the transfer. + * @param _from The previous owner of the token. + * @param _tokenId The NFT identifier being transferred. + * @param _data Additional data with no specified format. + * @return A bytes4 value indicating acceptance of the transfer. + */ + function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes calldata _data) + external + returns (bytes4); +} + +/** + * @notice Thrown when operating on a non-existent token. + */ +error ERC721NonexistentToken(uint256 _tokenId); +/** + * @notice Thrown when the provided owner does not match the actual owner of the token. + */ +error ERC721IncorrectOwner(address _sender, uint256 _tokenId, address _owner); +/** + * @notice Thrown when the receiver address is invalid. + */ +error ERC721InvalidReceiver(address _receiver); + +/** + * @notice Emitted when a token is transferred between addresses. + */ +event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId); + +bytes32 constant STORAGE_POSITION = keccak256("erc721.enumerable"); + +/** + * @custom:storage-location erc8042:erc721.enumerable + */ +struct ERC721EnumerableStorage { + mapping(address owner => mapping(uint256 index => uint256 tokenId)) ownerTokens; + mapping(uint256 tokenId => uint256 ownerTokensIndex) ownerTokensIndex; + uint256[] allTokens; + mapping(uint256 tokenId => uint256 allTokensIndex) allTokensIndex; +} + +/** + * @notice Returns the storage struct used by this facet. + * @return s The ERC721Enumerable storage struct. + */ +function getStorage() pure returns (ERC721EnumerableStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +bytes32 constant ERC721_STORAGE_POSITION = keccak256("erc721"); + +/** + * @custom:storage-location erc8042:erc721 + */ +struct ERC721Storage { + mapping(uint256 tokenId => address owner) ownerOf; + mapping(address owner => uint256 balance) balanceOf; + mapping(address owner => mapping(address operator => bool approved)) isApprovedForAll; + mapping(uint256 tokenId => address approved) approved; +} + +/** + * @notice Returns a pointer to the ERC-721 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC721Storage struct in storage. + */ +function getERC721Storage() pure returns (ERC721Storage storage s) { + bytes32 position = ERC721_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Internal function to transfer ownership of a token ID. + * @param _from The address sending the token. + * @param _to The address receiving the token. + * @param _tokenId The token ID being transferred. + */ +function transferFrom(address _from, address _to, uint256 _tokenId) { + ERC721EnumerableStorage storage s = getStorage(); + ERC721Storage storage erc721Storage = getERC721Storage(); + if (_to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + address owner = erc721Storage.ownerOf[_tokenId]; + if (owner == address(0)) { + revert ERC721NonexistentToken(_tokenId); + } + if (owner != _from) { + revert ERC721IncorrectOwner(_from, _tokenId, owner); + } + delete erc721Storage.approved[_tokenId]; + + unchecked { + uint256 tokenIndex = s.ownerTokensIndex[_tokenId]; + uint256 lastTokenIndex = erc721Storage.balanceOf[_from] - 1; + if (tokenIndex != lastTokenIndex) { + uint256 lastTokenId = s.ownerTokens[_from][lastTokenIndex]; + s.ownerTokens[_from][tokenIndex] = lastTokenId; + s.ownerTokensIndex[lastTokenId] = tokenIndex; + } + erc721Storage.balanceOf[_from]--; + + tokenIndex = erc721Storage.balanceOf[_to]; + s.ownerTokensIndex[_tokenId] = tokenIndex; + s.ownerTokens[_to][tokenIndex] = _tokenId; + erc721Storage.balanceOf[_to] = tokenIndex + 1; + erc721Storage.ownerOf[_tokenId] = _to; + } + emit Transfer(_from, _to, _tokenId); +} + +/** + * @notice Safely transfers a token, checking for receiver contract compatibility. + * @param _from The current owner of the token. + * @param _to The recipient address. + * @param _tokenId The token ID to transfer. + */ +function safeTransferFrom(address _from, address _to, uint256 _tokenId) { + transferFrom(_from, _to, _tokenId); + if (_to.code.length > 0) { + try IERC721Receiver(_to).onERC721Received(msg.sender, _from, _tokenId, "") returns (bytes4 returnValue) { + if (returnValue != IERC721Receiver.onERC721Received.selector) { + revert ERC721InvalidReceiver(_to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + revert ERC721InvalidReceiver(_to); + } + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } +} + +/** + * @notice Safely transfers a token with additional data. + * @param _from The current owner of the token. + * @param _to The recipient address. + * @param _tokenId The token ID to transfer. + * @param _data Additional data to send to the receiver contract. + */ +function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes memory _data) { + transferFrom(_from, _to, _tokenId); + if (_to.code.length > 0) { + try IERC721Receiver(_to).onERC721Received(msg.sender, _from, _tokenId, _data) returns (bytes4 returnValue) { + if (returnValue != IERC721Receiver.onERC721Received.selector) { + revert ERC721InvalidReceiver(_to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + revert ERC721InvalidReceiver(_to); + } + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } +} + diff --git a/cli/project-template/src/templates/token/ERC721/Metadata/ERC721MetadataFacet.sol b/cli/project-template/src/templates/token/ERC721/Metadata/ERC721MetadataFacet.sol new file mode 100644 index 00000000..0c79e43a --- /dev/null +++ b/cli/project-template/src/templates/token/ERC721/Metadata/ERC721MetadataFacet.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/** + * @title ERC-721 Token + * @notice A complete, dependency-free ERC-721 implementation using the diamond storage pattern. + * @dev This facet provides metadata, ownership, approvals, safe transfers, minting, burning, and helpers. + */ +contract ERC721MetadataFacet { + /** + * @notice Error indicating that the queried token does not exist. + */ + error ERC721NonexistentToken(uint256 _tokenId); + + /** + * @notice Error indicating the queried owner address is invalid (zero address). + */ + error ERC721InvalidOwner(address _owner); + + bytes32 constant STORAGE_POSITION = keccak256("erc721.metadata"); + + /** + * @custom:storage-location erc8042:erc721.metadata + */ + struct ERC721MetadataStorage { + string name; + string symbol; + string baseURI; + } + + bytes32 constant ERC721_STORAGE_POSITION = keccak256("erc721"); + + /** + * @custom:storage-location erc8042:erc721 + */ + struct ERC721Storage { + mapping(uint256 tokenId => address owner) ownerOf; + mapping(address owner => uint256 balance) balanceOf; + mapping(address owner => mapping(address operator => bool approved)) isApprovedForAll; + mapping(uint256 tokenId => address approved) approved; + } + + /** + * @notice Returns a pointer to the ERC-721 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC721Storage struct in storage. + */ + function getStorage() internal pure returns (ERC721MetadataStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns a pointer to the ERC-721 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC721Storage struct in storage. + */ + function getERC721Storage() internal pure returns (ERC721Storage storage s) { + bytes32 position = ERC721_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns the token collection name. + * @return The name of the token collection. + */ + function name() external view returns (string memory) { + return getStorage().name; + } + + /** + * @notice Returns the token collection symbol. + * @return The symbol of the token collection. + */ + function symbol() external view returns (string memory) { + return getStorage().symbol; + } + + /** + * @notice Provide the metadata URI for a given token ID. + * @param _tokenId tokenID of the NFT to query the metadata from + * @return the URI providing the detailed metadata of the specified tokenID + */ + function tokenURI(uint256 _tokenId) external view returns (string memory) { + ERC721MetadataStorage storage s = getStorage(); + ERC721Storage storage erc721Storage = getERC721Storage(); + address owner = erc721Storage.ownerOf[_tokenId]; + if (owner == address(0)) { + revert ERC721NonexistentToken(_tokenId); + } + if (bytes(s.baseURI).length == 0) { + return ""; + } + if (_tokenId == 0) { + return string.concat(s.baseURI, "0"); + } + /** + * Convert _tokenId to string + */ + uint256 temp = _tokenId; + uint256 stringLength; + while (temp != 0) { + stringLength++; + temp /= 10; + } + bytes memory tokenIdString = new bytes(stringLength); + while (_tokenId != 0) { + stringLength--; + /** + * Convert each digit to its ASCII representation + * by adding 48 to get the ASCII code for the digit. + * Then store it in the byte array + */ + tokenIdString[stringLength] = bytes1(uint8(48 + (_tokenId % 10))); + _tokenId /= 10; + } + return string.concat(s.baseURI, string(tokenIdString)); + } + + /** + * @notice Exports the function selectors of the ERC721MetadataFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC721MetadataFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.name.selector, this.symbol.selector, this.tokenURI.selector); + } +} diff --git a/cli/project-template/src/templates/token/ERC721/Metadata/ERC721MetadataMod.sol b/cli/project-template/src/templates/token/ERC721/Metadata/ERC721MetadataMod.sol new file mode 100644 index 00000000..c5be1f98 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC721/Metadata/ERC721MetadataMod.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/** + * @notice Error indicating that the queried token does not exist. + */ +error ERC721NonexistentToken(uint256 _tokenId); + +/** + * @notice Error indicating the queried owner address is invalid (zero address). + */ +error ERC721InvalidOwner(address _owner); + +bytes32 constant STORAGE_POSITION = keccak256("erc721.metadata"); + +/** + * @custom:storage-location erc8042:erc721.metadata + */ +struct ERC721MetadataStorage { + string name; + string symbol; + string baseURI; +} + +/** + * @notice Returns a pointer to the ERC-721 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC721Storage struct in storage. + */ +function getStorage() pure returns (ERC721MetadataStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +function setMetadata(string memory _name, string memory _symbol, string memory _baseURI) { + ERC721MetadataStorage storage s = getStorage(); + s.name = _name; + s.symbol = _symbol; + s.baseURI = _baseURI; +} diff --git a/cli/project-template/src/templates/token/ERC721/Mint/ERC721MintMod.sol b/cli/project-template/src/templates/token/ERC721/Mint/ERC721MintMod.sol new file mode 100644 index 00000000..17a9d158 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC721/Mint/ERC721MintMod.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @notice Thrown when the sender address is invalid (e.g., zero address). + * @param _sender The invalid sender address. + */ +error ERC721InvalidSender(address _sender); + +/** + * @notice Thrown when the receiver address is invalid (e.g., zero address). + * @param _receiver The invalid receiver address. + */ +error ERC721InvalidReceiver(address _receiver); + +/** + * @notice Emitted when ownership of a token changes, including minting and burning. + * @param _from The address transferring the token, or zero for minting. + * @param _to The address receiving the token, or zero for burning. + * @param _tokenId The ID of the token being transferred. + */ +event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId); + +/** + * @dev Storage position constant defined via keccak256 hash of diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc721"); + +/* + * @custom:storage-location erc8042:erc721 + * @notice Storage layout for ERC-721 token management. + * @dev Defines ownership, balances, approvals, and operator mappings per ERC-721 standard. + */ +struct ERC721Storage { + mapping(uint256 tokenId => address owner) ownerOf; + mapping(address owner => uint256 balance) balanceOf; + mapping(address owner => mapping(address operator => bool approved)) isApprovedForAll; + mapping(uint256 tokenId => address approved) approved; +} + +/** + * @notice Returns the ERC-721 storage struct from its predefined slot. + * @dev Uses inline assembly to access diamond storage location. + * @return s The storage reference for ERC-721 state variables. + */ +function getStorage() pure returns (ERC721Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Mints a new ERC-721 token to the specified address. + * @dev Reverts if the receiver address is zero or if the token already exists. + * @param _to The address that will own the newly minted token. + * @param _tokenId The ID of the token to mint. + */ +function mint(address _to, uint256 _tokenId) { + ERC721Storage storage s = getStorage(); + if (_to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + if (s.ownerOf[_tokenId] != address(0)) { + revert ERC721InvalidSender(address(0)); + } + s.ownerOf[_tokenId] = _to; + unchecked { + s.balanceOf[_to]++; + } + emit Transfer(address(0), _to, _tokenId); +} + diff --git a/cli/project-template/src/templates/token/ERC721/Transfer/ERC721TransferFacet.sol b/cli/project-template/src/templates/token/ERC721/Transfer/ERC721TransferFacet.sol new file mode 100644 index 00000000..e6c040ef --- /dev/null +++ b/cli/project-template/src/templates/token/ERC721/Transfer/ERC721TransferFacet.sol @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/** + * @title ERC-721 Token Receiver Interface + * @notice Interface for contracts that want to handle safe transfers of ERC-721 tokens. + * @dev Contracts implementing this must return the selector to confirm token receipt. + */ +interface IERC721Receiver { + /** + * @notice Handles the receipt of an NFT. + * @param _operator The address which called `safeTransferFrom`. + * @param _from The previous owner of the token. + * @param _tokenId The NFT identifier being transferred. + * @param _data Additional data with no specified format. + * @return The selector to confirm the token transfer. + */ + function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes calldata _data) + external + returns (bytes4); +} + +/** + * @title ERC-721 Token + * @notice A complete, dependency-free ERC-721 implementation using the diamond storage pattern. + * @dev This facet provides metadata, ownership, approvals, safe transfers, minting, burning, and helpers. + */ +contract ERC721TransferFacet { + /** + * @notice Error indicating that the queried token does not exist. + */ + error ERC721NonexistentToken(uint256 _tokenId); + + /** + * @notice Error indicating the sender does not match the token owner. + */ + error ERC721IncorrectOwner(address _sender, uint256 _tokenId, address _owner); + + /** + * @notice Error indicating the receiver address is invalid. + */ + error ERC721InvalidReceiver(address _receiver); + + /** + * @notice Error indicating the operator lacks approval to transfer the given token. + */ + error ERC721InsufficientApproval(address _operator, uint256 _tokenId); + + /** + * @notice Emitted when ownership of an NFT changes by any mechanism. + */ + event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId); + + bytes32 constant STORAGE_POSITION = keccak256("erc721"); + + /** + * @custom:storage-location erc8042:erc721 + */ + struct ERC721Storage { + mapping(uint256 tokenId => address owner) ownerOf; + mapping(address owner => uint256 balance) balanceOf; + mapping(address owner => mapping(address operator => bool approved)) isApprovedForAll; + mapping(uint256 tokenId => address approved) approved; + } + + /** + * @notice Returns a pointer to the ERC-721 storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The ERC721Storage struct in storage. + */ + function getStorage() internal pure returns (ERC721Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @dev Internal function to transfer a token, checking for ownership and approval. + * @param _from The current owner of the token. + * @param _to The address to receive the token. + * @param _tokenId The token ID to transfer. + */ + function internalTransferFrom(address _from, address _to, uint256 _tokenId) internal { + ERC721Storage storage s = getStorage(); + if (_to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + address owner = s.ownerOf[_tokenId]; + if (owner == address(0)) { + revert ERC721NonexistentToken(_tokenId); + } + if (owner != _from) { + revert ERC721IncorrectOwner(_from, _tokenId, owner); + } + if (msg.sender != _from) { + if (!s.isApprovedForAll[_from][msg.sender] && msg.sender != s.approved[_tokenId]) { + revert ERC721InsufficientApproval(msg.sender, _tokenId); + } + } + delete s.approved[_tokenId]; + unchecked { + s.balanceOf[_from]--; + s.balanceOf[_to]++; + } + s.ownerOf[_tokenId] = _to; + emit Transfer(_from, _to, _tokenId); + } + + /** + * @notice Transfers a token from one address to another. + * @param _from The current owner of the token. + * @param _to The address to receive the token. + * @param _tokenId The token ID to transfer. + */ + function transferFrom(address _from, address _to, uint256 _tokenId) external { + internalTransferFrom(_from, _to, _tokenId); + } + + /** + * @notice Safely transfers a token, checking if the receiver can handle ERC-721 tokens. + * @param _from The current owner of the token. + * @param _to The address to receive the token. + * @param _tokenId The token ID to transfer. + */ + function safeTransferFrom(address _from, address _to, uint256 _tokenId) external { + internalTransferFrom(_from, _to, _tokenId); + if (_to.code.length > 0) { + try IERC721Receiver(_to).onERC721Received(msg.sender, _from, _tokenId, "") returns (bytes4 returnValue) { + if (returnValue != IERC721Receiver.onERC721Received.selector) { + revert ERC721InvalidReceiver(_to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + revert ERC721InvalidReceiver(_to); + } else { + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } + } + } + + /** + * @notice Safely transfers a token with additional data. + * @param _from The current owner of the token. + * @param _to The address to receive the token. + * @param _tokenId The token ID to transfer. + * @param _data Additional data with no specified format. + */ + function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes calldata _data) external { + internalTransferFrom(_from, _to, _tokenId); + if (_to.code.length > 0) { + try IERC721Receiver(_to).onERC721Received(msg.sender, _from, _tokenId, _data) returns (bytes4 returnValue) { + if (returnValue != IERC721Receiver.onERC721Received.selector) { + revert ERC721InvalidReceiver(_to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + revert ERC721InvalidReceiver(_to); + } else { + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } + } + } + + /** + * @notice Exports the function selectors of the ERC721TransferFacet + * @dev This function is use as a selector discovery mechanism for diamonds + * @return selectors The exported function selectors of the ERC721TransferFacet + */ + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat( + this.transferFrom.selector, + bytes4(keccak256("safeTransferFrom(address,address,uint256)")), + bytes4(keccak256("safeTransferFrom(address,address,uint256,bytes)")) + ); + } +} diff --git a/cli/project-template/src/templates/token/ERC721/Transfer/ERC721TransferMod.sol b/cli/project-template/src/templates/token/ERC721/Transfer/ERC721TransferMod.sol new file mode 100644 index 00000000..bd964cd2 --- /dev/null +++ b/cli/project-template/src/templates/token/ERC721/Transfer/ERC721TransferMod.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/* + * @title ERC-721 Transfer Module for Compose + */ + +/** + * @notice Thrown when attempting to interact with a non-existent token. + * @param _tokenId The ID of the token that does not exist. + */ +error ERC721NonexistentToken(uint256 _tokenId); + +/** + * @notice Thrown when the sender is not the owner of the token. + * @param _sender The address attempting the operation. + * @param _tokenId The ID of the token being transferred. + * @param _owner The actual owner of the token. + */ +error ERC721IncorrectOwner(address _sender, uint256 _tokenId, address _owner); + +/** + * @notice Thrown when the receiver address is invalid (e.g., zero address). + * @param _receiver The invalid receiver address. + */ +error ERC721InvalidReceiver(address _receiver); + +/** + * @notice Emitted when ownership of a token changes, including minting and burning. + * @param _from The address transferring the token, or zero for minting. + * @param _to The address receiving the token, or zero for burning. + * @param _tokenId The ID of the token being transferred. + */ +event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId); + +/** + * @dev Storage position constant defined via keccak256 hash of diamond storage identifier. + */ +bytes32 constant STORAGE_POSITION = keccak256("erc721"); + +/** + * @notice Storage layout for ERC-721 token management. + * @dev Defines ownership, balances, approvals, and operator mappings per ERC-721 standard. + * @custom:storage-location erc8042:erc721 + */ +struct ERC721Storage { + mapping(uint256 tokenId => address owner) ownerOf; + mapping(address owner => uint256 balance) balanceOf; + mapping(address owner => mapping(address operator => bool approved)) isApprovedForAll; + mapping(uint256 tokenId => address approved) approved; +} + +/** + * @notice Returns the ERC-721 storage struct from its predefined slot. + * @dev Uses inline assembly to access diamond storage location. + * @return s The storage reference for ERC-721 state variables. + */ +function getStorage() pure returns (ERC721Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Transfers ownership of a token ID from one address to another. + * @dev Validates ownership, approval, and receiver address before updating state. + * @param _from The current owner of the token. + * @param _to The address that will receive the token. + * @param _tokenId The ID of the token being transferred. + */ +function transferFrom(address _from, address _to, uint256 _tokenId) { + ERC721Storage storage s = getStorage(); + if (_to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + address owner = s.ownerOf[_tokenId]; + if (owner == address(0)) { + revert ERC721NonexistentToken(_tokenId); + } + if (owner != _from) { + revert ERC721IncorrectOwner(_from, _tokenId, owner); + } + delete s.approved[_tokenId]; + unchecked { + s.balanceOf[_from]--; + s.balanceOf[_to]++; + } + s.ownerOf[_tokenId] = _to; + emit Transfer(_from, _to, _tokenId); +} + diff --git a/cli/project-template/src/templates/token/Royalty/RoyaltyFacet.sol b/cli/project-template/src/templates/token/Royalty/RoyaltyFacet.sol new file mode 100644 index 00000000..33cf00a5 --- /dev/null +++ b/cli/project-template/src/templates/token/Royalty/RoyaltyFacet.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/** + * @title Royalty Facet - ERC-2981 NFT Royalty Standard Implementation + * @notice Implements royalty queries for NFT secondary sales per ERC-2981 standard. + * @dev Provides standardized royalty information to NFT marketplaces and platforms. + * Supports both default and per-token royalty configurations using diamond storage. + * This is an implementation of the ERC-2981 NFT Royalty Standard. + */ +contract RoyaltyFacet { + /** + * @notice Storage slot identifier for royalty storage. + */ + bytes32 constant STORAGE_POSITION = keccak256("erc2981"); + + /** + * @dev The denominator with which to interpret royalty fees as a percentage of sale price. + * Expressed in basis points where 10000 = 100%. This value aligns with the ERC-2981 + * specification and marketplace expectations. + */ + uint96 constant FEE_DENOMINATOR = 10000; + + /** + * @notice Structure containing royalty information. + * @param receiver The address that will receive royalty payments. + * @param royaltyFraction The royalty fee expressed in basis points. + */ + struct RoyaltyInfo { + address receiver; + uint96 royaltyFraction; + } + + /** + * @custom:storage-location erc8042:erc2981 + */ + struct RoyaltyStorage { + RoyaltyInfo defaultRoyaltyInfo; + mapping(uint256 tokenId => RoyaltyInfo) tokenRoyaltyInfo; + } + + /** + * @notice Returns a pointer to the royalty storage struct. + * @dev Uses inline assembly to access the storage slot defined by STORAGE_POSITION. + * @return s The RoyaltyStorage struct in storage. + */ + function getStorage() internal pure returns (RoyaltyStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice Returns royalty information for a given token and sale price. + * @dev Returns token-specific royalty if set, otherwise falls back to default royalty. + * Royalty amount is calculated as a percentage of the sale price using basis points. + * Implements the ERC-2981 royaltyInfo function. + * @param _tokenId The NFT asset queried for royalty information. + * @param _salePrice The sale price of the NFT asset specified by _tokenId. + * @return receiver The address designated to receive the royalty payment. + * @return royaltyAmount The royalty payment amount for _salePrice. + */ + function royaltyInfo(uint256 _tokenId, uint256 _salePrice) + external + view + returns (address receiver, uint256 royaltyAmount) + { + RoyaltyStorage storage s = getStorage(); + RoyaltyInfo memory royalty = s.tokenRoyaltyInfo[_tokenId]; + + if (royalty.receiver == address(0)) { + royalty = s.defaultRoyaltyInfo; + } + + receiver = royalty.receiver; + royaltyAmount = (_salePrice * royalty.royaltyFraction) / FEE_DENOMINATOR; + } +} diff --git a/cli/project-template/src/templates/token/Royalty/RoyaltyMod.sol b/cli/project-template/src/templates/token/Royalty/RoyaltyMod.sol new file mode 100644 index 00000000..ecb64dcd --- /dev/null +++ b/cli/project-template/src/templates/token/Royalty/RoyaltyMod.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/* Compose + * https://compose.diamonds + */ + +/* + * @title LibRoyalty - ERC-2981 Royalty Standard Library + * @notice Provides internal functions and storage layout for ERC-2981 royalty logic. + * @dev Uses ERC-8042 for storage location standardization. Compatible with OpenZeppelin's ERC2981 behavior. + * This is an implementation of the ERC-2981 NFT Royalty Standard. + */ + +/** + * @notice Thrown when default royalty fee exceeds 100% (10000 basis points). + * @param _numerator The fee numerator that exceeds the denominator. + * @param _denominator The fee denominator (10000 basis points). + */ +error ERC2981InvalidDefaultRoyalty(uint256 _numerator, uint256 _denominator); + +/** + * @notice Thrown when default royalty receiver is the zero address. + * @param _receiver The invalid receiver address. + */ +error ERC2981InvalidDefaultRoyaltyReceiver(address _receiver); + +/** + * @notice Thrown when token-specific royalty fee exceeds 100% (10000 basis points). + * @param _tokenId The token ID with invalid royalty configuration. + * @param _numerator The fee numerator that exceeds the denominator. + * @param _denominator The fee denominator (10000 basis points). + */ +error ERC2981InvalidTokenRoyalty(uint256 _tokenId, uint256 _numerator, uint256 _denominator); + +/** + * @notice Thrown when token-specific royalty receiver is the zero address. + * @param _tokenId The token ID with invalid royalty configuration. + * @param _receiver The invalid receiver address. + */ +error ERC2981InvalidTokenRoyaltyReceiver(uint256 _tokenId, address _receiver); + +bytes32 constant STORAGE_POSITION = keccak256("erc2981"); + +/** + * @dev The denominator with which to interpret royalty fees as a percentage of sale price. + * Expressed in basis points where 10000 = 100%. This value aligns with the ERC-2981 + * specification and marketplace expectations. Implemented as a constant for gas efficiency + * rather than the virtual function pattern, as Compose does not support inheritance-based + * customization. To modify this value, deploy a custom facet implementation. + */ +uint96 constant FEE_DENOMINATOR = 10000; + +/** + * @notice Structure containing royalty information. + * @param receiver The address that will receive royalty payments. + * @param royaltyFraction The royalty fee expressed in basis points. + */ +struct RoyaltyInfo { + address receiver; + uint96 royaltyFraction; +} + +/** + * @custom:storage-location erc8042:erc2981 + */ +struct RoyaltyStorage { + RoyaltyInfo defaultRoyaltyInfo; + mapping(uint256 tokenId => RoyaltyInfo) tokenRoyaltyInfo; +} + +/** + * @notice Returns the royalty storage struct from its predefined slot. + * @dev Uses inline assembly to access diamond storage location. + * @return s The storage reference for royalty state variables. + */ +function getStorage() pure returns (RoyaltyStorage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice Queries royalty information for a given token and sale price. + * @dev Returns token-specific royalty or falls back to default royalty. + * Royalty amount is calculated as a percentage of the sale price using basis points. + * Implements the ERC-2981 royaltyInfo function logic. + * @param _tokenId The NFT asset queried for royalty information. + * @param _salePrice The sale price of the NFT asset. + * @return receiver The address designated to receive the royalty payment. + * @return royaltyAmount The royalty payment amount for _salePrice. + */ +function royaltyInfo(uint256 _tokenId, uint256 _salePrice) view returns (address receiver, uint256 royaltyAmount) { + RoyaltyStorage storage s = getStorage(); + RoyaltyInfo memory royalty = s.tokenRoyaltyInfo[_tokenId]; + + if (royalty.receiver == address(0)) { + royalty = s.defaultRoyaltyInfo; + } + + receiver = royalty.receiver; + royaltyAmount = (_salePrice * royalty.royaltyFraction) / FEE_DENOMINATOR; +} + +/** + * @notice Sets the default royalty information that applies to all tokens. + * @dev Validates receiver and fee, then updates default royalty storage. + * @param _receiver The royalty recipient address. + * @param _feeNumerator The royalty fee in basis points. + */ +function setDefaultRoyalty(address _receiver, uint96 _feeNumerator) { + if (_feeNumerator > FEE_DENOMINATOR) { + revert ERC2981InvalidDefaultRoyalty(_feeNumerator, FEE_DENOMINATOR); + } + if (_receiver == address(0)) { + revert ERC2981InvalidDefaultRoyaltyReceiver(address(0)); + } + + RoyaltyStorage storage s = getStorage(); + s.defaultRoyaltyInfo = RoyaltyInfo(_receiver, _feeNumerator); +} + +/** + * @notice Removes default royalty information. + * @dev After calling this function, royaltyInfo will return (address(0), 0) for tokens without specific royalty. + */ +function deleteDefaultRoyalty() { + RoyaltyStorage storage s = getStorage(); + delete s.defaultRoyaltyInfo; +} + +/** + * @notice Sets royalty information for a specific token, overriding the default. + * @dev Validates receiver and fee, then updates token-specific royalty storage. + * @param _tokenId The token ID to configure royalty for. + * @param _receiver The royalty recipient address. + * @param _feeNumerator The royalty fee in basis points. + */ +function setTokenRoyalty(uint256 _tokenId, address _receiver, uint96 _feeNumerator) { + if (_feeNumerator > FEE_DENOMINATOR) { + revert ERC2981InvalidTokenRoyalty(_tokenId, _feeNumerator, FEE_DENOMINATOR); + } + if (_receiver == address(0)) { + revert ERC2981InvalidTokenRoyaltyReceiver(_tokenId, address(0)); + } + + RoyaltyStorage storage s = getStorage(); + s.tokenRoyaltyInfo[_tokenId] = RoyaltyInfo(_receiver, _feeNumerator); +} + +/** + * @notice Resets royalty information for a specific token to use the default setting. + * @dev Clears token-specific royalty storage, causing fallback to default royalty. + * @param _tokenId The token ID to reset royalty configuration for. + */ +function resetTokenRoyalty(uint256 _tokenId) { + RoyaltyStorage storage s = getStorage(); + delete s.tokenRoyaltyInfo[_tokenId]; +} diff --git a/cli/project-template/src/utils/files.ts b/cli/project-template/src/utils/files.ts new file mode 100644 index 00000000..7d8603bd --- /dev/null +++ b/cli/project-template/src/utils/files.ts @@ -0,0 +1,62 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { parseSolidityImports } from "./solidityText"; + +// Check whether a filesystem path exists without throwing to callers. +export async function pathExists(target: string): Promise { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} + +// Copy a file only when the destination does not already exist. +export async function copyFileIfMissing(from: string, to: string): Promise { + try { + await fs.access(to); + return; + } catch { + // destination does not exist, continue + } + await fs.mkdir(path.dirname(to), { recursive: true }); + await fs.copyFile(from, to); +} + +// Write text content only when the target file does not already exist. +export async function writeFileIfMissing(to: string, content: string): Promise { + try { + await fs.access(to); + return; + } catch { + // destination does not exist, continue + } + await fs.mkdir(path.dirname(to), { recursive: true }); + await fs.writeFile(to, content, "utf8"); +} + +// Resolve local Solidity imports reachable from a set of seed source files. +export async function resolveLocalSolidityImportClosure(seedSources: string[]): Promise> { + const visited = new Set(); + const stack = [...seedSources]; + + while (stack.length > 0) { + const file = path.resolve(stack.pop() as string); + if (visited.has(file)) continue; + visited.add(file); + + const code = await fs.readFile(file, "utf8"); + for (const specifier of parseSolidityImports(code)) { + if (!specifier.startsWith(".")) { + continue; + } + const dep = path.resolve(path.dirname(file), specifier); + if (!visited.has(dep)) { + stack.push(dep); + } + } + } + + return visited; +} diff --git a/cli/project-template/src/utils/regex.ts b/cli/project-template/src/utils/regex.ts new file mode 100644 index 00000000..66ba7fb6 --- /dev/null +++ b/cli/project-template/src/utils/regex.ts @@ -0,0 +1,4 @@ +// Escape user-provided text so it can be safely embedded in a RegExp. +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/cli/project-template/src/utils/solidityText.ts b/cli/project-template/src/utils/solidityText.ts new file mode 100644 index 00000000..15a55100 --- /dev/null +++ b/cli/project-template/src/utils/solidityText.ts @@ -0,0 +1,88 @@ +export type SolidityFunctionInfo = { + name: string; + signature: string; + visibility: "external" | "public"; +}; + +// Parse Solidity import specifiers from source text. +export function parseSolidityImports(soliditySource: string): string[] { + const imports: string[] = []; + const re = /^\s*import\s+(?:[^'"]+from\s+)?["']([^"']+)["'];/gm; + let m: RegExpExecArray | null; + while ((m = re.exec(soliditySource)) !== null) { + imports.push(m[1]); + } + return imports; +} + +// Remove block and line comments from Solidity source text. +export function removeSolidityComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/.*$/gm, ""); +} + +// Normalize one Solidity parameter type by removing location and parameter name. +export function normalizeSolidityParameterType(parameter: string): string { + const withoutName = parameter + .trim() + .replace(/\s+/g, " ") + .replace(/\b(calldata|memory|storage|indexed)\b/g, "") + .trim(); + + const parts = withoutName.split(" ").filter(Boolean); + if (parts.length <= 1) { + return withoutName; + } + + return parts.slice(0, -1).join(" "); +} + +// Parse public and external Solidity functions with normalized signatures. +export function parseSolidityFunctions(source: string): SolidityFunctionInfo[] { + const cleanSource = removeSolidityComments(source); + const functions: SolidityFunctionInfo[] = []; + const functionPattern = /\bfunction\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*([^;{]*)/g; + let match: RegExpExecArray | null; + + while ((match = functionPattern.exec(cleanSource)) !== null) { + const [, name, rawParameters, tail] = match; + const visibilityMatch = tail.match(/\b(external|public)\b/); + if (!visibilityMatch || name === "exportSelectors") { + continue; + } + + const parameterTypes = rawParameters + .split(",") + .map((parameter) => parameter.trim()) + .filter(Boolean) + .map(normalizeSolidityParameterType); + + functions.push({ + name, + signature: `${name}(${parameterTypes.join(",")})`, + visibility: visibilityMatch[1] as "external" | "public", + }); + } + + return functions; +} + +// Parse function names exported through `this..selector` entries. +export function parseExportedSelectorNames(source: string): string[] { + const cleanSource = removeSolidityComments(source); + const exportMatch = cleanSource.match(/\bfunction\s+exportSelectors\s*\([^)]*\)[^{;]*\{([\s\S]*?)\n\s*\}/); + if (!exportMatch) { + return []; + } + + const exported = new Set(); + const selectorPattern = /\bthis\.([A-Za-z_][A-Za-z0-9_]*)\.selector\b/g; + let match: RegExpExecArray | null; + + while ((match = selectorPattern.exec(exportMatch[1])) !== null) { + exported.add(match[1]); + } + + return [...exported]; +} diff --git a/cli/project-template/src/utils/terminal.ts b/cli/project-template/src/utils/terminal.ts new file mode 100644 index 00000000..f0c26ca7 --- /dev/null +++ b/cli/project-template/src/utils/terminal.ts @@ -0,0 +1,14 @@ +// Wrap terminal text in ANSI cyan color codes. +export function cyan(text: string): string { + return `\u001b[36m${text}\u001b[39m`; +} + +// Wrap terminal text in ANSI red color codes. +export function red(text: string): string { + return `\u001b[31m${text}\u001b[39m`; +} + +// Wrap terminal text in ANSI yellow color codes. +export function yellow(text: string): string { + return `\u001b[33m${text}\u001b[39m`; +} diff --git a/cli/project-template/tsconfig.json b/cli/project-template/tsconfig.json new file mode 100644 index 00000000..59088f9e --- /dev/null +++ b/cli/project-template/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} From 2875548987628ac9310e10c979f2b2e1372cd8a6 Mon Sep 17 00:00:00 2001 From: Vagabond Date: Wed, 17 Jun 2026 03:29:06 +0700 Subject: [PATCH 2/3] fix: ci --- .../examples/pipeline-module-adapter.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/project-template/skills/compose-cli-architecture/examples/pipeline-module-adapter.ts b/cli/project-template/skills/compose-cli-architecture/examples/pipeline-module-adapter.ts index 0a4cb670..ff7ebfbb 100644 --- a/cli/project-template/skills/compose-cli-architecture/examples/pipeline-module-adapter.ts +++ b/cli/project-template/skills/compose-cli-architecture/examples/pipeline-module-adapter.ts @@ -57,14 +57,14 @@ enum DependencyKey { } const DependencyResolver = { - async resolve(requests: { key: DependencyKey; params?: unknown }[]) { + async resolve(_requests: { key: DependencyKey; params?: unknown }[]) { return { diamondInspect: {} as DiamondInspectAdapter, }; }, }; -async function validatePipeline( +export async function validatePipeline( ctx: ComposeContext ): Promise { const deps = (await DependencyResolver.resolve([ @@ -79,4 +79,4 @@ async function validatePipeline( ctx = await DiamondInspect.readFacetAddresses(ctx, deps); return ctx; -} \ No newline at end of file +} From 4ee64db2fe3a442290cfc9f2b91b127c8f191fef Mon Sep 17 00:00:00 2001 From: Vagabond Date: Wed, 17 Jun 2026 23:40:13 +0700 Subject: [PATCH 3/3] chore: remove project-template docs --- cli/project-template/.gitignore | 2 + .../skills/compose-cli-architecture/SKILL.md | 179 ----- .../examples/pipeline-module-adapter.ts | 82 --- .../references/checklist.md | 25 - .../references/doctrine.md | 92 --- cli/project-template/spec/M1/Init.md | 101 --- cli/project-template/spec/M1/M1Goal.md | 248 ------- cli/project-template/spec/M1/Validation.md | 89 --- cli/project-template/spec/M1/compose.json | 39 - cli/project-template/spec/M1/compose.lock | 22 - cli/project-template/spec/Metric.md | 10 - .../spec/internal-architecture.md | 689 ------------------ 12 files changed, 2 insertions(+), 1576 deletions(-) delete mode 100644 cli/project-template/skills/compose-cli-architecture/SKILL.md delete mode 100644 cli/project-template/skills/compose-cli-architecture/examples/pipeline-module-adapter.ts delete mode 100644 cli/project-template/skills/compose-cli-architecture/references/checklist.md delete mode 100644 cli/project-template/skills/compose-cli-architecture/references/doctrine.md delete mode 100644 cli/project-template/spec/M1/Init.md delete mode 100644 cli/project-template/spec/M1/M1Goal.md delete mode 100644 cli/project-template/spec/M1/Validation.md delete mode 100644 cli/project-template/spec/M1/compose.json delete mode 100644 cli/project-template/spec/M1/compose.lock delete mode 100644 cli/project-template/spec/Metric.md delete mode 100644 cli/project-template/spec/internal-architecture.md diff --git a/cli/project-template/.gitignore b/cli/project-template/.gitignore index 06aabcf8..aa1e8fc3 100644 --- a/cli/project-template/.gitignore +++ b/cli/project-template/.gitignore @@ -1,5 +1,7 @@ node_modules/ dist/ my-diamond/ +spec/ +skills/ *.tsbuildinfo .vscode/ diff --git a/cli/project-template/skills/compose-cli-architecture/SKILL.md b/cli/project-template/skills/compose-cli-architecture/SKILL.md deleted file mode 100644 index b5053306..00000000 --- a/cli/project-template/skills/compose-cli-architecture/SKILL.md +++ /dev/null @@ -1,179 +0,0 @@ ---- -name: compose-cli-architecture -description: Generate Compose CLI code, design workflows, review boundaries, or refactor using the pipeline-oriented modular architecture. Use for commands, context, pipelines, child pipelines, modules, adapters, dependency resolver, registry, and boundary decisions. ---- - -# Compose CLI Architecture - -## Core idea - -Compose CLI is a one-shot Node.js CLI. - -Use this architecture: - -```txt -Command parses input. -Context carries execution state. -Pipeline orchestrates workflow. -Module owns feature logic. -Adapter talks to external systems. -Resolver creates requested dependencies. -Registry maps dependency keys to factories. -``` - -## Use this skill when - -- Designing a command workflow. -- Generating pipeline/module/adapter code. -- Reviewing architecture boundaries. -- Deciding where logic belongs. -- Refactoring external library usage behind adapters. -- Adding dependency resolver or registry entries. - -## Do not use this skill for - -- Generic TypeScript help. -- Generic Node.js CLI setup. -- UI/frontend code. -- Long-running service or daemon architecture. -- Smart contract code unless Compose CLI is reading, validating, comparing, or reporting it. - -## Rules - -### Command entrypoint - -Only parse input, create context, call the main pipeline, and handle final output. - -### Context - -Context carries `param`, `config`, `state`, and `status`. - -Context should not own behavior. - -Modules may write only their own `ctx.state.`. - -### Pipeline - -Pipeline owns orchestration. - -It may call modules, child pipelines, and dependency resolver. - -It should not contain business logic. - -It should not call external libraries directly. - -### Child pipeline - -Use child pipelines for isolated groups of module steps or concurrency boundaries. - -Keep nesting shallow: - -```txt -Main Pipeline - -> Child Pipeline -``` - -Avoid deeper nesting unless strongly justified. - -### Module - -Module owns feature logic. - -A module function receives `ctx`, optionally receives `deps`, writes its own state, and returns `ctx`. - -Modules should not create adapters directly. - -Files that contain helper functions and exported modules should be structured in this order: - -```ts -// ===================== -// Helper -// ===================== - -// ===================== -// Modules -// ===================== -``` - -Keep private helper functions above the exported module object. Keep the exported module object under the `// Modules` section. - -Add a short 1-2 line comment above each function to explain what it does. - -### Adapter - -Adapter owns external communication. - -Use adapters for RPC, external APIs, external tools, runtime-specific concerns, or libraries likely to change. - -Adapter should not own domain validation. - -### Resolver and registry - -Resolver is lightweight dependency resolution, not a full DI container. - -Pipeline requests dependencies. - -Registry maps keys to adapter factories. - -Resolver creates only requested dependencies. - -Modules receive dependencies through function parameters. - -## Boundary guide - -Put code in: - -```txt -Command => CLI parsing and pipeline selection -Context => shared execution state -Pipeline => workflow order and dependency resolution -ChildPipeline=> isolated module group / concurrency boundary -Module => feature logic and validation -Adapter => external communication -Resolver => create requested dependencies -Registry => map dependency key to factory -``` - -## Generation workflow - -When generating code: - -1. Identify command workflow. -2. Define context input. -3. Choose main pipeline or child pipeline. -4. List module steps. -5. Identify external dependencies. -6. Define adapter interface. -7. Add dependency key and registry factory. -8. Resolve deps in pipeline. -9. Pass deps into module. -10. Ensure each module writes only its own state. - -## Review workflow - -When reviewing code, check: - -1. Is command entrypoint thin? -2. Is pipeline only orchestration? -3. Is business logic inside modules? -4. Are external calls behind adapters? -5. Are deps resolved at pipeline level? -6. Do modules receive deps instead of creating adapters? -7. Is child pipeline nesting shallow? -8. Does context carry state without owning behavior? - -## Output format - -For generation: - -1. File tree -2. Code -3. Short explanation - -For review: - -1. Verdict -2. Violations -3. Fixes -4. Patch or corrected code -5. Remaining risks diff --git a/cli/project-template/skills/compose-cli-architecture/examples/pipeline-module-adapter.ts b/cli/project-template/skills/compose-cli-architecture/examples/pipeline-module-adapter.ts deleted file mode 100644 index ff7ebfbb..00000000 --- a/cli/project-template/skills/compose-cli-architecture/examples/pipeline-module-adapter.ts +++ /dev/null @@ -1,82 +0,0 @@ -type ComposeError = { - code: string; - message: string; - nativeError: unknown | null; -}; - -type ModuleState = { - success: boolean; - result: T | null; - error: ComposeError | null; -}; - -type ExecutionStatus = { - success: boolean; - stopped: boolean; - failedAt: string | null; - error: ComposeError | null; -}; - -type ComposeContext = { - param: Record; - config: Record; - state: Record; - status: ExecutionStatus; -}; - -interface DiamondInspectAdapter { - facetAddresses(address: string): Promise; -} - -type DiamondInspectDeps = { - diamondInspect: DiamondInspectAdapter; -}; - -const DiamondInspect = { - async readFacetAddresses( - ctx: ComposeContext, - { diamondInspect }: DiamondInspectDeps - ): Promise { - const address = ctx.param.address as string; - const facetAddresses = await diamondInspect.facetAddresses(address); - - ctx.state.diamondInspect = { - success: true, - result: { - facetAddresses, - }, - error: null, - }; - - return ctx; - }, -}; - -enum DependencyKey { - DiamondInspect = "diamondInspect", -} - -const DependencyResolver = { - async resolve(_requests: { key: DependencyKey; params?: unknown }[]) { - return { - diamondInspect: {} as DiamondInspectAdapter, - }; - }, -}; - -export async function validatePipeline( - ctx: ComposeContext -): Promise { - const deps = (await DependencyResolver.resolve([ - { - key: DependencyKey.DiamondInspect, - params: { - chain: ctx.param.chain, - }, - }, - ])) as DiamondInspectDeps; - - ctx = await DiamondInspect.readFacetAddresses(ctx, deps); - - return ctx; -} diff --git a/cli/project-template/skills/compose-cli-architecture/references/checklist.md b/cli/project-template/skills/compose-cli-architecture/references/checklist.md deleted file mode 100644 index 5c62a238..00000000 --- a/cli/project-template/skills/compose-cli-architecture/references/checklist.md +++ /dev/null @@ -1,25 +0,0 @@ -# Review Checklist - -## Clean - -- Command entrypoint is thin. -- Pipeline reads like workflow table of contents. -- Modules own business logic. -- Files with helpers place private helpers under the `Helper` banner before exported modules under the `Modules` banner. -- Functions have a short 1-2 line comment explaining what they do. -- Adapters own external communication. -- Resolver is lightweight. -- Registry only maps keys to factories. -- Context carries state. -- Child pipeline nesting is shallow. - -## Red flags - -- Pipeline calls `viem`, `ethers`, APIs, or subprocess directly. -- Module creates adapter directly. -- Helper functions are mixed below exported module objects. -- Adapter validates domain rules. -- Resolver becomes full DI container. -- Context becomes service locator. -- Child pipeline nests too deep. -- One function does command + pipeline + module + adapter work. diff --git a/cli/project-template/skills/compose-cli-architecture/references/doctrine.md b/cli/project-template/skills/compose-cli-architecture/references/doctrine.md deleted file mode 100644 index f5a424c6..00000000 --- a/cli/project-template/skills/compose-cli-architecture/references/doctrine.md +++ /dev/null @@ -1,92 +0,0 @@ -# Compose CLI Doctrine - -## Architecture - -Compose CLI uses Pipeline-Oriented Modular Architecture. - -The CLI is one-shot, Node.js-based, and should not hold, store, or transmit private keys. - -## Context shape - -```ts -type ComposeError = { - code: string; - message: string; - nativeError: unknown | null; -}; - -type ModuleState = { - success: boolean; - result: T | null; - error: ComposeError | null; -}; - -type ExecutionStatus = { - success: boolean; - stopped: boolean; - failedAt: string | null; - error: ComposeError | null; -}; - -type ChildPipelineState = { - success: boolean; - state: Record; - status: ExecutionStatus; -}; - -type ComposeContext = { - param: Record; - config: Record; - state: Record; - status: ExecutionStatus; -}; -``` - -## Dependency resolver shape - -```ts -enum DependencyKey { - DiamondInspect = "diamondInspect", -} - -type DependencyParams = Record; - -type DependencyFactory = ( - params?: DependencyParams -) => Promise | T; - -type DependencyRequest = { - key: DependencyKey; - params?: DependencyParams; -}; - -const DependencyResolver = { - async resolve( - requests: DependencyRequest[] - ): Promise> { - const deps: Record = {}; - - for (const request of requests) { - const factory = DependencyRegistry[request.key]; - - if (!factory) { - throw new Error(`Dependency factory not found: ${request.key}`); - } - - deps[request.key] = await factory(request.params); - } - - return deps; - }, -}; -``` - -## Main boundary sentence - -```txt -Pipeline decides order. -Module decides meaning. -Adapter handles outside world. -Resolver creates dependencies. -Context records the trace. -``` \ No newline at end of file diff --git a/cli/project-template/spec/M1/Init.md b/cli/project-template/spec/M1/Init.md deleted file mode 100644 index 076a3bc4..00000000 --- a/cli/project-template/spec/M1/Init.md +++ /dev/null @@ -1,101 +0,0 @@ -# compose init - -## Constraints - -- For M1, `compose init` should primarily use modules. Adapters are not required for the normal `init` flow, but they may be introduced for reusable RPC-related validation work such as keccak or selector computation. -- Foundry and Hardhat are primary business concerns of the CLI, so their handling should be expressed explicitly through modules and pipelines. -- Adapters may be introduced later when a boundary needs flexibility, such as RPC clients or external-language integration. -- `compose.lock` may be mentioned for planning, but it is not implemented in M1. - -## Modules - -A module can contain multiple functions or methods. -Each function is a small unit of logic. - -- A module can read everything from context. -- A module can mutate only its own section in context. - -### entryModule - -Handles user-facing CLI work and libraries such as `commander`, `inquirer`, and `picocolors`. - -- Parse command input. -- Ask interactive questions when needed. -- Render terminal output. - -### configModule - -Handles config and metadata-related file work for M1. - -- Read Compose-owned metadata from `bases`. -- Build generated `compose.json`. -- Write generated `compose.json` into the user project directory. - -`compose.lock` is future scope and is not written by M1 `init`. - -### pipelineBuilderModule - -Determines which command was requested and routes to the correct pipeline. - -### storageValidationModule - -Contains the logic needed for storage collision checks. - -- Reused from `validate`. -- May introduce an RPC-oriented adapter in M1 if that helps keep keccak-related logic reusable and isolated. - -### selectorValidationModule - -Contains the logic needed for selector collision checks. - -- Reused from `validate`. -- May introduce an RPC-oriented adapter in M1 if that helps keep selector computation reusable and isolated. -- For Compose facets, `exportSelectors()` is the source used for cross-facet collision checking after per-facet completeness is verified. - -### scaffoldingModule - -Works with the filesystem and copies template files from Compose into the user's working directory. - -## Pipelines - -A pipeline should not implement business features directly. -It should call modules to do the business work. -A pipeline may use conditions, loops, and context parsing as part of orchestration. - -Except for the command entrypoint, each pipeline takes context as input and returns context as output. - -If a main pipeline calls a child pipeline: - -- At the beginning, the child pipeline should take only the data it needs and create a separate child context. -- The main pipeline can read the child pipeline result and append it back into the main context. - -### entryPipeline.ts - -Root pipeline for command execution. - -- Call `entryModule` to handle CLI-facing work. -- Determine which mode to use. -- Determine which framework to use. -- Route the request to the correct pipeline. - -### foundryInitPipeline - -Handles the `init` command for Foundry. - -### hardhatInitPipeline - -Handles the `init` command for Hardhat. - -### storageValidatePipeline - -Reused from `validate`. - -### selectorValidatePipeline - -Reused from `validate`. - -### scaffoldingPipeline - -Handles file-copy orchestration by calling `scaffoldingModule`. - -If a filesystem exception occurs during scaffolding, the pipeline should support rollback behavior. diff --git a/cli/project-template/spec/M1/M1Goal.md b/cli/project-template/spec/M1/M1Goal.md deleted file mode 100644 index aeb0c1ae..00000000 --- a/cli/project-template/spec/M1/M1Goal.md +++ /dev/null @@ -1,248 +0,0 @@ -# Development Commands - -## `compose init` - -Scaffold a new Compose diamond project. - -`compose init` creates a new local project, generates the initial `compose.json`, and prepares the project file structure. - -`compose init` should build a diamond configuration from available Compose library facets, starter patterns, local example facets, and framework-specific project setup. - -- Supports interactive prompts or flag-driven usage. -- Generates `compose.json` during scaffolding. -- Supports Foundry and Hardhat project setup. -- Supports starter presets such as bare diamond, Counter, ERC-20, ERC-721, and future examples. -- Allows the developer to select, add, or remove available Compose library facets before generation. -- Optionally generates local starter facets and tests. -- Recomputes the diamond surface as facets are added or removed. -- Resolves selected facets into a selector ownership table before writing the project. -- Detects selector collisions during project building. -- Shows collision details during interactive facet selection. -- Allows the developer to resolve collisions by choosing an owner, excluding selectors, or removing one of the colliding facets. -- Writes explicit selector export rules when a collision is resolved intentionally. -- Fails safely in non-interactive mode if required choices or selector collisions cannot be resolved automatically. - -Interactive flow: - -```shell -Select project framework: -> Foundry - Hardhat - -# A base is a standard that consists of multiple facets. -Select base: - Bare diamond -> ERC-20 - ERC-721 - -# Libraries are facets for cross-cutting concerns. -Select Compose library facets: -[x] DiamondInspectFacet -[x] ERC165Facet -[ ] OwnershipFacet -... - -# Extensions are facets that support optional features of a standard. -Select extension facets: -[ ] ERC20BurnFacet -[ ] ERC20MintFacet -[ ] ERC20MetadataFacet -[ ] ERC20PermitFacet - -Select local example facets: -[x] CounterFacet -[ ] None - -# Run internal validation here. - -> Yes -``` - -**Example non-interactive usage:** - -```shell -compose init my-diamond \ - --framework foundry \ - --base ERC20 \ - --library AccessControlAdminFacet,AccessControlGrantFacet \ - --extension ERC20MetadataFacet \ - --yes -``` - -Non-interactive mode must only succeed when the selected preset fully resolves the project shape, including selector ownership. If unresolved selector collisions are found, the command must fail with a clear error instead of guessing. - -The builder must never rely on facet order, implicit priority, or last-write-wins behavior to resolve selector ownership. Every imported selector in the generated diamond must have exactly one owner. - -### Metadata - -This metadata is owned by Compose CLI and lives under the Compose root directory, not inside the user project directory. -It acts as an internal catalog for `compose init` to decide which diamond facets, library facets, base facets, and extension facets are available for selection. - -The source of truth is the Compose-owned `bases` directory. `diamond.json` and `libraries.json` are special global catalogs. Every other JSON file is a selectable base. Each base has two parts: - -- `required`: facets that are always included when that base is selected. -- `optional`: extension facets shown after the user selects that base. - -For `diamond.json` and `libraries.json`, required facets are included for every generated diamond. Optional facets are shown in the Compose library selection unless they are already required by the selected base. - -Example: - -```json -{ - "diamond.json": { - "diamond": { - "label": "Diamond", - "required": { - "DiamondInspectFacet": { - "path": "./src/templates/diamond/DiamondInspectFacet.sol" - } - }, - "optional": { - "DiamondUpgradeFacet": { - "path": "./src/templates/diamond/DiamondUpgradeFacet.sol" - } - } - } - }, - "libraries.json": { - "libraries": { - "label": "Libraries", - "required": {}, - "optional": { - "ERC165Facet": { - "path": "./src/templates/interfaceDetection/ERC165/ERC165Facet.sol" - } - } - } - }, - "erc20.json": { - "erc-20": { - "label": "ERC-20", - "required": { - "ERC20DataFacet": { - "path": "./src/templates/token/ERC20/Data/ERC20DataFacet.sol" - }, - "ERC20ApproveFacet": { - "path": "./src/templates/token/ERC20/Approve/ERC20ApproveFacet.sol" - }, - "ERC20TransferFacet": { - "path": "./src/templates/token/ERC20/Transfer/ERC20TransferFacet.sol" - } - }, - "optional": { - "ERC20BurnFacet": { - "path": "./src/templates/token/ERC20/Burn/ERC20BurnFacet.sol" - }, - "ERC20MetadataFacet": { - "path": "./src/templates/token/ERC20/Metadata/ERC20MetadataFacet.sol" - }, - "ERC20PermitFacet": { - "path": "./src/templates/token/ERC20/Permit/ERC20PermitFacet.sol" - }, - "ERC20BridgeableFacet": { - "path": "./src/templates/token/ERC20/Bridgeable/ERC20BridgeableFacet.sol" - } - } - } - }, - "erc721.json": "...", - "erc1155.json": "..." -} -``` - -#### Target project layout - -```txt -root -`-- src - |-- diamond - | `-- diamond facets - |-- libraries - | `-- shared project libraries - `-- facets - |-- base - | `-- base facets - `-- extensions - `-- extension facets -``` - -## `compose validate` - -### Definition - -Static analysis on the local codebase before deployment. - -- **Auto-compilation**: Detects the project framework (Foundry or Hardhat) and runs `forge build` or `hardhat compile` under the hood if compiled artifacts are stale or missing. Skips compilation if artifacts are already fresh. -- **Storage layout validation**: When multiple facets share a namespaced storage slot, validates that structs are safely extended. New fields may be appended (e.g. `{a, b}` to `{a, b, c}`), but fields must not be reordered or inserted (e.g. `{a, b}` to `{a, c, b}` is invalid). One struct must be a prefix of the other. Storage slot detection uses a fallback chain: - 1. **ERC-8042 annotation** (`@custom:storage-location erc8042:`): preferred, parsed directly from source. - 2. **`.slot :=` pattern**: if the annotation is missing, the CLI scans the source for any assembly block containing a `.slot :=` assignment, regardless of whether it occurs in a dedicated accessor function or inline. From each match, it traces backward to resolve: (a) the `keccak256("namespace")` constant that feeds the slot, and (b) the struct type of the storage variable being assigned. This produces the same namespace-to-struct mapping as the annotation. A warning is raised: *"Missing `@custom:storage-location` annotation -- storage slot inferred from `.slot :=` pattern. Add the annotation for reliable detection."* - 3. **Neither detected**: the storage check is skipped for that facet with a warning: *"Cannot determine storage layout -- no ERC-8042 annotation or recognized storage pattern found."* -- **Selector clash detection**: Flags two different functions that hash to the same 4-byte selector across all facets in the diamond. -- **`exportSelectors()` consistency**: Verifies every facet's `exportSelectors()` return value matches its actual external functions. -- **Missing facet registration**: Warns about facets in the codebase not referenced in `compose.json`. -- Exit code non-zero on failure (CI-friendly). - -### Validation types - -#### Selector collision - -Compute each selected function's 4-byte selector and detect duplicates. -This can be handled using `exportSelectors()` on each facet. - -#### Storage collision - -Storage collision happens when two different layouts point to the same storage slot. - -There are three main kinds of storage collision: - -- Two different storage layouts point to the same identifier. -- Variables are placed outside the diamond storage struct. -- Storage changes during an update, such as a new variable placed in the middle of a struct. - -Across this project, some storage layouts may have a different number of elements while still preserving the same field order. -Those implementations are valid. - -#### Example - -```solidity -// Valid -// ERC20Mint -bytes32 constant STORAGE_POSITION = keccak256("erc20"); -struct ERC20Storage { - mapping(address owner => uint256 balance) balanceOf; - uint256 totalSupply; -} - -// ERC20Transfer -bytes32 constant STORAGE_POSITION = keccak256("erc20"); -struct ERC20Storage { - mapping(address owner => uint256 balance) balanceOf; - uint256 totalSupply; - mapping(address owner => mapping(address spender => uint256 allowance)) allowance; -} - -// ============================ -// Invalid -// ERC20Mint -bytes32 constant STORAGE_POSITION = keccak256("erc20"); -struct ERC20Storage { - uint256 totalSupply; - mapping(address owner => uint256 balance) balanceOf; -} - -// ERC20Transfer -bytes32 constant STORAGE_POSITION = keccak256("erc20"); -struct ERC20Storage { - mapping(address owner => uint256 balance) balanceOf; - uint256 totalSupply; -} -``` - -## Libraries - -```shell -commander # parse commands such as: compose init --framework foundry -inquirer # ask users to choose options, checkboxes, and confirmations -picocolors # color terminal output -fs-extra # copy templates, write files, and ensure directories exist -``` diff --git a/cli/project-template/spec/M1/Validation.md b/cli/project-template/spec/M1/Validation.md deleted file mode 100644 index 0cc70022..00000000 --- a/cli/project-template/spec/M1/Validation.md +++ /dev/null @@ -1,89 +0,0 @@ -# compose validate - -## Summary - -Validation checks the selected diamond surface before files are written or before a deployed project is trusted. - -- Selector validation checks function selector ownership. -- Identifier validation checks storage layout compatibility. -- Both checks should fail fast when the diamond shape is unsafe. - -## Constraints - -- Validation is static analysis on the selected diamond surface. -- Validation must fail fast when a required check fails. -- Selector collision detection must run after selector export validation. -- Identifier collision validation is separate from selector collision validation. - -## Selector Validation - -Selector validation works on selected facets from `init` or facets referenced by `compose.json`. - -Order: - -1. Scan selected facets. -2. Validate selector exports. -3. Detect selector collisions. - -### scanSelectedFacets - -Owned by `scaffoldingModule` during `init`. - -- Read selected package facets. -- Read selected local example facets. -- Parse external and public functions. -- Parse `exportSelectors()`. -- Parse storage layout identifiers for later storage validation. - -### validateSelectorExports - -Owned by `validationModule`. - -- Verify every intended external or public function is exported by `exportSelectors()`. -- Verify every function exported by `exportSelectors()` exists in the facet. -- Fail before selector collision detection if this check fails. - -### detectSelectorCollisions - -Owned by `validationModule`. - -- Use `exportSelectors()` as the selector source after export validation passes. -- Compute each exported function's 4-byte selector from its signature. -- Fail if more than one selected facet exports the same selector. -- This is the last selector validation step before project files are written. - -## Identifier Validation - -Identifier validation checks storage layout collisions for selected facets. - -- Prefer ERC-8042 storage annotations. -- Fall back to detected storage slot assignment patterns. -- Skip the storage check for a facet when no supported storage pattern is found. -- Fail when different storage layouts point to the same namespace in an unsafe way. - -### Detection Order - -1. Parse `@custom:storage-location erc8042:` annotations. -2. If no annotation is found, infer storage from `.slot :=` assignments. -3. If neither is found, warn and skip storage validation for that facet. - -### Layout Rule - -Each detected namespace maps to a normalized storage type sequence. - -- Types are normalized before comparison. -- `uint` and `int` are normalized to `uint256` and `int256`. -- Mapping parameter names are ignored. -- `mapping(address owner => uint256 balance)` is compared as `mapping(address=>uint256)`. -- Inline structs are flattened in storage order. - -### Compare Rule - -Group storage records by namespace. - -- If a namespace appears once, it is safe. -- If a namespace appears multiple times, compare the layouts. -- Compatible layouts must share the same prefix. -- Appending fields is safe. -- Reordering fields or inserting fields before existing fields is unsafe. -- Incompatible duplicate namespaces fail validation. diff --git a/cli/project-template/spec/M1/compose.json b/cli/project-template/spec/M1/compose.json deleted file mode 100644 index 7bcbdc1d..00000000 --- a/cli/project-template/spec/M1/compose.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "project": "my-diamond-project", - "compose": "0.0.3", - "framework": "foundry", - - "diamonds": { - "MyDiamond": { - "contract": "src/MyDiamond.sol:MyDiamond", - "facets": { - "DiamondInspectFacet": { - "source": "package", - "package": "@perfect-abstractions/compose", - "contract": "DiamondInspectFacet" - }, - "DiamondUpgradeFacet": { - "source": "package", - "package": "@perfect-abstractions/compose", - "contract": "DiamondUpgradeFacet" - }, - "CounterFacet": { - "source": "local", - "contract": "src/facets/CounterFacet.sol:CounterFacet" - } - }, - "init": { - "contract": "src/init/DiamondInit.sol:DiamondInit", - "function": "init(address)", - "args": ["{{deployer}}"] - } - } - }, - - "chains": { - "sepolia": { "rpc": "${SEPOLIA_RPC_URL}", "chainId": 11155111 }, - "base": { "rpc": "${BASE_RPC_URL}", "chainId": 8453 } - }, - - "registry": "https://registry.compose.diamonds" -} \ No newline at end of file diff --git a/cli/project-template/spec/M1/compose.lock b/cli/project-template/spec/M1/compose.lock deleted file mode 100644 index 95f2f10c..00000000 --- a/cli/project-template/spec/M1/compose.lock +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compose": "0.0.3", - "deployments": { - "MyDiamond": { - "sepolia": { - "diamond": "0xabc...123", - "facets": { - "DiamondInspectFacet": "0xdef...456", - "DiamondUpgradeFacet": "0xghi...789", - "CounterFacet": "0xjkl...012" - }, - "lastSync": "2026-04-06T12:00:00Z", - "txHash": "0x..." - }, - "base": { - "diamond": "0xmno...345", - "facets": {}, - "lastSync": "2026-04-05T08:30:00Z" - } - } - } -} \ No newline at end of file diff --git a/cli/project-template/spec/Metric.md b/cli/project-template/spec/Metric.md deleted file mode 100644 index 9abd82e4..00000000 --- a/cli/project-template/spec/Metric.md +++ /dev/null @@ -1,10 +0,0 @@ -### Metrics by Milestone - - -| Milestone | Key Signals | How Measured | -| --------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | -| **M1: Foundation** | Projects created with `compose init`; `validate` runs per week; template popularity | Opt-in telemetry, GitHub stars/clones, npm download counts | -| **M2: On-Chain Read** | Unique diamonds inspected; `diff` command usage; chains queried | Opt-in telemetry | -| **M3: Deploy** | Deployments via CLI (vs. manual scripts); chains deployed to; `verify` and `status` usage | Opt-in telemetry, registry deploy events (M5) | -| **M4: Upgrade** | Upgrades planned and applied; plan-to-apply conversion rate; facets upgraded per operation | Opt-in telemetry | -| **M5: Registry** | Registry facets referenced in projects; gas saved vs. self-deploy; chains with registry usage | Opt-in telemetry, registry index fetch logs | diff --git a/cli/project-template/spec/internal-architecture.md b/cli/project-template/spec/internal-architecture.md deleted file mode 100644 index 87dafb74..00000000 --- a/cli/project-template/spec/internal-architecture.md +++ /dev/null @@ -1,689 +0,0 @@ -# Compose CLI - Internal Architecture Proposal - -> **Status:** Draft -> **Date:** 2026-05-20 -> **Product:** Compose CLI -> **Version:** 0.1.0 - ---- - -## Table of Contents - -1. [Pipeline-Oriented Modular Architecture](#pipeline-oriented-modular-architecture) -2. [Assumptions / Constraints](#assumptions--constraints) -3. [General](#general) -4. [Context Object (`ctx`)](#context-object-ctx) - - [Example Context Types](#example-context-types) - - [Example Context Values](#example-context-values) - - [Notes](#notes) -5. [Pipeline](#pipeline) - - [Main Pipeline](#main-pipeline) - - [Child Pipeline](#child-pipeline) - - [Child Pipeline and Concurrency](#child-pipeline-and-concurrency) - - [Example](#example) - - [Note](#note) -6. [Modules](#modules) - - [Modules Example](#modules-example) - - [Note](#note-1) -7. [Module, Pipeline, and Adapters](#module-pipeline-and-adapters) -8. [Adapters](#adapters) - - [Adapter example](#adapter-example) - - [Modules and Adapters](#modules-and-adapters) -9. [Dependency Resolver and Registry](#dependency-resolver-and-registry) - - [Example](#example-1) -10. [Utils](#utils) - - [Utils Workflow](#utils-workflow) -11. [Change Log](#change-log) - ---- - -## **Pipeline-Oriented Modular Architecture** - -A modular architecture where pipelines own orchestration, modules provide stateless units of work, and adapters isolate external tools and libraries. - -## Assumptions / Constraints - -- The CLI is a one-shot tool, not a daemon or background service. - -- The CLI will evolve across multiple milestones, so the architecture should tolerate new features and breaking changes. - -- The CLI remains a Node.js tool, consistent with the current tech stack. - -- The CLI never holds, stores, or transmits private keys. - -- This architecture should respect the constraints defined in the original PRD. - -## General - -![Pipeline-Oriented Modular Architecture](./resources/pipeline-oriented-modular-architecture.png) - -This architecture is built around four main components: **Context (`ctx`)** , **Pipeline**, **Module**, and **Adapter**. - -- Pipelines define sequential chains of actions for specific workflows. - -- Context carries shared state throughout a pipeline execution. - -- Modules are focused units of work that handle the business logic of each step. - -- Adapters are focused units of work that handle external tools, external libraries, or runtime-specific concerns. - -Each command input is parsed into a context object, then passed into the corresponding pipeline. - -The pipeline calls modules in order to process the workflow. If a module needs to interact with an external tool, library, API, or runtime, it calls an adapter. - -The same context is passed from the first module to the last module in the pipeline. - -## Context Object (`ctx`) - -The context object represents the execution scenario of a single command. It centralizes shared information so that modules executed later in the pipeline have the data they need to complete their work. - -The context object can contain parameters, config, module state, child pipeline state, and errors produced during execution. However, it should not own execution behavior. Its role is to carry information between modules and child pipelines through a controlled shared structure, instead of making them depend on each other directly. - -### Example Context Types - -```ts - -type ComposeError = { - code: string; - message: string; - nativeError: unknown | null; -}; - -type ModuleState = { - success: boolean; - result: T | null; - error: ComposeError | null; -}; - -type ExecutionStatus = { - success: boolean; - stopped: boolean; - failedAt: string | null; - error: ComposeError | null; -}; - -type ChildPipelineState = { - success: boolean; - state: Record; - status: ExecutionStatus; -}; - -type ComposeContext = { - param: Record; - config: Record; - state: Record; - status: ExecutionStatus; -}; - -``` - -### Example Context Values - -```ts - -const ctx: ComposeContext = { - param: { - // Parsed command input from the command entrypoint. - }, - - config: { - // Loaded config and long-lived project data, - // such as compose.json, compose.lock, and project metadata. - }, - - state: { - fileSystem: { - success: true, - result: { - // File System Module output. - }, - error: null, - }, - - validationPipeline: { - success: false, - - state: { - validateIdentifier: { - success: true, - result: { - // Identifier validation output. - }, - error: null, - }, - - validateLayout: { - success: false, - result: null, - error: { - code: "LAYOUT_VALIDATION_FAILED", - message: "Layout validation failed.", - nativeError: null, - }, - }, - }, - - status: { - success: false, - stopped: true, - failedAt: "validationPipeline.validateLayout", - - error: { - code: "VALIDATION_PIPELINE_FAILED", - message: "Validation pipeline failed.", - nativeError: null, - }, - }, - }, - - // ... - // more modules / child pipelines here - }, - - status: { - // Top-level command execution status. - - success: false, - stopped: true, - failedAt: "validationPipeline.validateLayout", - - error: { - code: "COMMAND_FAILED", - message: "Command execution failed.", - nativeError: null, - }, - }, -}; - -``` - -### Notes - -- Context allows modules to be added, removed, or reordered across different stages of a pipeline with fewer direct dependencies between modules. - -- It gives modules executed later in the pipeline access to the information produced by previous steps, which enables fallback scenarios when something does not work as expected. - -- In a development environment, it can also provide a full trace of the workflow, which is valuable for debugging. - -## Pipeline - -In this architecture, a pipeline acts as an orchestrator. -It defines what the workflow looks like, which modules should be called, and the order in which they should run. - -A pipeline should only coordinate module execution. It should not execute business logic directly, and it should not interact with adapters by itself. If external interaction is needed, the pipeline calls a module, and the module calls the adapter. - -### Main Pipeline - -![Main-Pipeline](./resources/main-pipeline.png) - -A Main Pipeline is the primary workflow of a CLI command. - -Each command, such as init, validate, diff, or plan, creates a context object and passes it into one Main Pipeline from its command entrypoint. From there, the Main Pipeline orchestrates the modules required to complete the command. - -The Main Pipeline does not need to be directly aware of the command implementation. It only receives a context object and executes the workflow defined for that pipeline. - -### Child Pipeline - -At some stages of a pipeline, there may be multiple smaller modules that need to run as a group. In that case, a pipeline can call another pipeline as part of its workflow. This inner pipeline is called a Child Pipeline. - -![Child-Pipeline](./resources/child-pipeline.png) - -Each Child Pipeline should create its own context object from the parent context, containing only the information required for that pipeline to run. - -After the Child Pipeline finishes, it returns its result. The parent pipeline then appends the Child Pipeline context or result into the state of its own context, and continues to the next action. - -This keeps the Child Pipeline isolated while still allowing the parent pipeline to collect its output in a controlled way. - -### Child Pipeline and Concurrency - -A Child Pipeline is particularly useful for managing concurrency. -Each Child Pipeline reads from and writes to its own context object, while keeping the workflow of its internal modules consistent and predictable. - -A useful benefit of this model is that concurrency is handled at the Child Pipeline boundary. The parent pipeline only sees the Child Pipeline as one executable unit, while the Child Pipeline keeps its internal module workflow predictable. - -When the workflow needs to evolve, developers can add more modules inside the Child Pipeline and arrange their order normally, without manually managing parallel execution, thread locks, or shared-state coordination for each individual module. - -Parallel Child Pipelines should be executed with a reasonable concurrency limit, so the CLI can improve performance without overwhelming the local machine or RPC provider. - -### Example - -**Main Pipeline** - -```ts - -async function mainPipeline(ctx: ComposeContext): Promise { - // Run normal modules in the parent pipeline. - ctx = await FileSystem.load(ctx); - - // Execute the child pipeline with an isolated child context. - const validationCtx: ComposeContext = await ValidationPipeline.execute(ctx); - - // Append the child pipeline result back into the parent context state. - ctx.state.validationPipeline = { - success: validationCtx.status.success, - state: validationCtx.state, - status: validationCtx.status, - }; - - // Continue the parent pipeline after collecting the child pipeline output. - ctx = await Output.write(ctx); - - return ctx; -} - -``` - -**Child Pipeline** - -```ts - -const ValidationPipeline = { - async execute(parentCtx: ComposeContext): Promise { - const fileSystemState = parentCtx.state.fileSystem as ModuleState<{ - identifiers: unknown; - layout: unknown; - }>; - - // Create an isolated context for this child pipeline. - // Only pass the data this pipeline needs from the parent context. - let childCtx: ComposeContext = Context.createChild(parentCtx, { - name: "validationPipeline", - input: { - identifiers: fileSystemState.result?.identifiers, - layout: fileSystemState.result?.layout, - }, - }); - - // Run actions from the Validation module in a predictable order. - childCtx = await Validation.computeKeccak(childCtx); - childCtx = await Validation.validateIdentifier(childCtx); - childCtx = await Validation.validateLayout(childCtx); - - // Finalize the child pipeline status before returning it to the parent. - childCtx.status = { - success: true, - stopped: false, - failedAt: null, - error: null, - }; - - // Return the child context to the parent pipeline. - // The parent pipeline can append this result into its own context state. - return childCtx; - }, -}; - -``` - -### Note - -- While powerful, Child Pipelines should not be overused. Too many nested pipelines can make the workflow branch too much and increase unnecessary complexity. - -- The Main Pipeline can call Modules directly or call a Child Pipeline when a group of actions needs isolation, concurrency, or room to grow. - -- Child Pipelines should not be nested further unless there is a very strong reason, because deep nesting makes the workflow harder to follow. - -**For Compose CLI, I recommend keeping the structure to one Main Pipeline and at most one level of Child Pipeline.** - -## Modules - -A Module is a focused group of units of work inside a pipeline. -It represents a group of related functions that solve one business concern. - -Each function inside a module should do one focused task. It can read the context, perform its work, and write its own result back into the context. - -A module function should not modify state owned by another module. If it needs data from another module, it should read that data from the context instead of depending on that module directly. - -### Modules Example - -```ts - -const Validation = { - async computeKeccak(ctx: ComposeContext): Promise { - // Compute keccak-based identifiers. - // Write result to ctx.state.computeKeccak. - return ctx; - }, - - async validateIdentifier(ctx: ComposeContext): Promise { - // Validate identifier rules. - // Write result to ctx.state.validateIdentifier. - return ctx; - }, - - async validateLayout(ctx: ComposeContext): Promise { - // Validate layout rules. - // Write result to ctx.state.validateLayout. - return ctx; - }, -}; - -``` - -### Note - -Module functions follow a functional-style interface. **They receive a context object and return the context object**. However, for a CLI tool, recreating the whole context object on every module call may create unnecessary memory overhead, especially when the context contains artifacts, layouts, bytecode, or reports. Because of that, modules are allowed to append their own execution result into the context. This is a more practical and efficient approach, as long as each module only writes to its own state section. - -Grouping module functions by semantic meaning also creates a clear anchor for reading the source code. A new developer can quickly understand which modules a pipeline uses, then go deeper into a specific pipeline or module when needed. This improves readability for humans and also provides better context anchors for AI-assisted development. - -## Module, Pipeline, and Adapters - -Some modules may need to interact with external tools, libraries, APIs, or runtimes. These interactions should be handled through adapters. - -To reduce the cost of future changes, modules should not declare or create adapters directly. Instead, the required dependencies should be resolved by the pipeline and passed into the module from the beginning. - -A module function can receive dependencies through its signature, for example: - -```ts - -Module.function(ctx, deps) - -``` - -The pipeline is the orchestration layer. It knows which modules and adapters are needed for its business flow, so it is the practical place to select and assign adapters explicitly. - -This makes sense because the pipeline does not directly perform adapter operations or internal business logic. It simply coordinates the correct adapter with the correct module. - -```ts - -interface HashingAdapterInterface { - keccak256(value: string): string; -} - -type SelectorCollisionDeps = { - hashing: HashingAdapterInterface; -}; - -// Create adapters from registry. -const deps = (await DependencyResolver.resolve([ - { - key: DependencyKey.Hashing, - }, -])) as SelectorCollisionDeps; - -// Assign adapters to module. -ctx = await ValidationModule.detectSelectorCollisions(ctx, { - hashing: deps.hashing, -}); - -``` - -This keeps modules stateless and testable, while allowing pipelines to control adapter lifecycle and dependency scope. -The dependency resolver and adapter conventions will be described in later sections. - -## Adapters - -![Adapters](./resources/adapters.png) - -In traditional software design, an adapter is a pattern used to interact with external dependencies through a stable interface. - -The idea is simple: modules should call a stable adapter interface instead of depending directly on a specific library. If we later need to replace a library, we can update the registry and implement a new adapter while keeping most modules and pipelines unchanged. - -This is especially important for Web3 tooling, where libraries and frameworks can change quickly. If Compose CLI wants to keep up with new tooling trends, adapters can become an important leverage point. - -**For Compose CLI, adapters can also provide another benefit. They can help the Node.js layer communicate with external systems or extensions cleanly.** - -Some performance-sensitive or security-sensitive features could be implemented outside the Node.js layer, for example in Rust or C++. In that case, the adapter becomes the communication boundary between the Node.js orchestration layer and the external implementation. - -The Node.js pipeline and modules can still orchestrate the workflow, while adapters handle the integration details. - -If the CLI later needs to rewrite part of the core, the new core can reuse these extensions naturally as long as the adapter boundary remains stable and well-defined. - -### Adapter example - -A `DiamondInspectAdapter` can be used to read Diamond introspection data without coupling modules directly to a specific RPC library. - -The adapter exposes an interface: - -```ts - -interface DiamondInspectAdapter { - facetAddresses(address: string): Promise; -} - -``` - -The implementation can use `viem`, `ethers.js`, or another RPC library internally: - -```ts - -class ViemDiamondInspectAdapter implements DiamondInspectAdapter { - constructor(private readonly client: ViemClient) {} - - async facetAddresses(address: string): Promise { - const result = await this.client.readContract({ - address: address as `0x${string}`, - abi: IDiamondInspectAbi, - functionName: "facetAddresses", - }); - - return [...result].map(String); - } -} - -``` - -The module only depends on the interface: - -```ts - -type DiamondInspectDeps = { - diamondInspect: DiamondInspectAdapter; -}; - -const DiamondInspect = { - async readFacetAddresses( - ctx: ComposeContext, - { diamondInspect }: DiamondInspectDeps - ): Promise { - const facetAddresses = await diamondInspect.facetAddresses( - ctx.param.address as string - ); - - ctx.state.diamondInspect = { - success: true, - result: { - facetAddresses, - }, - error: null, - }; - - return ctx; - }, -}; - -``` - -The pipeline asks the dependency resolver to create the adapter from the registry, then passes the resolved dependency into the module: - -```ts - -const chain = ctx.config.chains[ctx.param.chain]; - -const deps = (await DependencyResolver.resolve([ - { - key: DependencyKey.DiamondInspect, - params: { - rpcUrl: chain.rpc, - chainId: chain.chainId, - }, - }, -])) as DiamondInspectDeps; - -ctx = await DiamondInspect.readFacetAddresses(ctx, deps); - -``` - -If Compose later switches from `viem` to `ethers.js`, only the `ViemDiamondInspectAdapter` implementation needs to change. - -### Modules and Adapters - -Modules and adapters have a special relationship. Both are grouped units of work that help handle a real feature or concern. - -- A module handles internal data and business logic. - -- An adapter handles communication with the external world. - -Together, they allow the CLI to implement a feature without coupling the internal logic directly to external libraries, tools, APIs, or runtimes. - -However, this boundary needs to be balanced carefully. - -If an adapter contains too much business logic, such as validating domain input or enforcing feature-specific output constraints, it can become too specific to one module and hard to reuse elsewhere. - -On the other hand, if an adapter is too thin and only mirrors the external library API, then many modules can use it, but replacing the underlying library may still force changes across the codebase. In that case, the adapter loses part of its value. - -A balanced approach is to split responsibilities by feature: - -- The module owns the feature logic and validation. - -- The adapter owns the external communication and library-specific details. - -This gives us a clean boundary without turning adapters into hidden business modules. - -Adapters and interfaces are powerful concepts, but overusing them can create a lot of unnecessary code. So they should be used mainly for features that need flexibility or may change over time, such as RPC clients or external extensions. - -For stable dependencies that are unlikely to change often, such as basic CLI parsing or filesystem access, it may be more practical to use a simple module/ utils and accept some coupling. - -## Dependency Resolver and Registry - -The dependency resolver is a special module that provides a lightweight mechanism for resolving adapter dependencies at the pipeline level. - -It is similar to dependency injection in the sense that dependencies are still defined explicitly in one place. However, instead of letting a container construct and inject dependencies automatically, the pipeline resolves the adapter dependencies it needs at the stage where they are needed and passes them into modules explicitly. - -This concept is useful for a CLI because it gives us the convenience of separating adapter definitions from the main workflow, while avoiding the burden of loading or constructing every dependency upfront. - -![Resolver](./resources/resolver.png) - -- The Registry is the single source of truth for mapping dependency keys to adapter factories. - -- Resolver creates adapters from explicit dependency requests. - -- Pipeline decides which adapter dependencies are needed for each stage. - -- Module receives the resolved adapters through its function signature. - -### Example - -Registry Example - -```ts - -// Dependency keys are also used as output field names in the resolved deps object. -enum DependencyKey { - DiamondInspect = "diamondInspect", -} - -type DependencyParams = Record; - -type DependencyFactory = ( - params?: DependencyParams -) => Promise | T; - -type DependencyRequest = { - key: DependencyKey; - params?: DependencyParams; -}; - -// Registry maps dependency keys to adapter factories. -const DependencyRegistry: Record = { - [DependencyKey.DiamondInspect]: (params) => { - const rpcUrl = params?.rpcUrl as string; - const chainId = params?.chainId as number; - - const client = createViemClient({ - rpcUrl, - chainId, - }); - - return new ViemDiamondInspectAdapter(client); - }, -}; - -``` - -Dependency Resolver Example - -```ts - -const DependencyResolver = { - async resolve( - requests: DependencyRequest[] - ): Promise> { - // Resolved dependencies are returned as an object keyed by DependencyKey values. - const deps: Record = {}; - - for (const request of requests) { - const factory = DependencyRegistry[request.key]; - - if (!factory) { - throw new Error(`Dependency factory not found: ${request.key}`); - } - - // Create the adapter only when the pipeline explicitly requests it. - deps[request.key] = await factory(request.params); - } - - return deps; - }, -}; - -``` - -## Utils - -Utils are shared mechanical helpers. - -They are not a workflow component. They are a supporting source layer for small reusable operations that do not carry Compose business meaning. - -```txt -src/ - utils/ - files.ts - regex.ts - ... -``` - -Examples: - -- file helpers -- regex helpers -- string normalization helpers -- generic JSON helpers -- small path helpers - -Do not put Compose domain logic in utils. - -A util should not depend on: - -- `ComposeContext` -- `BasesCatalog` -- `FacetEntry` -- module state -- pipelines -- adapters -- resolver - -If a helper understands selected facets, bases, selector ownership, storage layout rules, scaffold categories, or validation rules, it belongs in a module instead of utils. - -### Utils Workflow - -Before adding a new helper: - -1. Check the existing `src/utils` directory. -2. Reuse an existing util if it already solves the problem. -3. If a close category exists, append the helper to that util file. -4. If no close category exists, create a small new util category. -5. Do not rewrite, delete, or change existing util behavior unless the task is explicitly utility maintenance. - -Main rule: - -```txt -Utils handle mechanics. -Modules handle meaning. -``` - -## Change Log - -| Date | Change | Rationale | -|---|---|---| -| 2026-06-16 | Added `Utils` as a supporting source layer. | AI usually creates small helpers for utility logic, which can add duplicate or fragmented helper functions across module files. The utils layer defines a mechanism to organize utility logic effectively without dumping everything into one object or scattering fragmented functions across the codebase. |