diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 348ad1ea..7589cccc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,19 +5,18 @@ Feel free to dive in! [Open](../../issues/new) an issue, [start](../../discussions/new) a discussion or submit a PR. For any informal concerns or feedback, please join our [Discord server](https://discord.gg/bSwRCwWRsT). -Contributions to Sablier Airdrops are welcome by anyone interested in writing more tests, improving readability, -optimizing for gas efficiency, or extending the protocol via new features. +Contributions are welcome by anyone interested in writing more tests, improving readability, optimizing for gas +efficiency, or extending the protocol via new features. ## Pre Requisites -You will need the following software on your machine: - -- [Git](https://git-scm.com/downloads) -- [Foundry](https://github.com/foundry-rs/foundry) -- [Node.Js](https://nodejs.org/en/download/) -- [Bun](https://bun.sh/) -- [Rust](https://rust-lang.org/tools/install) -- [Bulloak](https://bulloak.dev/) +- [Node.js](https://nodejs.org) (v20+) +- [Just](https://github.com/casey/just) (command runner) +- [Bun](https://bun.sh) (package manager) +- [Ni](https://github.com/antfu-collective/ni) (package manager resolver) +- [Foundry](https://github.com/foundry-rs/foundry) (EVM development framework) +- [Rust](https://rust-lang.org/tools/install) (Rust compiler) +- [Bulloak](https://bulloak.dev) (CLI for checking tests) In addition, familiarity with [Solidity](https://soliditylang.org/) is requisite. @@ -32,8 +31,8 @@ $ git clone git@github.com:sablier-labs/airdrops.git Then, inside the project's directory, run this to install the Node.js dependencies and build the contracts: ```shell -$ bun install -$ bun run build +$ just install +$ just build ``` Switch to the `staging` branch, where all development work should be done: @@ -44,19 +43,22 @@ $ git switch staging Now you can start making changes. -To see a list of all available scripts: +To see a list of all available scripts, run this command: ```shell -$ bun run +$ just --list ``` ## Pull Requests When making a pull request, ensure that: -- The base branch is `staging`. +- The base development branch is `staging`. - All tests pass. - - Fork testing requires environment variables to be set up in the forked repo. +- Concrete tests are generated using Bulloak and the Branching Tree Technique (BTT). + - You can learn more about this on the [Bulloak website](https://bulloak.dev). + - If you modify a test tree, use this command to generate the corresponding test contract that complies with BTT: + `bulloak scaffold -wf /path/to/file.tree` - Code coverage remains the same or greater. - All new code adheres to the style guide: - All lint checks pass. diff --git a/bun.lock b/bun.lock index 0fa57398..7e69e687 100644 --- a/bun.lock +++ b/bun.lock @@ -7,8 +7,8 @@ "@chainlink/contracts": "1.3.0", "@openzeppelin/contracts": "5.3.0", "@prb/math": "4.1.0", - "@sablier/evm-utils": "github:sablier-labs/evm-utils#e81a04b", - "@sablier/lockup": "github:sablier-labs/lockup#0c8f8fa", + "@sablier/evm-utils": "github:sablier-labs/evm-utils#e66fb2d", + "@sablier/lockup": "github:sablier-labs/lockup#07303b1", }, "devDependencies": { "@sablier/devkit": "github:sablier-labs/devkit", @@ -172,9 +172,9 @@ "@sablier/devkit": ["@sablier/devkit@github:sablier-labs/devkit#4c340df", {}, "sablier-labs-devkit-4c340df"], - "@sablier/evm-utils": ["@sablier/evm-utils@github:sablier-labs/evm-utils#e81a04b", {}, "sablier-labs-evm-utils-e81a04b"], + "@sablier/evm-utils": ["@sablier/evm-utils@github:sablier-labs/evm-utils#e66fb2d", { "dependencies": { "@chainlink/contracts": "1.3.0" } }, "sablier-labs-evm-utils-e66fb2d"], - "@sablier/lockup": ["@sablier/lockup@github:sablier-labs/lockup#0c8f8fa", { "dependencies": { "@openzeppelin/contracts": "5.3.0", "@prb/math": "4.1.0", "@sablier/evm-utils": "github:sablier-labs/evm-utils#64835cd" }, "peerDependencies": { "@prb/math": "4.x.x" } }, "sablier-labs-lockup-0c8f8fa"], + "@sablier/lockup": ["@sablier/lockup@github:sablier-labs/lockup#07303b1", { "dependencies": { "@openzeppelin/contracts": "5.3.0", "@prb/math": "4.1.0", "@sablier/evm-utils": "github:sablier-labs/evm-utils#e81a04b" }, "peerDependencies": { "@prb/math": "4.x.x" } }, "sablier-labs-lockup-07303b1"], "@scroll-tech/contracts": ["@scroll-tech/contracts@0.1.0", "", {}, "sha512-aBbDOc3WB/WveZdpJYcrfvMYMz7ZTEiW8M9XMJLba8p9FAR5KGYB/cV+8+EUsq3MKt7C1BfR+WnXoTVdvwIY6w=="], @@ -684,8 +684,6 @@ "type-detect": ["type-detect@4.1.0", "", {}, "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw=="], - "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], - "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -738,13 +736,7 @@ "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], - "@sablier/lockup/@sablier/evm-utils": ["@sablier/evm-utils@github:sablier-labs/evm-utils#64835cd", {}, "sablier-labs-evm-utils-64835cd"], - - "@types/bn.js/@types/node": ["@types/node@24.0.1", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw=="], - - "@types/pbkdf2/@types/node": ["@types/node@24.0.1", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw=="], - - "@types/secp256k1/@types/node": ["@types/node@24.0.1", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw=="], + "@sablier/lockup/@sablier/evm-utils": ["@sablier/evm-utils@github:sablier-labs/evm-utils#e81a04b", {}, "sablier-labs-evm-utils-e81a04b"], "cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], diff --git a/justfile b/justfile index 6b597313..e692ebe0 100644 --- a/justfile +++ b/justfile @@ -3,7 +3,4 @@ import "./node_modules/@sablier/devkit/just/evm.just" default: - @just --list - -test *args: - forge test --nmc ChainlinkOracle_Fork_Test {{ args }} \ No newline at end of file + @just --list \ No newline at end of file diff --git a/package.json b/package.json index ba7b45a6..ba3f4878 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "@chainlink/contracts": "1.3.0", "@openzeppelin/contracts": "5.3.0", "@prb/math": "4.1.0", - "@sablier/lockup": "github:sablier-labs/lockup#0c8f8fa", - "@sablier/evm-utils": "github:sablier-labs/evm-utils#e81a04b" + "@sablier/lockup": "github:sablier-labs/lockup#07303b1", + "@sablier/evm-utils": "github:sablier-labs/evm-utils#e66fb2d" }, "devDependencies": { "@sablier/devkit": "github:sablier-labs/devkit", diff --git a/scripts/solidity/Base.sol b/scripts/solidity/Base.sol deleted file mode 100644 index 4bc1df24..00000000 --- a/scripts/solidity/Base.sol +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// solhint-disable code-complexity -pragma solidity >=0.8.22 <0.9.0; - -import { BaseScript as EvmUtilsBaseScript } from "@sablier/evm-utils/src/tests/BaseScript.sol"; - -abstract contract BaseScript is EvmUtilsBaseScript { - /// @notice Returns the Chainlink oracle for the supported chains. These addresses can be verified on - /// https://docs.chain.link/data-feeds/price-feeds/addresses. - /// @dev If the chain does not have a Chainlink oracle, return 0. - function chainlinkOracle() public view returns (address addr) { - uint256 chainId = block.chainid; - - // Ethereum Mainnet - if (chainId == 1) return 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419; - // Arbitrum One - if (chainId == 42_161) return 0x639Fe6ab55C921f74e7fac1ee960C0B6293ba612; - // Avalanche - if (chainId == 43_114) return 0x0A77230d17318075983913bC2145DB16C7366156; - // Base - if (chainId == 8453) return 0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70; - // BNB Smart Chain - if (chainId == 56) return 0x0567F2323251f0Aab15c8dFb1967E4e8A7D42aeE; - // Gnosis Chain - if (chainId == 100) return 0x678df3415fc31947dA4324eC63212874be5a82f8; - // Linea - if (chainId == 59_144) return 0x3c6Cd9Cc7c7a4c2Cf5a82734CD249D7D593354dA; - // Optimism - if (chainId == 10) return 0x13e3Ee699D1909E989722E753853AE30b17e08c5; - // Polygon - if (chainId == 137) return 0xAB594600376Ec9fD91F8e885dADF0CE036862dE0; - // Scroll - if (chainId == 534_352) return 0x6bF14CB0A831078629D993FDeBcB182b21A8774C; - - // Return address zero for unsupported chain. - return address(0); - } - - /// @notice Returns the initial min USD fee as $1. If the chain does not have Chainlink, return 0. - function initialMinFeeUSD() public view returns (uint256) { - if (chainlinkOracle() != address(0)) { - return 1e8; - } - return 0; - } -} diff --git a/scripts/solidity/CreateMerkleInstant.s.sol b/scripts/solidity/CreateMerkleInstant.s.sol index ceec62c7..13e5a426 100644 --- a/scripts/solidity/CreateMerkleInstant.s.sol +++ b/scripts/solidity/CreateMerkleInstant.s.sol @@ -2,10 +2,10 @@ pragma solidity >=0.8.22 <0.9.0; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { BaseScript } from "@sablier/evm-utils/src/tests/BaseScript.sol"; import { ISablierMerkleInstant } from "../../src/interfaces/ISablierMerkleInstant.sol"; import { SablierFactoryMerkleInstant } from "../../src/SablierFactoryMerkleInstant.sol"; import { MerkleInstant } from "../../src/types/DataTypes.sol"; -import { BaseScript } from "./Base.sol"; /// @dev Creates a dummy MerkleInstant campaign. contract CreateMerkleInstant is BaseScript { diff --git a/scripts/solidity/CreateMerkleLL.s.sol b/scripts/solidity/CreateMerkleLL.s.sol index 8631bc86..f209ed03 100644 --- a/scripts/solidity/CreateMerkleLL.s.sol +++ b/scripts/solidity/CreateMerkleLL.s.sol @@ -3,11 +3,11 @@ pragma solidity >=0.8.22 <0.9.0; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { ud60x18 } from "@prb/math/src/UD60x18.sol"; +import { BaseScript } from "@sablier/evm-utils/src/tests/BaseScript.sol"; import { ISablierLockup } from "@sablier/lockup/src/interfaces/ISablierLockup.sol"; import { ISablierMerkleLL } from "../../src/interfaces/ISablierMerkleLL.sol"; import { SablierFactoryMerkleLL } from "../../src/SablierFactoryMerkleLL.sol"; import { MerkleLL } from "../../src/types/DataTypes.sol"; -import { BaseScript } from "./Base.sol"; /// @dev Creates a dummy campaign to airdrop tokens through Lockup Linear. contract CreateMerkleLL is BaseScript { diff --git a/scripts/solidity/CreateMerkleLT.s.sol b/scripts/solidity/CreateMerkleLT.s.sol index 2b563430..0a3d8684 100644 --- a/scripts/solidity/CreateMerkleLT.s.sol +++ b/scripts/solidity/CreateMerkleLT.s.sol @@ -3,13 +3,12 @@ pragma solidity >=0.8.22 <0.9.0; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { ud2x18 } from "@prb/math/src/UD2x18.sol"; +import { BaseScript } from "@sablier/evm-utils/src/tests/BaseScript.sol"; import { ISablierLockup } from "@sablier/lockup/src/interfaces/ISablierLockup.sol"; - import { ISablierMerkleLT } from "../../src/interfaces/ISablierMerkleLT.sol"; import { SablierFactoryMerkleLT } from "../../src/SablierFactoryMerkleLT.sol"; import { MerkleLT } from "../../src/types/DataTypes.sol"; -import { BaseScript } from "./Base.sol"; /// @dev Creates a dummy campaign to airdrop tokens through Lockup Tranched. contract CreateMerkleLT is BaseScript { diff --git a/scripts/solidity/CreateMerkleVCA.s.sol b/scripts/solidity/CreateMerkleVCA.s.sol index a570ddb2..429a9235 100644 --- a/scripts/solidity/CreateMerkleVCA.s.sol +++ b/scripts/solidity/CreateMerkleVCA.s.sol @@ -2,10 +2,10 @@ pragma solidity >=0.8.22 <0.9.0; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { BaseScript } from "@sablier/evm-utils/src/tests/BaseScript.sol"; import { ISablierMerkleVCA } from "../../src/interfaces/ISablierMerkleVCA.sol"; import { SablierFactoryMerkleVCA } from "../../src/SablierFactoryMerkleVCA.sol"; import { MerkleVCA } from "../../src/types/DataTypes.sol"; -import { BaseScript } from "./Base.sol"; /// @dev Creates a dummy MerkleVCA campaign. contract CreateMerkleVCA is BaseScript { diff --git a/scripts/solidity/DeployDeterministicFactories.s.sol b/scripts/solidity/DeployDeterministicFactories.s.sol index c8e1972a..17f57aae 100644 --- a/scripts/solidity/DeployDeterministicFactories.s.sol +++ b/scripts/solidity/DeployDeterministicFactories.s.sol @@ -1,11 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity >=0.8.22 <0.9.0; +import { BaseScript } from "@sablier/evm-utils/src/tests/BaseScript.sol"; import { SablierFactoryMerkleInstant } from "../../src/SablierFactoryMerkleInstant.sol"; import { SablierFactoryMerkleLL } from "../../src/SablierFactoryMerkleLL.sol"; import { SablierFactoryMerkleLT } from "../../src/SablierFactoryMerkleLT.sol"; import { SablierFactoryMerkleVCA } from "../../src/SablierFactoryMerkleVCA.sol"; -import { BaseScript } from "./Base.sol"; /// @notice Deploys the FactoryMerkle contracts at deterministic addresses. /// @dev Reverts if any contract has already been deployed. diff --git a/scripts/solidity/DeployFactories.s.sol b/scripts/solidity/DeployFactories.s.sol index 02f482bc..dd189f1c 100644 --- a/scripts/solidity/DeployFactories.s.sol +++ b/scripts/solidity/DeployFactories.s.sol @@ -1,11 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity >=0.8.22 <0.9.0; +import { BaseScript } from "@sablier/evm-utils/src/tests/BaseScript.sol"; import { SablierFactoryMerkleInstant } from "../../src/SablierFactoryMerkleInstant.sol"; import { SablierFactoryMerkleLL } from "../../src/SablierFactoryMerkleLL.sol"; import { SablierFactoryMerkleLT } from "../../src/SablierFactoryMerkleLT.sol"; import { SablierFactoryMerkleVCA } from "../../src/SablierFactoryMerkleVCA.sol"; -import { BaseScript } from "./Base.sol"; /// @notice Deploys the FactoryMerkle contracts. contract DeployFactories is BaseScript { diff --git a/src/SablierMerkleInstant.sol b/src/SablierMerkleInstant.sol index 0f360412..3d3afda4 100644 --- a/src/SablierMerkleInstant.sol +++ b/src/SablierMerkleInstant.sol @@ -6,7 +6,6 @@ import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.s import { SablierMerkleBase } from "./abstracts/SablierMerkleBase.sol"; import { ISablierMerkleInstant } from "./interfaces/ISablierMerkleInstant.sol"; -import { Errors } from "./libraries/Errors.sol"; import { MerkleInstant } from "./types/DataTypes.sol"; /* @@ -71,10 +70,10 @@ contract SablierMerkleInstant is payable override { - // Check, Effect and Interaction: Pre-process the claim parameters. + // Check, Effect and Interaction: Pre-process the claim parameters on behalf of the recipient. _preProcessClaim(index, recipient, amount, merkleProof); - // Interaction: Post-process the claim parameters. + // Interaction: Post-process the claim parameters on behalf of the recipient. _postProcessClaim({ index: index, recipient: recipient, to: recipient, amount: amount }); } @@ -88,19 +87,39 @@ contract SablierMerkleInstant is external payable override + notZeroAddress(to) { - // Check: `to` must not be the zero address. - if (to == address(0)) { - revert Errors.SablierMerkleInstant_ToZeroAddress(); - } - - // Check, Effect and Interaction: Pre-process the claim parameters. + // Check, Effect and Interaction: Pre-process the claim parameters on behalf of `msg.sender`. _preProcessClaim({ index: index, recipient: msg.sender, amount: amount, merkleProof: merkleProof }); - // Interaction: Post-process the claim parameters. + // Interaction: Post-process the claim parameters on behalf of `msg.sender`. _postProcessClaim({ index: index, recipient: msg.sender, to: to, amount: amount }); } + /// @inheritdoc ISablierMerkleInstant + function claimViaSig( + uint256 index, + address recipient, + address to, + uint128 amount, + bytes32[] calldata merkleProof, + bytes calldata signature + ) + external + payable + override + notZeroAddress(to) + { + // Check: the signature is valid and the recovered signer matches the recipient. + _checkSignature(index, recipient, to, amount, signature); + + // Check, Effect and Interaction: Pre-process the claim parameters on behalf of the recipient. + _preProcessClaim(index, recipient, amount, merkleProof); + + // Interaction: Post-process the claim parameters on behalf of the recipient. + _postProcessClaim(index, recipient, to, amount); + } + /*////////////////////////////////////////////////////////////////////////// PRIVATE STATE-CHANGING FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ diff --git a/src/SablierMerkleLL.sol b/src/SablierMerkleLL.sol index e24a2f63..24f968af 100644 --- a/src/SablierMerkleLL.sol +++ b/src/SablierMerkleLL.sol @@ -8,7 +8,6 @@ import { Lockup, LockupLinear } from "@sablier/lockup/src/types/DataTypes.sol"; import { SablierMerkleLockup } from "./abstracts/SablierMerkleLockup.sol"; import { ISablierMerkleLL } from "./interfaces/ISablierMerkleLL.sol"; -import { Errors } from "./libraries/Errors.sol"; import { MerkleLL } from "./types/DataTypes.sol"; /* @@ -104,10 +103,10 @@ contract SablierMerkleLL is payable override { - // Check, Effect and Interaction: Pre-process the claim parameters. + // Check, Effect and Interaction: Pre-process the claim parameters on behalf of the recipient. _preProcessClaim(index, recipient, amount, merkleProof); - // Effect and Interaction: Post-process the claim parameters. + // Effect and Interaction: Post-process the claim parameters on behalf of the recipient. _postProcessClaim({ index: index, recipient: recipient, to: recipient, amount: amount }); } @@ -121,19 +120,39 @@ contract SablierMerkleLL is external payable override + notZeroAddress(to) { - // Check: `to` must not be the zero address. - if (to == address(0)) { - revert Errors.SablierMerkleLL_ToZeroAddress(); - } - - // Check, Effect and Interaction: Pre-process the claim parameters. + // Check, Effect and Interaction: Pre-process the claim parameters on behalf of `msg.sender`. _preProcessClaim({ index: index, recipient: msg.sender, amount: amount, merkleProof: merkleProof }); - // Effect and Interaction: Post-process the claim parameters. + // Effect and Interaction: Post-process the claim parameters on behalf of `msg.sender`. _postProcessClaim({ index: index, recipient: msg.sender, to: to, amount: amount }); } + /// @inheritdoc ISablierMerkleLL + function claimViaSig( + uint256 index, + address recipient, + address to, + uint128 amount, + bytes32[] calldata merkleProof, + bytes calldata signature + ) + external + payable + override + notZeroAddress(to) + { + // Check: the signature is valid and the recovered signer matches the recipient. + _checkSignature(index, recipient, to, amount, signature); + + // Check, Effect and Interaction: Pre-process the claim parameters on behalf of the recipient. + _preProcessClaim(index, recipient, amount, merkleProof); + + // Effect and Interaction: Post-process the claim parameters on behalf of the recipient. + _postProcessClaim(index, recipient, to, amount); + } + /*////////////////////////////////////////////////////////////////////////// PRIVATE STATE-CHANGING FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ diff --git a/src/SablierMerkleLT.sol b/src/SablierMerkleLT.sol index fb8fcfd5..c369f465 100644 --- a/src/SablierMerkleLT.sol +++ b/src/SablierMerkleLT.sol @@ -114,10 +114,10 @@ contract SablierMerkleLT is payable override { - // Check, Effect and Interaction: Pre-process the claim parameters. + // Check, Effect and Interaction: Pre-process the claim parameters on behalf of the recipient. _preProcessClaim(index, recipient, amount, merkleProof); - // Check, Effect and Interaction: Post-process the claim parameters. + // Check, Effect and Interaction: Post-process the claim parameters on behalf of the recipient. _postProcessClaim({ index: index, recipient: recipient, to: recipient, amount: amount }); } @@ -131,19 +131,39 @@ contract SablierMerkleLT is external payable override + notZeroAddress(to) { - // Check: `to` must not be the zero address. - if (to == address(0)) { - revert Errors.SablierMerkleLT_ToZeroAddress(); - } - - // Check, Effect and Interaction: Pre-process the claim parameters. + // Check, Effect and Interaction: Pre-process the claim parameters on behalf of `msg.sender`. _preProcessClaim({ index: index, recipient: msg.sender, amount: amount, merkleProof: merkleProof }); - // Check, Effect and Interaction: Post-process the claim parameters. + // Check, Effect and Interaction: Post-process the claim parameters on behalf of `msg.sender`. _postProcessClaim({ index: index, recipient: msg.sender, to: to, amount: amount }); } + /// @inheritdoc ISablierMerkleLT + function claimViaSig( + uint256 index, + address recipient, + address to, + uint128 amount, + bytes32[] calldata merkleProof, + bytes calldata signature + ) + external + payable + override + notZeroAddress(to) + { + // Check: the signature is valid and the recovered signer matches the recipient. + _checkSignature(index, recipient, to, amount, signature); + + // Check, Effect and Interaction: Pre-process the claim parameters on behalf of the recipient. + _preProcessClaim(index, recipient, amount, merkleProof); + + // Check, Effect and Interaction: Post-process the claim parameters on behalf of the recipient. + _postProcessClaim(index, recipient, to, amount); + } + /*////////////////////////////////////////////////////////////////////////// PRIVATE READ-ONLY FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ diff --git a/src/SablierMerkleVCA.sol b/src/SablierMerkleVCA.sol index 98779948..cd1cdb77 100644 --- a/src/SablierMerkleVCA.sol +++ b/src/SablierMerkleVCA.sol @@ -155,10 +155,10 @@ contract SablierMerkleVCA is payable override { - // Check, Effect and Interaction: Pre-process the claim parameters. + // Check, Effect and Interaction: Pre-process the claim parameters on behalf of the recipient. _preProcessClaim({ index: index, recipient: recipient, amount: fullAmount, merkleProof: merkleProof }); - // Check, Effect and Interaction: Post-process the claim parameters. + // Check, Effect and Interaction: Post-process the claim parameters on behalf of the recipient. _postProcessClaim({ index: index, recipient: recipient, to: recipient, fullAmount: fullAmount }); } @@ -172,19 +172,39 @@ contract SablierMerkleVCA is external payable override + notZeroAddress(to) { - // Check: `to` must not be the zero address. - if (to == address(0)) { - revert Errors.SablierMerkleVCA_ToZeroAddress(); - } - - // Check, Effect and Interaction: Pre-process the claim parameters. + // Check, Effect and Interaction: Pre-process the claim parameters on behalf of `msg.sender`. _preProcessClaim({ index: index, recipient: msg.sender, amount: fullAmount, merkleProof: merkleProof }); - // Check, Effect and Interaction: Post-process the claim parameters. + // Check, Effect and Interaction: Post-process the claim parameters on behalf of `msg.sender`. _postProcessClaim({ index: index, recipient: msg.sender, to: to, fullAmount: fullAmount }); } + /// @inheritdoc ISablierMerkleVCA + function claimViaSig( + uint256 index, + address recipient, + address to, + uint128 fullAmount, + bytes32[] calldata merkleProof, + bytes calldata signature + ) + external + payable + override + notZeroAddress(to) + { + // Check: the signature is valid and the recovered signer matches the recipient. + _checkSignature(index, recipient, to, fullAmount, signature); + + // Check, Effect and Interaction: Pre-process the claim parameters on behalf of the recipient. + _preProcessClaim(index, recipient, fullAmount, merkleProof); + + // Check, Effect and Interaction: Post-process the claim parameters on behalf of the recipient. + _postProcessClaim(index, recipient, to, fullAmount); + } + /*////////////////////////////////////////////////////////////////////////// PRIVATE READ-ONLY FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ diff --git a/src/abstracts/SablierMerkleBase.sol b/src/abstracts/SablierMerkleBase.sol index ba8aae86..8ef950e5 100644 --- a/src/abstracts/SablierMerkleBase.sol +++ b/src/abstracts/SablierMerkleBase.sol @@ -5,11 +5,14 @@ import { AggregatorV3Interface } from "@chainlink/contracts/src/v0.8/shared/inte import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { MerkleProof } from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; import { BitMaps } from "@openzeppelin/contracts/utils/structs/BitMaps.sol"; import { Adminable } from "@sablier/evm-utils/src/Adminable.sol"; import { ISablierFactoryMerkleBase } from "./../interfaces/ISablierFactoryMerkleBase.sol"; import { ISablierMerkleBase } from "./../interfaces/ISablierMerkleBase.sol"; import { Errors } from "./../libraries/Errors.sol"; +import { SignatureHash } from "./../libraries/SignatureHash.sol"; /// @title SablierMerkleBase /// @notice See the documentation in {ISablierMerkleBase}. @@ -27,6 +30,9 @@ abstract contract SablierMerkleBase is /// @inheritdoc ISablierMerkleBase uint40 public immutable override CAMPAIGN_START_TIME; + /// @inheritdoc ISablierMerkleBase + bytes32 public immutable DOMAIN_SEPARATOR; + /// @inheritdoc ISablierMerkleBase uint40 public immutable override EXPIRATION; @@ -60,6 +66,17 @@ abstract contract SablierMerkleBase is /// @dev Packed booleans that record the history of claims. BitMaps.BitMap internal _claimedBitMap; + /*////////////////////////////////////////////////////////////////////////// + MODIFIERS + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Modifier to check that `to` is not zero address. + modifier notZeroAddress(address to) { + _revertIfToZeroAddress(to); + + _; + } + /*////////////////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////////////////*/ @@ -77,12 +94,18 @@ abstract contract SablierMerkleBase is ) Adminable(initialAdmin) { + // Compute the domain separator to be used for claiming using an EIP-712 or EIP-1271 signature. + DOMAIN_SEPARATOR = keccak256( + abi.encode(SignatureHash.DOMAIN_TYPEHASH, SignatureHash.PROTOCOL_NAME, block.chainid, address(this)) + ); + CAMPAIGN_START_TIME = campaignStartTime; EXPIRATION = expiration; FACTORY = ISablierFactoryMerkleBase(msg.sender); MERKLE_ROOT = merkleRoot; ORACLE = FACTORY.oracle(); TOKEN = token; + campaignName = campaignName_; ipfsCID = ipfsCID_; minFeeUSD = FACTORY.minFeeUSDFor(campaignCreator); @@ -157,11 +180,45 @@ abstract contract SablierMerkleBase is }); } + /*////////////////////////////////////////////////////////////////////////// + INTERNAL READ-ONLY FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Verifies the signature against the provided parameters. It supports both EIP-712 and EIP-1271 signatures. + function _checkSignature( + uint256 index, + address recipient, + address to, + uint128 amount, + bytes calldata signature + ) + internal + view + { + // Encode the claim parameters using claim type hash and hash it. + bytes32 claimHash = keccak256(abi.encode(SignatureHash.CLAIM_TYPEHASH, index, recipient, to, amount)); + + // Returns the keccak256 digest of the claim parameters using claim hash and the domain separator. + bytes32 digest = MessageHashUtils.toTypedDataHash({ domainSeparator: DOMAIN_SEPARATOR, structHash: claimHash }); + + // If recipient is an EOA, `isValidSignatureNow` recovers the signer using ECDSA from the signature and the + // digest. It returns true if the recovered signer matches the recipient. If the recipient is a contract, + // `isValidSignatureNow` checks if the recipient implements the `IERC1271` interface and returns the magic value + // as per EIP-1271 for the given digest and signature. + bool isSignatureValid = + SignatureChecker.isValidSignatureNow({ signer: recipient, hash: digest, signature: signature }); + + // Check: `isSignatureValid` is true. + if (!isSignatureValid) { + revert Errors.SablierMerkleBase_InvalidSignature(); + } + } + /*////////////////////////////////////////////////////////////////////////// PRIVATE READ-ONLY FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ - /// @dev See the documentation for the user-facing functions that call this internal function. + /// @dev See the documentation for the user-facing functions that call this private function. function _calculateMinFeeWei() private view returns (uint256) { // If the oracle is not set, return 0. if (ORACLE == address(0)) { @@ -218,6 +275,13 @@ abstract contract SablierMerkleBase is return firstClaimTime > 0 && block.timestamp > firstClaimTime + 7 days; } + /// @dev This function checks if `to` is zero address. + function _revertIfToZeroAddress(address to) private pure { + if (to == address(0)) { + revert Errors.SablierMerkleBase_ToZeroAddress(); + } + } + /*////////////////////////////////////////////////////////////////////////// INTERNAL STATE-CHANGING FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ diff --git a/src/interfaces/ISablierMerkleBase.sol b/src/interfaces/ISablierMerkleBase.sol index 6fa4014c..aa2ff204 100644 --- a/src/interfaces/ISablierMerkleBase.sol +++ b/src/interfaces/ISablierMerkleBase.sol @@ -24,11 +24,15 @@ interface ISablierMerkleBase is IAdminable { /// @notice The timestamp at which campaign starts and claim begins. /// @dev This is an immutable state variable. - function CAMPAIGN_START_TIME() external returns (uint40); + function CAMPAIGN_START_TIME() external view returns (uint40); + + /// @notice The domain separator, as required by EIP-712 and EIP-1271, used for signing claim to prevent replay + /// attacks across different campaigns. + function DOMAIN_SEPARATOR() external view returns (bytes32); /// @notice The cut-off point for the campaign, as a Unix timestamp. A value of zero means there is no expiration. /// @dev This is an immutable state variable. - function EXPIRATION() external returns (uint40); + function EXPIRATION() external view returns (uint40); /// @notice Retrieves the address of the factory contract. function FACTORY() external view returns (ISablierFactoryMerkleBase); @@ -39,7 +43,7 @@ interface ISablierMerkleBase is IAdminable { /// @notice The root of the Merkle tree used to validate the proofs of inclusion. /// @dev This is an immutable state variable. - function MERKLE_ROOT() external returns (bytes32); + function MERKLE_ROOT() external view returns (bytes32); /// @notice Retrieves the oracle contract address. /// @dev This is an immutable state variable. @@ -47,7 +51,7 @@ interface ISablierMerkleBase is IAdminable { /// @notice The ERC-20 token to distribute. /// @dev This is an immutable state variable. - function TOKEN() external returns (IERC20); + function TOKEN() external view returns (IERC20); /// @notice Calculates the min fee in wei required to claim the airdrop. /// @dev Uses {minFeeUSD} and the oracle price. @@ -71,7 +75,7 @@ interface ISablierMerkleBase is IAdminable { /// @notice Returns a flag indicating whether a claim has been made for a given index. /// @dev Uses a bitmap to save gas. /// @param index The index of the recipient to check. - function hasClaimed(uint256 index) external returns (bool); + function hasClaimed(uint256 index) external view returns (bool); /// @notice Returns a flag indicating whether the campaign has expired. function hasExpired() external view returns (bool); diff --git a/src/interfaces/ISablierMerkleInstant.sol b/src/interfaces/ISablierMerkleInstant.sol index 4f5d5d26..0c5baaaf 100644 --- a/src/interfaces/ISablierMerkleInstant.sol +++ b/src/interfaces/ISablierMerkleInstant.sol @@ -17,7 +17,7 @@ interface ISablierMerkleInstant is ISablierMerkleBase { STATE-CHANGING FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ - /// @notice Makes the claim by transferring the tokens directly to the recipient. + /// @notice Claim airdrop on behalf of eligible recipient and transfer it to the recipient address. /// /// @dev It emits a {Claim} event. /// @@ -34,7 +34,7 @@ interface ISablierMerkleInstant is ISablierMerkleBase { /// @param merkleProof The proof of inclusion in the Merkle tree. function claim(uint256 index, address recipient, uint128 amount, bytes32[] calldata merkleProof) external payable; - /// @notice Makes the claim by transferring the tokens directly to the `to` address. + /// @notice Claim airdrop and transfer the tokens to the `to` address. /// /// @dev It emits a {Claim} event. /// @@ -48,4 +48,63 @@ interface ISablierMerkleInstant is ISablierMerkleBase { /// @param amount The amount of ERC-20 tokens allocated to the `msg.sender`. /// @param merkleProof The proof of inclusion in the Merkle tree. function claimTo(uint256 index, address to, uint128 amount, bytes32[] calldata merkleProof) external payable; + + /// @notice Claim airdrop on behalf of eligible recipient using an EIP-712 or EIP-1271 signature, and transfer the + /// tokens to the `to` address. + /// + /// @dev It emits a {Claim} event. + /// + /// Requirements: + /// - If `recipient` is an EOA, it must match the recovered signer. + /// - If `recipient` is a contract, it must implement the IERC-1271 interface. + /// - The `to` is not the zero address. + /// - Refer to the requirements in {claim}. + /// + /// Below is the example of typed data to be signed by the airdrop recipient, referenced from + /// https://docs.metamask.io/wallet/how-to/sign-data/#example. + /// + /// ```json + /// types: { + /// EIP712Domain: [ + /// { name: "name", type: "string" }, + /// { name: "chainId", type: "uint256" }, + /// { name: "verifyingContract", type: "address" }, + /// ], + /// Claim: [ + /// { name: "index", type: "uint256" }, + /// { name: "recipient", type: "address" }, + /// { name: "to", type: "address" }, + /// { name: "amount", type: "uint128" }, + /// ], + /// }, + /// domain: { + /// name: "Sablier Airdrops Protocol", + /// chainId: 1, // Chain on which the contract is deployed + /// verifyingContract: "0xTheAddressOfThisContract", // The address of this contract + /// }, + /// primaryType: "Claim", + /// message: { + /// index: 2, // The index of the signer in the Merkle tree + /// recipient: "0xTheAddressOfTheRecipient", // The address of the airdrop recipient + /// to: "0xTheAddressReceivingTheTokens", // The address where recipient wants to transfer the tokens + /// amount: "1000000000000000000000" // The amount of tokens allocated to the recipient + /// }, + /// ``` + /// + /// @param index The index of the recipient in the Merkle tree. + /// @param recipient The address of the airdrop recipient who is providing the signature. + /// @param to The address receiving the ERC-20 tokens on behalf of the recipient. + /// @param amount The amount of ERC-20 tokens allocated to the recipient. + /// @param merkleProof The proof of inclusion in the Merkle tree. + /// @param signature The EIP-712 or EIP-1271 signature from the airdrop recipient. + function claimViaSig( + uint256 index, + address recipient, + address to, + uint128 amount, + bytes32[] calldata merkleProof, + bytes calldata signature + ) + external + payable; } diff --git a/src/interfaces/ISablierMerkleLL.sol b/src/interfaces/ISablierMerkleLL.sol index 52d6ef4f..dfccfc95 100644 --- a/src/interfaces/ISablierMerkleLL.sol +++ b/src/interfaces/ISablierMerkleLL.sol @@ -34,8 +34,8 @@ interface ISablierMerkleLL is ISablierMerkleLockup { STATE-CHANGING FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ - /// @notice Makes the claim. If the vesting end time is in the future, it creates a Lockup Linear stream, - /// otherwise it transfers the tokens directly to the recipient. + /// @notice Claim airdrop on behalf of eligible recipient. If the vesting end time is in the future, it creates a + /// Lockup Linear stream, otherwise it transfers the tokens directly to the recipient address. /// /// @dev It emits a {Claim} event. /// @@ -53,7 +53,7 @@ interface ISablierMerkleLL is ISablierMerkleLockup { /// @param merkleProof The proof of inclusion in the Merkle tree. function claim(uint256 index, address recipient, uint128 amount, bytes32[] calldata merkleProof) external payable; - /// @notice Makes the claim. If the vesting end time is in the future, it creates a Lockup Linear stream with `to` + /// @notice Claim airdrop. If the vesting end time is in the future, it creates a Lockup Linear stream with `to` /// address as the stream recipient, otherwise it transfers the tokens directly to the `to` address. /// /// @dev It emits a {Claim} event. @@ -68,4 +68,64 @@ interface ISablierMerkleLL is ISablierMerkleLockup { /// @param amount The amount of ERC-20 tokens allocated to the `msg.sender`. /// @param merkleProof The proof of inclusion in the Merkle tree. function claimTo(uint256 index, address to, uint128 amount, bytes32[] calldata merkleProof) external payable; + + /// @notice Claim airdrop on behalf of eligible recipient using an EIP-712 or EIP-1271 signature. If the vesting end + /// time is in the future, it creates a Lockup Linear stream with `to` address as the stream recipient, otherwise it + /// transfers the tokens directly to the `to` address. + /// + /// @dev It emits a {Claim} event. + /// + /// Requirements: + /// - If `recipient` is an EOA, it must match the recovered signer. + /// - If `recipient` is a contract, it must implement the IERC-1271 interface. + /// - The `to` is not the zero address. + /// - Refer to the requirements in {claim}. + /// + /// Below is the example of typed data to be signed by the airdrop recipient, referenced from + /// https://docs.metamask.io/wallet/how-to/sign-data/#example. + /// + /// ```json + /// types: { + /// EIP712Domain: [ + /// { name: "name", type: "string" }, + /// { name: "chainId", type: "uint256" }, + /// { name: "verifyingContract", type: "address" }, + /// ], + /// Claim: [ + /// { name: "index", type: "uint256" }, + /// { name: "recipient", type: "address" }, + /// { name: "to", type: "address" }, + /// { name: "amount", type: "uint128" }, + /// ], + /// }, + /// domain: { + /// name: "Sablier Airdrops Protocol", + /// chainId: 1, // Chain on which the contract is deployed + /// verifyingContract: "0xTheAddressOfThisContract", // The address of this contract + /// }, + /// primaryType: "Claim", + /// message: { + /// index: 2, // The index of the signer in the Merkle tree + /// recipient: "0xTheAddressOfTheRecipient", // The address of the airdrop recipient + /// to: "0xTheAddressReceivingTheTokens", // The address where recipient wants to transfer the tokens + /// amount: "1000000000000000000000" // The amount of tokens allocated to the recipient + /// }, + /// ``` + /// + /// @param index The index of the recipient in the Merkle tree. + /// @param recipient The address of the airdrop recipient who is providing the signature. + /// @param to The address to which Lockup stream or ERC-20 tokens will be sent on behalf of the recipient. + /// @param amount The amount of ERC-20 tokens allocated to the recipient. + /// @param merkleProof The proof of inclusion in the Merkle tree. + /// @param signature The EIP-712 or EIP-1271 signature from the airdrop recipient. + function claimViaSig( + uint256 index, + address recipient, + address to, + uint128 amount, + bytes32[] calldata merkleProof, + bytes calldata signature + ) + external + payable; } diff --git a/src/interfaces/ISablierMerkleLT.sol b/src/interfaces/ISablierMerkleLT.sol index a1baf721..db3bf5d0 100644 --- a/src/interfaces/ISablierMerkleLT.sol +++ b/src/interfaces/ISablierMerkleLT.sol @@ -16,7 +16,7 @@ interface ISablierMerkleLT is ISablierMerkleLockup { /// @notice Retrieves the start time of the vesting stream, as a Unix timestamp. Zero is a sentinel value for /// `block.timestamp`. - function VESTING_START_TIME() external returns (uint40); + function VESTING_START_TIME() external view returns (uint40); /// @notice Retrieves the tranches with their respective unlock percentages and durations. function tranchesWithPercentages() external view returns (MerkleLT.TrancheWithPercentage[] memory); @@ -25,8 +25,8 @@ interface ISablierMerkleLT is ISablierMerkleLockup { STATE-CHANGING FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ - /// @notice Makes the claim. If the vesting end time is in the future, it creates a Lockup Tranched stream, - /// otherwise it transfers the tokens directly to the recipient. + /// @notice Claim airdrop on behalf of eligible recipient. If the vesting end time is in the future, it creates a + /// Lockup Tranched stream, otherwise it transfers the tokens directly to the recipient address. /// /// @dev It emits a {Claim} event. /// @@ -45,7 +45,7 @@ interface ISablierMerkleLT is ISablierMerkleLockup { /// @param merkleProof The proof of inclusion in the Merkle tree. function claim(uint256 index, address recipient, uint128 amount, bytes32[] calldata merkleProof) external payable; - /// @notice Makes the claim. If the vesting end time is in the future, it creates a Lockup Tranched stream with `to` + /// @notice Claim airdrop. If the vesting end time is in the future, it creates a Lockup Tranched stream with `to` /// address as the stream recipient, otherwise it transfers the tokens directly to the `to` address. /// /// @dev It emits a {Claim} event. @@ -60,4 +60,64 @@ interface ISablierMerkleLT is ISablierMerkleLockup { /// @param amount The amount of ERC-20 tokens allocated to the `msg.sender`. /// @param merkleProof The proof of inclusion in the Merkle tree. function claimTo(uint256 index, address to, uint128 amount, bytes32[] calldata merkleProof) external payable; + + /// @notice Claim airdrop on behalf of eligible recipient using an EIP-712 or EIP-1271 signature. If the vesting end + /// time is in the future, it creates a Lockup Tranched stream with `to` address as the stream recipient, otherwise + /// it transfers the tokens directly to the `to` address. + /// + /// @dev It emits a {Claim} event. + /// + /// Requirements: + /// - If `recipient` is an EOA, it must match the recovered signer. + /// - If `recipient` is a contract, it must implement the IERC-1271 interface. + /// - The `to` is not the zero address. + /// - Refer to the requirements in {claim}. + /// + /// Below is the example of typed data to be signed by the airdrop recipient, referenced from + /// https://docs.metamask.io/wallet/how-to/sign-data/#example. + /// + /// ```json + /// types: { + /// EIP712Domain: [ + /// { name: "name", type: "string" }, + /// { name: "chainId", type: "uint256" }, + /// { name: "verifyingContract", type: "address" }, + /// ], + /// Claim: [ + /// { name: "index", type: "uint256" }, + /// { name: "recipient", type: "address" }, + /// { name: "to", type: "address" }, + /// { name: "amount", type: "uint128" }, + /// ], + /// }, + /// domain: { + /// name: "Sablier Airdrops Protocol", + /// chainId: 1, // Chain on which the contract is deployed + /// verifyingContract: "0xTheAddressOfThisContract", // The address of this contract + /// }, + /// primaryType: "Claim", + /// message: { + /// index: 2, // The index of the signer in the Merkle tree + /// recipient: "0xTheAddressOfTheRecipient", // The address of the airdrop recipient + /// to: "0xTheAddressReceivingTheTokens", // The address where recipient wants to transfer the tokens + /// amount: "1000000000000000000000" // The amount of tokens allocated to the recipient + /// }, + /// ``` + /// + /// @param index The index of the recipient in the Merkle tree. + /// @param recipient The address of the airdrop recipient who is providing the signature. + /// @param to The address to which Lockup stream or ERC-20 tokens will be sent on behalf of the recipient. + /// @param amount The amount of ERC-20 tokens allocated to the recipient. + /// @param merkleProof The proof of inclusion in the Merkle tree. + /// @param signature The EIP-712 or EIP-1271 signature from the airdrop recipient. + function claimViaSig( + uint256 index, + address recipient, + address to, + uint128 amount, + bytes32[] calldata merkleProof, + bytes calldata signature + ) + external + payable; } diff --git a/src/interfaces/ISablierMerkleLockup.sol b/src/interfaces/ISablierMerkleLockup.sol index 6e6da517..ee8789f8 100644 --- a/src/interfaces/ISablierMerkleLockup.sol +++ b/src/interfaces/ISablierMerkleLockup.sol @@ -28,11 +28,11 @@ interface ISablierMerkleLockup is ISablierMerkleBase { /// @notice A flag indicating whether the streams can be canceled. /// @dev This is an immutable state variable. - function STREAM_CANCELABLE() external returns (bool); + function STREAM_CANCELABLE() external view returns (bool); /// @notice A flag indicating whether the stream NFTs are transferable. /// @dev This is an immutable state variable. - function STREAM_TRANSFERABLE() external returns (bool); + function STREAM_TRANSFERABLE() external view returns (bool); /// @notice Retrieves the stream IDs associated with the airdrops claimed by the provided recipient. /// In practice, most campaigns will only have one stream per recipient. diff --git a/src/interfaces/ISablierMerkleVCA.sol b/src/interfaces/ISablierMerkleVCA.sol index f17e7b91..6141d3bb 100644 --- a/src/interfaces/ISablierMerkleVCA.sol +++ b/src/interfaces/ISablierMerkleVCA.sol @@ -52,8 +52,8 @@ interface ISablierMerkleVCA is ISablierMerkleBase { STATE-CHANGING FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ - /// @notice Makes the claim by transferring the tokens directly to the recipient. If the vesting end time is in the - /// future, it calculates the claim amount, otherwise it transfers the full amount. + /// @notice Claim airdrop on behalf of eligible recipient and transfer it to the recipient address. If the vesting + /// end time is in the future, it calculates the claim amount, otherwise it transfers the full amount. /// /// @dev It emits a {Claim} event. /// @@ -78,7 +78,8 @@ interface ISablierMerkleVCA is ISablierMerkleBase { external payable; - /// @notice Makes the claim by transferring the tokens directly to the `to` address. + /// @notice Claim airdrop. If the vesting end time is in the future, it calculates the claim amount to transfer to + /// the `to` address, otherwise it transfers the full amount. /// /// @dev It emits a {Claim} event. /// @@ -92,4 +93,64 @@ interface ISablierMerkleVCA is ISablierMerkleBase { /// @param fullAmount The total amount of ERC-20 tokens allocated to the recipient. /// @param merkleProof The proof of inclusion in the Merkle tree. function claimTo(uint256 index, address to, uint128 fullAmount, bytes32[] calldata merkleProof) external payable; + + /// @notice Claim airdrop on behalf of eligible recipient using an EIP-712 or EIP-1271 signature. If the vesting end + /// time is in the future, it calculates the claim amount to transfer to the `to` address, otherwise it transfers + /// the full amount. + /// + /// @dev It emits a {Claim} event. + /// + /// Requirements: + /// - If `recipient` is an EOA, it must match the recovered signer. + /// - If `recipient` is a contract, it must implement the IERC-1271 interface. + /// - The `to` must not be the zero address. + /// - Refer to the requirements in {claim}. + /// + /// Below is the example of typed data to be signed by the airdrop recipient, referenced from + /// https://docs.metamask.io/wallet/how-to/sign-data/#example. + /// + /// ```json + /// types: { + /// EIP712Domain: [ + /// { name: "name", type: "string" }, + /// { name: "chainId", type: "uint256" }, + /// { name: "verifyingContract", type: "address" }, + /// ], + /// Claim: [ + /// { name: "index", type: "uint256" }, + /// { name: "recipient", type: "address" }, + /// { name: "to", type: "address" }, + /// { name: "amount", type: "uint128" }, + /// ], + /// }, + /// domain: { + /// name: "Sablier Airdrops Protocol", + /// chainId: 1, // Chain on which the contract is deployed + /// verifyingContract: "0xTheAddressOfThisContract", // The address of this contract + /// }, + /// primaryType: "Claim", + /// message: { + /// index: 2, // The index of the signer in the Merkle tree + /// recipient: "0xTheAddressOfTheRecipient", // The address of the airdrop recipient + /// to: "0xTheAddressReceivingTheTokens", // The address where recipient wants to transfer the tokens + /// amount: "1000000000000000000000" // The amount of tokens allocated to the recipient + /// }, + /// ``` + /// + /// @param index The index of the recipient in the Merkle tree. + /// @param recipient The address of the airdrop recipient who is providing the signature. + /// @param to The address receiving the ERC-20 tokens on behalf of the recipient. + /// @param fullAmount The total amount of ERC-20 tokens allocated to the recipient. + /// @param merkleProof The proof of inclusion in the Merkle tree. + /// @param signature The EIP-712 or EIP-1271 signature from the airdrop recipient. + function claimViaSig( + uint256 index, + address recipient, + address to, + uint128 fullAmount, + bytes32[] calldata merkleProof, + bytes calldata signature + ) + external + payable; } diff --git a/src/libraries/Errors.sol b/src/libraries/Errors.sol index e89a3a94..09fa3f1f 100644 --- a/src/libraries/Errors.sol +++ b/src/libraries/Errors.sol @@ -6,6 +6,28 @@ import { UD60x18 } from "@prb/math/src/UD60x18.sol"; /// @title Errors /// @notice Library containing all custom errors the protocol may revert with. library Errors { + /*////////////////////////////////////////////////////////////////////////// + SABLIER-FACTORY-MERKLE-BASE + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Thrown when an unauthorized address collects fee without setting the fee recipient to admin address. + error SablierFactoryMerkleBase_FeeRecipientNotAdmin(address feeRecipient, address admin); + + /// @notice Thrown if fee transfer fails. + error SablierFactoryMerkleBase_FeeTransferFailed(address feeRecipient, uint256 feeAmount); + + /// @notice Thrown when trying to create a campaign with native token. + error SablierFactoryMerkleBase_ForbidNativeToken(address nativeToken); + + /// @notice Thrown when trying to set fee to a value that exceeds the maximum USD fee. + error SablierFactoryMerkleBase_MaxFeeUSDExceeded(uint256 newFeeUSD, uint256 maxFeeUSD); + + /// @notice Thrown when trying to set the native token address when it is already set. + error SablierFactoryMerkleBase_NativeTokenAlreadySet(address nativeToken); + + /// @notice Thrown when trying to set zero address as native token. + error SablierFactoryMerkleBase_NativeTokenZeroAddress(); + /*////////////////////////////////////////////////////////////////////////// SABLIER-MERKLE-BASE //////////////////////////////////////////////////////////////////////////*/ @@ -35,44 +57,14 @@ library Errors { /// @notice Thrown when trying to claim with an invalid Merkle proof. error SablierMerkleBase_InvalidProof(); + /// @notice Thrown when claiming with an invalid EIP-712 or EIP-1271 signature. + error SablierMerkleBase_InvalidSignature(); + /// @notice Thrown when trying to set a new min USD fee that is higher than the current fee. error SablierMerkleBase_NewMinFeeUSDNotLower(uint256 currentMinFeeUSD, uint256 newMinFeeUSD); - /*////////////////////////////////////////////////////////////////////////// - SABLIER-FACTORY-MERKLE-BASE - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Thrown when an unauthorized address collects fee without setting the fee recipient to admin address. - error SablierFactoryMerkleBase_FeeRecipientNotAdmin(address feeRecipient, address admin); - - /// @notice Thrown if fee transfer fails. - error SablierFactoryMerkleBase_FeeTransferFailed(address feeRecipient, uint256 feeAmount); - - /// @notice Thrown when trying to create a campaign with native token. - error SablierFactoryMerkleBase_ForbidNativeToken(address nativeToken); - - /// @notice Thrown when trying to set fee to a value that exceeds the maximum USD fee. - error SablierFactoryMerkleBase_MaxFeeUSDExceeded(uint256 newFeeUSD, uint256 maxFeeUSD); - - /// @notice Thrown when trying to set the native token address when it is already set. - error SablierFactoryMerkleBase_NativeTokenAlreadySet(address nativeToken); - - /// @notice Thrown when trying to set zero address as native token. - error SablierFactoryMerkleBase_NativeTokenZeroAddress(); - - /*////////////////////////////////////////////////////////////////////////// - SABLIER-MERKLE-INSTANT - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Thrown when trying to claim to the zero address. - error SablierMerkleInstant_ToZeroAddress(); - - /*////////////////////////////////////////////////////////////////////////// - SABLIER-MERKLE-LL - //////////////////////////////////////////////////////////////////////////*/ - /// @notice Thrown when trying to claim to the zero address. - error SablierMerkleLL_ToZeroAddress(); + error SablierMerkleBase_ToZeroAddress(); /*////////////////////////////////////////////////////////////////////////// SABLIER-MERKLE-LT @@ -81,9 +73,6 @@ library Errors { /// @notice Thrown when trying to claim from an LT campaign with tranches' unlock percentages not adding up to 100%. error SablierMerkleLT_TotalPercentageNotOneHundred(uint64 totalPercentage); - /// @notice Thrown when trying to claim to the zero address. - error SablierMerkleLT_ToZeroAddress(); - /*////////////////////////////////////////////////////////////////////////// SABLIER-MERKLE-VCA //////////////////////////////////////////////////////////////////////////*/ @@ -103,9 +92,6 @@ library Errors { /// @notice Thrown if the start time is zero. error SablierMerkleVCA_StartTimeZero(); - /// @notice Thrown when trying to claim to the zero address. - error SablierMerkleVCA_ToZeroAddress(); - /// @notice Thrown if the unlock percentage is greater than 100%. error SablierMerkleVCA_UnlockPercentageTooHigh(UD60x18 unlockPercentage); } diff --git a/src/libraries/SignatureHash.sol b/src/libraries/SignatureHash.sol new file mode 100644 index 00000000..4a2d11be --- /dev/null +++ b/src/libraries/SignatureHash.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.22; + +/// @title SignatureHash +/// @notice Library containing the hashes for the EIP-712 and EIP-1271 signatures. +library SignatureHash { + /// @dev The struct type hash for computing the domain separator for EIP-712 and EIP-1271 signatures. + bytes32 public constant CLAIM_TYPEHASH = + keccak256("Claim(uint256 index,address recipient,address to,uint128 amount)"); + + /// @notice The domain type hash for computing the domain separator. + bytes32 public constant DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"); + + /// @notice The protocol name for the EIP-712 and EIP-1271 signatures. + bytes32 public constant PROTOCOL_NAME = keccak256("Sablier Airdrops Protocol"); +} diff --git a/tests/Base.t.sol b/tests/Base.t.sol index a3358695..dc513539 100644 --- a/tests/Base.t.sol +++ b/tests/Base.t.sol @@ -5,6 +5,8 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { Arrays } from "@openzeppelin/contracts/utils/Arrays.sol"; import { ud2x18 } from "@prb/math/src/UD2x18.sol"; import { ud, UD60x18 } from "@prb/math/src/UD60x18.sol"; +import { ERC1271WalletMock } from "@sablier/evm-utils/src/mocks/ERC1271WalletMock.sol"; +import { Noop } from "@sablier/evm-utils/src/mocks/Noop.sol"; import { BaseTest as EvmUtilsBase } from "@sablier/evm-utils/src/tests/BaseTest.sol"; import { ISablierLockup } from "@sablier/lockup/src/interfaces/ISablierLockup.sol"; import { LockupNFTDescriptor } from "@sablier/lockup/src/LockupNFTDescriptor.sol"; @@ -31,7 +33,6 @@ import { SablierMerkleLT } from "src/SablierMerkleLT.sol"; import { SablierMerkleVCA } from "src/SablierMerkleVCA.sol"; import { MerkleInstant, MerkleLL, MerkleLT, MerkleVCA } from "src/types/DataTypes.sol"; import { Assertions } from "./utils/Assertions.sol"; -import { ChainlinkOracleMock } from "./utils/ChainlinkMocks.sol"; import { Constants } from "./utils/Constants.sol"; import { DeployOptimized } from "./utils/DeployOptimized.sol"; import { Fuzzers } from "./utils/Fuzzers.sol"; @@ -46,6 +47,8 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, F VARIABLES //////////////////////////////////////////////////////////////////////////*/ + bytes internal eip712Signature; + uint256 internal recipientPrivateKey; Users internal users; /*////////////////////////////////////////////////////////////////////////// @@ -65,7 +68,6 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, F ISablierMerkleLL internal merkleLL; ISablierMerkleLT internal merkleLT; ISablierMerkleVCA internal merkleVCA; - ChainlinkOracleMock internal oracle; /*////////////////////////////////////////////////////////////////////////// SET-UP FUNCTION @@ -76,15 +78,12 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, F // Create the protocol admin. users.admin = payable(makeAddr({ name: "Admin" })); - vm.startPrank({ msgSender: users.admin }); + vm.startPrank(users.admin); // Deploy the Lockup contract. address nftDescriptor = address(new LockupNFTDescriptor()); lockup = new SablierLockup(users.admin, nftDescriptor); - // Deploy the mock Chainlink Oracle. - oracle = new ChainlinkOracleMock(); - // Deploy the factories. deployFactoriesConditionally(); @@ -116,15 +115,24 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, F spenders[2] = address(factoryMerkleLT); spenders[3] = address(factoryMerkleVCA); - // Create test users. + // Create recipient and store private key since it is used to claim using signature. + (users.recipient, recipientPrivateKey) = createUserAndKey("Recipient", spenders); + + // Create a new recipient as an ERC-1271 smart contract with recipient as the admin. + users.smartWalletWithIERC1271 = payable(address(new ERC1271WalletMock(users.recipient))); + vm.label(users.smartWalletWithIERC1271, "SmartWalletWithIERC1271"); + dealAndApproveSpenders(users.smartWalletWithIERC1271, spenders); + + // Create a new recipient as a dummy smart contract. + users.smartWalletWithoutIERC1271 = payable(address(new Noop())); + vm.label(users.smartWalletWithoutIERC1271, "SmartWalletWithoutIERC1271"); + dealAndApproveSpenders(users.smartWalletWithoutIERC1271, spenders); + + // Create rest of the users. users.accountant = createUser("Accountant", spenders); users.campaignCreator = createUser("CampaignCreator", spenders); users.eve = createUser("Eve", spenders); - users.recipient = createUser("Recipient", spenders); - users.recipient1 = createUser("Recipient1", spenders); - users.recipient2 = createUser("Recipient2", spenders); - users.recipient3 = createUser("Recipient3", spenders); - users.recipient4 = createUser("Recipient4", spenders); + users.unknownRecipient = createUser("UnknownRecipient", spenders); users.sender = createUser("Sender", spenders); // Assign fee collector and fee management roles to the accountant user. @@ -175,34 +183,52 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, F merkleProof = leaves.length == 1 ? new bytes32[](0) : getProof(leaves.toBytes32(), pos); } - function index1Proof() public view returns (bytes32[] memory) { - return indexProof(INDEX1, users.recipient1); + /// @dev Returns the index of the default recipient in the Merkle tree. + function getIndexInMerkleTree() internal view returns (uint256 index) { + index = getIndexInMerkleTree(users.recipient); } - function index2Proof() public view returns (bytes32[] memory) { - return indexProof(INDEX2, users.recipient2); - } - - function index3Proof() public view returns (bytes32[] memory) { - return indexProof(INDEX3, users.recipient3); + /// @dev Returns the index of the recipient in the Merkle tree. + function getIndexInMerkleTree(address recipient) internal view returns (uint256 index) { + if (recipient == users.recipient) { + index = INDEX1; + } else if (recipient == users.smartWalletWithIERC1271) { + index = INDEX2; + } else if (recipient == users.smartWalletWithoutIERC1271) { + index = INDEX3; + } else if (recipient == users.unknownRecipient) { + index = INDEX4; + } else { + revert("Invalid recipient"); + } } - function index4Proof() public view returns (bytes32[] memory) { - return indexProof(INDEX4, users.recipient4); + /// @dev Returns the Merkle proof for the default recipient. + function getMerkleProof() internal view returns (bytes32[] memory merkleProof) { + merkleProof = getMerkleProof(users.recipient); } - function indexProof(uint256 index, address recipient) public view returns (bytes32[] memory) { - return computeMerkleProof(LeafData({ index: index, recipient: recipient, amount: CLAIM_AMOUNT }), LEAVES); + /// @dev Returns the Merkle proof for the given recipient. + function getMerkleProof(address recipient) internal view returns (bytes32[] memory merkleProof) { + uint256 index = getIndexInMerkleTree(recipient); + merkleProof = computeMerkleProof(LeafData({ index: index, recipient: recipient, amount: CLAIM_AMOUNT }), LEAVES); } /// @dev We need a separate function to initialize the Merkle tree because, at the construction time, the users are /// not yet set. function initMerkleTree() public { LeafData[] memory leafData = new LeafData[](RECIPIENT_COUNT); - leafData[0] = LeafData({ index: INDEX1, recipient: users.recipient1, amount: CLAIM_AMOUNT }); - leafData[1] = LeafData({ index: INDEX2, recipient: users.recipient2, amount: CLAIM_AMOUNT }); - leafData[2] = LeafData({ index: INDEX3, recipient: users.recipient3, amount: CLAIM_AMOUNT }); - leafData[3] = LeafData({ index: INDEX4, recipient: users.recipient4, amount: CLAIM_AMOUNT }); + address[] memory recipients = new address[](RECIPIENT_COUNT); + recipients[0] = users.recipient; + recipients[1] = users.smartWalletWithIERC1271; + recipients[2] = users.smartWalletWithoutIERC1271; + recipients[3] = users.unknownRecipient; + + for (uint256 i = 0; i < RECIPIENT_COUNT; ++i) { + leafData[i] = + LeafData({ index: getIndexInMerkleTree(recipients[i]), recipient: recipients[i], amount: CLAIM_AMOUNT }); + } + MerkleBuilder.computeLeaves(LEAVES, leafData); MERKLE_ROOT = getRoot(LEAVES.toBytes32()); } @@ -232,7 +258,21 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, F vm.expectCall( merkleLockup, msgValue, - abi.encodeCall(ISablierMerkleInstant.claimTo, (INDEX1, users.eve, CLAIM_AMOUNT, index1Proof())) + abi.encodeCall( + ISablierMerkleInstant.claimTo, (getIndexInMerkleTree(), users.eve, CLAIM_AMOUNT, getMerkleProof()) + ) + ); + } + + /// @dev Expects a call to {claimViaSig} with msgValue as `msg.value`. + function expectCallToClaimViaSigWithMsgValue(address merkleLockup, uint256 msgValue) internal { + vm.expectCall( + merkleLockup, + msgValue, + abi.encodeCall( + ISablierMerkleInstant.claimViaSig, + (getIndexInMerkleTree(), users.recipient, users.eve, CLAIM_AMOUNT, getMerkleProof(), eip712Signature) + ) ); } @@ -257,7 +297,9 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, F vm.expectCall( merkleLockup, msgValue, - abi.encodeCall(ISablierMerkleInstant.claim, (INDEX1, users.recipient1, CLAIM_AMOUNT, index1Proof())) + abi.encodeCall( + ISablierMerkleInstant.claim, (getIndexInMerkleTree(), users.recipient, CLAIM_AMOUNT, getMerkleProof()) + ) ); } diff --git a/tests/fork/chainlink-oracle/ChainlinkOracle.t.sol b/tests/fork/chainlink-oracle/ChainlinkOracle.t.sol deleted file mode 100644 index ef42f93b..00000000 --- a/tests/fork/chainlink-oracle/ChainlinkOracle.t.sol +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.22 <0.9.0; - -import { BaseScript } from "scripts/solidity/Base.sol"; -import { SablierFactoryMerkleInstant } from "src/SablierFactoryMerkleInstant.sol"; - -import { Base_Test } from "./../../Base.t.sol"; - -contract ChainlinkOracle_Fork_Test is BaseScript, Base_Test { - /// @notice A modifier that runs the forked test for a given chain - modifier initForkTest(string memory chainName) { - // Fork chain on the latest block number. - vm.createSelectFork({ urlOrAlias: chainName }); - - // Deploy the Merkle Instant factory and create a new campaign. - factoryMerkleInstant = new SablierFactoryMerkleInstant(users.admin, initialMinFeeUSD(), chainlinkOracle()); - merkleInstant = factoryMerkleInstant.createMerkleInstant( - merkleInstantConstructorParams(), AGGREGATE_AMOUNT, RECIPIENT_COUNT - ); - - // Assert that the Chainlink returns a non-zero price by checking the value of min fee in wei. - assertLt(0, merkleInstant.calculateMinFeeWei(), "min fee wei"); - - _; - } - - function testFork_ChainlinkOracle_Mainnet() external initForkTest("mainnet") { } - - function testFork_ChainlinkOracle_Arbitrum() external initForkTest("arbitrum") { } - - function testFork_ChainlinkOracle_Avalanche() external initForkTest("avalanche") { } - - function testFork_ChainlinkOracle_Base() external initForkTest("base") { } - - // function testFork_ChainlinkOracle_BNB() external initForkTest("bnb") { } - - function testFork_ChainlinkOracle_Gnosis() external initForkTest("gnosis") { } - - function testFork_ChainlinkOracle_Linea() external initForkTest("linea") { } - - function testFork_ChainlinkOracle_Optimism() external initForkTest("optimism") { } - - function testFork_ChainlinkOracle_Polygon() external initForkTest("polygon") { } - - function testFork_ChainlinkOracle_Scroll() external initForkTest("scroll") { } -} diff --git a/tests/integration/Integration.t.sol b/tests/integration/Integration.t.sol index 2d334588..189b60a3 100644 --- a/tests/integration/Integration.t.sol +++ b/tests/integration/Integration.t.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.22 <0.9.0; -import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { ISablierMerkleInstant } from "src/interfaces/ISablierMerkleInstant.sol"; import { ISablierMerkleLL } from "src/interfaces/ISablierMerkleLL.sol"; import { ISablierMerkleLT } from "src/interfaces/ISablierMerkleLT.sol"; @@ -9,6 +8,7 @@ import { ISablierMerkleVCA } from "src/interfaces/ISablierMerkleVCA.sol"; import { MerkleInstant, MerkleLL, MerkleLT, MerkleVCA } from "src/types/DataTypes.sol"; import { Base_Test } from "../Base.t.sol"; +import { Utilities } from "../utils/Utilities.sol"; abstract contract Integration_Test is Base_Test { /*////////////////////////////////////////////////////////////////////////// @@ -39,14 +39,14 @@ abstract contract Integration_Test is Base_Test { MERKLE-CLAIMS //////////////////////////////////////////////////////////////////////////*/ - /// @dev Claim to `users.recipient1` address using {claim} function. + /// @dev Claim to `users.recipient` address using {claim} function. function claim() internal { claim({ msgValue: MIN_FEE_WEI, - index: INDEX1, - recipient: users.recipient1, + index: getIndexInMerkleTree(), + recipient: users.recipient, amount: CLAIM_AMOUNT, - merkleProof: index1Proof() + merkleProof: getMerkleProof() }); } @@ -59,40 +59,21 @@ abstract contract Integration_Test is Base_Test { ) internal { - if (Strings.equal(campaignType, "instant")) { - merkleInstant.claim{ value: msgValue }({ - index: index, - recipient: recipient, - amount: amount, - merkleProof: merkleProof - }); - } else if (Strings.equal(campaignType, "ll")) { - merkleLL.claim{ value: msgValue }({ - index: index, - recipient: recipient, - amount: amount, - merkleProof: merkleProof - }); - } else if (Strings.equal(campaignType, "lt")) { - merkleLT.claim{ value: msgValue }({ - index: index, - recipient: recipient, - amount: amount, - merkleProof: merkleProof - }); - } else if (Strings.equal(campaignType, "vca")) { - merkleVCA.claim{ value: msgValue }({ - index: index, - recipient: recipient, - fullAmount: amount, - merkleProof: merkleProof - }); - } - } - - /// @dev Claim to Eve address on behalf of `users.recipient1` using {claimTo} function. + // Using `ISablierMerkleInstant` interface over `merkleBase` works for all Merkle contracts due to similarity in + // claim function signature. + address campaignAddr = address(merkleBase); + ISablierMerkleInstant(campaignAddr).claim{ value: msgValue }(index, recipient, amount, merkleProof); + } + + /// @dev Claim to Eve address on behalf of `users.recipient` using {claimTo} function. function claimTo() internal { - claimTo({ msgValue: MIN_FEE_WEI, index: INDEX1, to: users.eve, amount: CLAIM_AMOUNT, merkleProof: index1Proof() }); + claimTo({ + msgValue: MIN_FEE_WEI, + index: getIndexInMerkleTree(), + to: users.eve, + amount: CLAIM_AMOUNT, + merkleProof: getMerkleProof() + }); } function claimTo( @@ -104,15 +85,54 @@ abstract contract Integration_Test is Base_Test { ) internal { - if (Strings.equal(campaignType, "instant")) { - merkleInstant.claimTo{ value: msgValue }({ index: index, to: to, amount: amount, merkleProof: merkleProof }); - } else if (Strings.equal(campaignType, "ll")) { - merkleLL.claimTo{ value: msgValue }({ index: index, to: to, amount: amount, merkleProof: merkleProof }); - } else if (Strings.equal(campaignType, "lt")) { - merkleLT.claimTo{ value: msgValue }({ index: index, to: to, amount: amount, merkleProof: merkleProof }); - } else if (Strings.equal(campaignType, "vca")) { - merkleVCA.claimTo{ value: msgValue }({ index: index, to: to, fullAmount: amount, merkleProof: merkleProof }); - } + // Using `ISablierMerkleInstant` interface over `merkleBase` works for all Merkle contracts due to similarity in + // claimTo function signature. + address campaignAddr = address(merkleBase); + ISablierMerkleInstant(campaignAddr).claimTo{ value: msgValue }(index, to, amount, merkleProof); + } + + /// @dev Claim to Eve address on behalf of `users.recipient` using {claimViaSig} function. + function claimViaSig() internal { + claimViaSig({ + msgValue: MIN_FEE_WEI, + index: getIndexInMerkleTree(), + recipient: users.recipient, + to: users.eve, + amount: CLAIM_AMOUNT, + merkleProof: getMerkleProof(), + signature: eip712Signature + }); + } + + function claimViaSig( + uint256 msgValue, + uint256 index, + address recipient, + address to, + uint128 amount, + bytes32[] memory merkleProof, + bytes memory signature + ) + internal + { + // Using `ISablierMerkleInstant` interface over `merkleBase` works for all Merkle contracts due to similarity in + // claimViaSig function signature. + address campaignAddr = address(merkleBase); + ISablierMerkleInstant(campaignAddr).claimViaSig{ value: msgValue }( + index, recipient, to, amount, merkleProof, signature + ); + } + + /// @dev Generate the EIP-712 signature to claim with default parameters. + function generateSignature(address user, address merkleContract) internal view returns (bytes memory) { + return Utilities.generateEIP712Signature({ + signerPrivateKey: recipientPrivateKey, + merkleContract: merkleContract, + index: getIndexInMerkleTree(user), + recipient: user, + to: users.eve, + amount: CLAIM_AMOUNT + }); } /*////////////////////////////////////////////////////////////////////////// diff --git a/tests/integration/concrete/campaign/instant/MerkleInstant.t.sol b/tests/integration/concrete/campaign/instant/MerkleInstant.t.sol index d564fc8e..9e875cf3 100644 --- a/tests/integration/concrete/campaign/instant/MerkleInstant.t.sol +++ b/tests/integration/concrete/campaign/instant/MerkleInstant.t.sol @@ -20,6 +20,7 @@ abstract contract MerkleInstant_Integration_Shared_Test is Integration_Test { // Cast the {FactoryMerkleInstant} contract as {ISablierFactoryMerkleBase} factoryMerkleBase = factoryMerkleInstant; + // Cast the {MerkleInstant} contract as {ISablierMerkleBase} merkleBase = merkleInstant; diff --git a/tests/integration/concrete/campaign/instant/claim-to/claimTo.t.sol b/tests/integration/concrete/campaign/instant/claim-to/claimTo.t.sol index a1f7329d..ea4e53a0 100644 --- a/tests/integration/concrete/campaign/instant/claim-to/claimTo.t.sol +++ b/tests/integration/concrete/campaign/instant/claim-to/claimTo.t.sol @@ -12,28 +12,24 @@ contract ClaimTo_MerkleInstant_Integration_Test is ClaimTo_Integration_Test, Mer ClaimTo_Integration_Test.setUp(); } - function test_ClaimTo() + function test_WhenMerkleProofValid() external + override whenToAddressNotZero - givenCampaignStartTimeNotInFuture - givenCampaignNotExpired - givenMsgValueNotLessThanFee givenCallerNotClaimed - whenIndexValid whenCallerEligible - whenAmountValid - whenMerkleProofValid { uint256 previousFeeAccrued = address(factoryMerkleInstant).balance; + uint256 index = getIndexInMerkleTree(); vm.expectEmit({ emitter: address(merkleInstant) }); - emit ISablierMerkleInstant.Claim(INDEX1, users.recipient1, CLAIM_AMOUNT, users.eve); + emit ISablierMerkleInstant.Claim(index, users.recipient, CLAIM_AMOUNT, users.eve); expectCallToTransfer({ to: users.eve, value: CLAIM_AMOUNT }); expectCallToClaimToWithMsgValue(address(merkleInstant), MIN_FEE_WEI); claimTo(); - assertTrue(merkleInstant.hasClaimed(INDEX1), "not claimed"); + assertTrue(merkleInstant.hasClaimed(index), "not claimed"); assertEq(address(factoryMerkleInstant).balance, previousFeeAccrued + MIN_FEE_WEI, "fee collected"); } diff --git a/tests/integration/concrete/campaign/instant/claim-via-sign/claimViaSig.t.sol b/tests/integration/concrete/campaign/instant/claim-via-sign/claimViaSig.t.sol new file mode 100644 index 00000000..4b366a66 --- /dev/null +++ b/tests/integration/concrete/campaign/instant/claim-via-sign/claimViaSig.t.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22 <0.9.0; + +import { ISablierMerkleInstant } from "src/interfaces/ISablierMerkleInstant.sol"; + +import { ClaimViaSig_Integration_Test } from "./../../shared/claim-via-sig/claimViaSig.t.sol"; +import { MerkleInstant_Integration_Shared_Test } from "./../MerkleInstant.t.sol"; + +contract ClaimViaSig_MerkleInstant_Integration_Test is + ClaimViaSig_Integration_Test, + MerkleInstant_Integration_Shared_Test +{ + function setUp() public virtual override(MerkleInstant_Integration_Shared_Test, ClaimViaSig_Integration_Test) { + MerkleInstant_Integration_Shared_Test.setUp(); + ClaimViaSig_Integration_Test.setUp(); + } + + function test_WhenSignerSameAsRecipient() + external + override + whenToAddressNotZero + givenRecipientIsEOA + whenSignatureCompatible + { + uint256 previousFeeAccrued = address(factoryMerkleInstant).balance; + uint256 index = getIndexInMerkleTree(); + + eip712Signature = generateSignature(users.recipient, address(merkleInstant)); + + vm.expectEmit({ emitter: address(merkleInstant) }); + emit ISablierMerkleInstant.Claim(index, users.recipient, CLAIM_AMOUNT, users.eve); + + expectCallToTransfer({ to: users.eve, value: CLAIM_AMOUNT }); + expectCallToClaimViaSigWithMsgValue(address(merkleInstant), MIN_FEE_WEI); + claimViaSig(); + + assertTrue(merkleInstant.hasClaimed(index), "not claimed"); + + assertEq(address(factoryMerkleInstant).balance, previousFeeAccrued + MIN_FEE_WEI, "fee collected"); + } + + function test_WhenRecipientImplementsIERC1271Interface() + external + override + whenToAddressNotZero + givenRecipientIsContract + { + uint256 previousFeeAccrued = address(factoryMerkleInstant).balance; + uint256 index = getIndexInMerkleTree(users.smartWalletWithIERC1271); + + eip712Signature = generateSignature(users.smartWalletWithIERC1271, address(merkleInstant)); + + vm.expectEmit({ emitter: address(merkleInstant) }); + emit ISablierMerkleInstant.Claim(index, users.smartWalletWithIERC1271, CLAIM_AMOUNT, users.eve); + + expectCallToTransfer({ to: users.eve, value: CLAIM_AMOUNT }); + claimViaSig({ + msgValue: MIN_FEE_WEI, + index: index, + recipient: users.smartWalletWithIERC1271, + to: users.eve, + amount: CLAIM_AMOUNT, + merkleProof: getMerkleProof(users.smartWalletWithIERC1271), + signature: eip712Signature + }); + + assertTrue(merkleInstant.hasClaimed(index), "not claimed"); + + assertEq(address(factoryMerkleInstant).balance, previousFeeAccrued + MIN_FEE_WEI, "fee collected"); + } +} diff --git a/tests/integration/concrete/campaign/instant/claim/claim.t.sol b/tests/integration/concrete/campaign/instant/claim/claim.t.sol index aa3d8328..c0ed418d 100644 --- a/tests/integration/concrete/campaign/instant/claim/claim.t.sol +++ b/tests/integration/concrete/campaign/instant/claim/claim.t.sol @@ -11,8 +11,9 @@ contract Claim_MerkleInstant_Integration_Test is Claim_Integration_Test, MerkleI MerkleInstant_Integration_Shared_Test.setUp(); } - function test_Claim() + function test_WhenMerkleProofValid() external + override givenCampaignStartTimeNotInFuture givenCampaignNotExpired givenMsgValueNotLessThanFee @@ -20,18 +21,18 @@ contract Claim_MerkleInstant_Integration_Test is Claim_Integration_Test, MerkleI whenIndexValid whenRecipientEligible whenAmountValid - whenMerkleProofValid { uint256 previousFeeAccrued = address(factoryMerkleInstant).balance; + uint256 index = getIndexInMerkleTree(); vm.expectEmit({ emitter: address(merkleInstant) }); - emit ISablierMerkleInstant.Claim(INDEX1, users.recipient1, CLAIM_AMOUNT, users.recipient1); + emit ISablierMerkleInstant.Claim(index, users.recipient, CLAIM_AMOUNT, users.recipient); - expectCallToTransfer({ to: users.recipient1, value: CLAIM_AMOUNT }); + expectCallToTransfer({ to: users.recipient, value: CLAIM_AMOUNT }); expectCallToClaimWithMsgValue(address(merkleInstant), MIN_FEE_WEI); claim(); - assertTrue(merkleInstant.hasClaimed(INDEX1), "not claimed"); + assertTrue(merkleInstant.hasClaimed(index), "not claimed"); assertEq(address(factoryMerkleInstant).balance, previousFeeAccrued + MIN_FEE_WEI, "fee collected"); } diff --git a/tests/integration/concrete/campaign/instant/constructor.t.sol b/tests/integration/concrete/campaign/instant/constructor.t.sol index aaf972ac..8bfcc13b 100644 --- a/tests/integration/concrete/campaign/instant/constructor.t.sol +++ b/tests/integration/concrete/campaign/instant/constructor.t.sol @@ -3,9 +3,10 @@ pragma solidity >=0.8.22 <0.9.0; import { SablierMerkleInstant } from "src/SablierMerkleInstant.sol"; -import { MerkleInstant_Integration_Shared_Test } from "./MerkleInstant.t.sol"; +import { Utilities } from "../../../../utils/Utilities.sol"; +import { Integration_Test } from "./../../../Integration.t.sol"; -contract Constructor_MerkleInstant_Integration_Test is MerkleInstant_Integration_Shared_Test { +contract Constructor_MerkleInstant_Integration_Test is Integration_Test { function test_Constructor() external { // Make Factory the caller for the constructor test. setMsgSender(address(factoryMerkleInstant)); @@ -18,6 +19,11 @@ contract Constructor_MerkleInstant_Integration_Test is MerkleInstant_Integration assertEq(constructedInstant.admin(), users.campaignCreator, "admin"); assertEq(constructedInstant.campaignName(), CAMPAIGN_NAME, "campaign name"); assertEq(constructedInstant.CAMPAIGN_START_TIME(), CAMPAIGN_START_TIME, "campaign start time"); + assertEq( + constructedInstant.DOMAIN_SEPARATOR(), + Utilities.computeEIP712DomainSeparator(address(constructedInstant)), + "domain separator" + ); assertEq(constructedInstant.EXPIRATION(), EXPIRATION, "expiration"); assertEq(address(constructedInstant.FACTORY()), address(factoryMerkleInstant), "factory"); assertEq(constructedInstant.ipfsCID(), IPFS_CID, "IPFS CID"); diff --git a/tests/integration/concrete/campaign/ll/MerkleLL.t.sol b/tests/integration/concrete/campaign/ll/MerkleLL.t.sol index a02ed96b..a39850c3 100644 --- a/tests/integration/concrete/campaign/ll/MerkleLL.t.sol +++ b/tests/integration/concrete/campaign/ll/MerkleLL.t.sol @@ -18,6 +18,7 @@ abstract contract MerkleLL_Integration_Shared_Test is Integration_Test { // Cast the {FactoryMerkleLL} contract as {ISablierFactoryMerkleBase} factoryMerkleBase = factoryMerkleLL; + // Cast the {MerkleLL} contract as {ISablierMerkleBase} merkleBase = merkleLL; diff --git a/tests/integration/concrete/campaign/ll/claim-to/claimTo.t.sol b/tests/integration/concrete/campaign/ll/claim-to/claimTo.t.sol index 5bf37299..99493a40 100644 --- a/tests/integration/concrete/campaign/ll/claim-to/claimTo.t.sol +++ b/tests/integration/concrete/campaign/ll/claim-to/claimTo.t.sol @@ -25,7 +25,7 @@ contract ClaimTo_MerkleLL_Integration_Test is ClaimTo_Integration_Test, MerkleLL // It should emit a {Claim} event. vm.expectEmit({ emitter: address(merkleLL) }); - emit ISablierMerkleLockup.Claim(INDEX1, users.recipient1, CLAIM_AMOUNT, users.eve); + emit ISablierMerkleLockup.Claim(getIndexInMerkleTree(), users.recipient, CLAIM_AMOUNT, users.eve); expectCallToTransfer({ to: users.eve, value: CLAIM_AMOUNT }); expectCallToClaimToWithMsgValue(address(merkleLL), MIN_FEE_WEI); @@ -47,7 +47,10 @@ contract ClaimTo_MerkleLL_Integration_Test is ClaimTo_Integration_Test, MerkleLL params.startUnlockPercentage = ud(0.5e18); params.cliffUnlockPercentage = ud(0.6e18); + // Create the MerkleLL campaign and cast it as {ISablierMerkleBase}. merkleLL = factoryMerkleLL.createMerkleLL(params, AGGREGATE_AMOUNT, RECIPIENT_COUNT); + merkleBase = merkleLL; + uint128 startUnlockAmount = ud(CLAIM_AMOUNT).mul(ud(0.5e18)).intoUint128(); uint128 cliffUnlockAmount = ud(CLAIM_AMOUNT).mul(ud(0.6e18)).intoUint128(); @@ -73,7 +76,9 @@ contract ClaimTo_MerkleLL_Integration_Test is ClaimTo_Integration_Test, MerkleLL MerkleLL.ConstructorParams memory params = merkleLLConstructorParams(); params.vestingStartTime = 0; + // Create the MerkleLL campaign and cast it as {ISablierMerkleBase}. merkleLL = factoryMerkleLL.createMerkleLL(params, AGGREGATE_AMOUNT, RECIPIENT_COUNT); + merkleBase = merkleLL; // It should create a stream with block.timestamp as vesting start time. // It should create a stream with Eve as recipient. @@ -91,7 +96,9 @@ contract ClaimTo_MerkleLL_Integration_Test is ClaimTo_Integration_Test, MerkleLL params.cliffDuration = 0; params.cliffUnlockPercentage = ZERO; + // Create the MerkleLL campaign and cast it as {ISablierMerkleBase}. merkleLL = factoryMerkleLL.createMerkleLL(params, AGGREGATE_AMOUNT, RECIPIENT_COUNT); + merkleBase = merkleLL; // It should create a stream with block.timestamp as vesting start time. // It should create a stream with cliff as zero. @@ -119,9 +126,11 @@ contract ClaimTo_MerkleLL_Integration_Test is ClaimTo_Integration_Test, MerkleLL uint256 expectedStreamId = lockup.nextStreamId(); uint256 previousFeeAccrued = address(factoryMerkleLL).balance; + uint256 index = getIndexInMerkleTree(); + // It should emit a {Claim} event. vm.expectEmit({ emitter: address(merkleLL) }); - emit ISablierMerkleLockup.Claim(INDEX1, users.recipient1, CLAIM_AMOUNT, expectedStreamId, users.eve); + emit ISablierMerkleLockup.Claim(index, users.recipient, CLAIM_AMOUNT, expectedStreamId, users.eve); expectCallToTransferFrom({ from: address(merkleLL), to: address(lockup), value: CLAIM_AMOUNT }); expectCallToClaimToWithMsgValue(address(merkleLL), MIN_FEE_WEI); @@ -147,14 +156,14 @@ contract ClaimTo_MerkleLL_Integration_Test is ClaimTo_Integration_Test, MerkleLL assertEq(lockup.isTransferable(expectedStreamId), STREAM_TRANSFERABLE, "is transferable"); assertEq(lockup.wasCanceled(expectedStreamId), false, "was canceled"); - assertTrue(merkleLL.hasClaimed(INDEX1), "not claimed"); + assertTrue(merkleLL.hasClaimed(index), "not claimed"); // It should create the stream with the correct Lockup model. assertEq(lockup.getLockupModel(expectedStreamId), Lockup.Model.LOCKUP_LINEAR); uint256[] memory expectedClaimedStreamIds = new uint256[](1); expectedClaimedStreamIds[0] = expectedStreamId; - assertEq(merkleLL.claimedStreams(users.recipient1), expectedClaimedStreamIds, "claimed streams"); + assertEq(merkleLL.claimedStreams(users.recipient), expectedClaimedStreamIds, "claimed streams"); assertEq(address(factoryMerkleLL).balance, previousFeeAccrued + MIN_FEE_WEI, "fee collected"); } diff --git a/tests/integration/concrete/campaign/ll/claim-to/claimTo.tree b/tests/integration/concrete/campaign/ll/claim-to/claimTo.tree index a452004d..20851942 100644 --- a/tests/integration/concrete/campaign/ll/claim-to/claimTo.tree +++ b/tests/integration/concrete/campaign/ll/claim-to/claimTo.tree @@ -1,5 +1,5 @@ ClaimTo_MerkleLL_Integration_Test -└── when valid Merkle proof +└── when merkle proof valid ├── when vesting end time not exceed claim time │ ├── it should transfer the tokens to Eve │ └── it should emit a {Claim} event diff --git a/tests/integration/concrete/campaign/ll/claim-via-sign/claimViaSig.t.sol b/tests/integration/concrete/campaign/ll/claim-via-sign/claimViaSig.t.sol new file mode 100644 index 00000000..1376125a --- /dev/null +++ b/tests/integration/concrete/campaign/ll/claim-via-sign/claimViaSig.t.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22 <0.9.0; + +import { ISablierMerkleLockup } from "src/interfaces/ISablierMerkleLockup.sol"; + +import { ClaimViaSig_Integration_Test } from "./../../shared/claim-via-sig/claimViaSig.t.sol"; +import { MerkleLL_Integration_Shared_Test } from "./../MerkleLL.t.sol"; + +contract ClaimViaSig_MerkleLL_Integration_Test is ClaimViaSig_Integration_Test, MerkleLL_Integration_Shared_Test { + function setUp() public virtual override(MerkleLL_Integration_Shared_Test, ClaimViaSig_Integration_Test) { + MerkleLL_Integration_Shared_Test.setUp(); + ClaimViaSig_Integration_Test.setUp(); + } + + function test_WhenSignerSameAsRecipient() + external + override + whenToAddressNotZero + givenRecipientIsEOA + whenSignatureCompatible + { + uint256 expectedStreamId = lockup.nextStreamId(); + uint256 previousFeeAccrued = address(factoryMerkleLL).balance; + uint256 index = getIndexInMerkleTree(); + + eip712Signature = generateSignature(users.recipient, address(merkleLL)); + + vm.expectEmit({ emitter: address(merkleLL) }); + emit ISablierMerkleLockup.Claim(index, users.recipient, CLAIM_AMOUNT, expectedStreamId, users.eve); + + expectCallToTransferFrom({ from: address(merkleLL), to: address(lockup), value: CLAIM_AMOUNT }); + expectCallToClaimViaSigWithMsgValue(address(merkleLL), MIN_FEE_WEI); + + // Claim the airstream. + claimViaSig(); + + assertTrue(merkleLL.hasClaimed(index), "not claimed"); + + assertEq(address(factoryMerkleLL).balance, previousFeeAccrued + MIN_FEE_WEI, "fee collected"); + } + + function test_WhenRecipientImplementsIERC1271Interface() + external + override + whenToAddressNotZero + givenRecipientIsContract + { + uint256 expectedStreamId = lockup.nextStreamId(); + uint256 previousFeeAccrued = address(factoryMerkleLL).balance; + uint256 index = getIndexInMerkleTree(users.smartWalletWithIERC1271); + + eip712Signature = generateSignature(users.smartWalletWithIERC1271, address(merkleLL)); + + vm.expectEmit({ emitter: address(merkleLL) }); + emit ISablierMerkleLockup.Claim(index, users.smartWalletWithIERC1271, CLAIM_AMOUNT, expectedStreamId, users.eve); + + expectCallToTransferFrom({ from: address(merkleLL), to: address(lockup), value: CLAIM_AMOUNT }); + + // Claim the airstream. + claimViaSig({ + msgValue: MIN_FEE_WEI, + index: index, + recipient: users.smartWalletWithIERC1271, + to: users.eve, + amount: CLAIM_AMOUNT, + merkleProof: getMerkleProof(users.smartWalletWithIERC1271), + signature: eip712Signature + }); + + assertTrue(merkleLL.hasClaimed(index), "not claimed"); + + assertEq(address(factoryMerkleLL).balance, previousFeeAccrued + MIN_FEE_WEI, "fee collected"); + } +} diff --git a/tests/integration/concrete/campaign/ll/claim/claim.t.sol b/tests/integration/concrete/campaign/ll/claim/claim.t.sol index d63deb5b..1e612072 100644 --- a/tests/integration/concrete/campaign/ll/claim/claim.t.sol +++ b/tests/integration/concrete/campaign/ll/claim/claim.t.sol @@ -20,19 +20,19 @@ contract Claim_MerkleLL_Integration_Test is Claim_Integration_Test, MerkleLL_Int // Forward in time to the end of the vesting period. vm.warp({ newTimestamp: VESTING_END_TIME }); - uint256 expectedRecipientBalance = dai.balanceOf(users.recipient1) + CLAIM_AMOUNT; + uint256 expectedRecipientBalance = dai.balanceOf(users.recipient) + CLAIM_AMOUNT; // It should emit a {Claim} event. vm.expectEmit({ emitter: address(merkleLL) }); - emit ISablierMerkleLockup.Claim(INDEX1, users.recipient1, CLAIM_AMOUNT, users.recipient1); + emit ISablierMerkleLockup.Claim(getIndexInMerkleTree(), users.recipient, CLAIM_AMOUNT, users.recipient); - expectCallToTransfer({ to: users.recipient1, value: CLAIM_AMOUNT }); + expectCallToTransfer({ to: users.recipient, value: CLAIM_AMOUNT }); expectCallToClaimWithMsgValue(address(merkleLL), MIN_FEE_WEI); claim(); // It should transfer the tokens to the recipient. - assertEq(dai.balanceOf(users.recipient1), expectedRecipientBalance, "recipient balance"); + assertEq(dai.balanceOf(users.recipient), expectedRecipientBalance, "recipient balance"); } function test_RevertWhen_TotalPercentageGreaterThan100() @@ -47,6 +47,10 @@ contract Claim_MerkleLL_Integration_Test is Claim_Integration_Test, MerkleLL_Int params.cliffUnlockPercentage = ud(0.6e18); merkleLL = factoryMerkleLL.createMerkleLL(params, AGGREGATE_AMOUNT, RECIPIENT_COUNT); + + // Cast the {MerkleLL} contract as {ISablierMerkleBase}. + merkleBase = merkleLL; + uint128 startUnlockAmount = ud(CLAIM_AMOUNT).mul(ud(0.5e18)).intoUint128(); uint128 cliffUnlockAmount = ud(CLAIM_AMOUNT).mul(ud(0.6e18)).intoUint128(); @@ -110,6 +114,9 @@ contract Claim_MerkleLL_Integration_Test is Claim_Integration_Test, MerkleLL_Int /// @dev Helper function to test claim. function _test_Claim(uint40 streamStartTime, uint40 cliffTime) private { + // Cast the {MerkleLL} contract as {ISablierMerkleBase}. + merkleBase = merkleLL; + deal({ token: address(dai), to: address(merkleLL), give: AGGREGATE_AMOUNT }); uint256 expectedStreamId = lockup.nextStreamId(); @@ -117,7 +124,9 @@ contract Claim_MerkleLL_Integration_Test is Claim_Integration_Test, MerkleLL_Int // It should emit a {Claim} event. vm.expectEmit({ emitter: address(merkleLL) }); - emit ISablierMerkleLockup.Claim(INDEX1, users.recipient1, CLAIM_AMOUNT, expectedStreamId, users.recipient1); + emit ISablierMerkleLockup.Claim( + getIndexInMerkleTree(), users.recipient, CLAIM_AMOUNT, expectedStreamId, users.recipient + ); expectCallToTransferFrom({ from: address(merkleLL), to: address(lockup), value: CLAIM_AMOUNT }); expectCallToClaimWithMsgValue(address(merkleLL), MIN_FEE_WEI); @@ -131,7 +140,7 @@ contract Claim_MerkleLL_Integration_Test is Claim_Integration_Test, MerkleLL_Int assertEq(lockup.getCliffTime(expectedStreamId), cliffTime, "vesting cliff time"); assertEq(lockup.getDepositedAmount(expectedStreamId), CLAIM_AMOUNT, "depositedAmount"); assertEq(lockup.getEndTime(expectedStreamId), streamStartTime + VESTING_TOTAL_DURATION, "stream end time"); - assertEq(lockup.getRecipient(expectedStreamId), users.recipient1, "recipient"); + assertEq(lockup.getRecipient(expectedStreamId), users.recipient, "recipient"); assertEq(lockup.getSender(expectedStreamId), users.campaignCreator, "sender"); assertEq(lockup.getStartTime(expectedStreamId), streamStartTime, "stream start time"); assertEq(lockup.getUnderlyingToken(expectedStreamId), dai, "token"); @@ -143,14 +152,14 @@ contract Claim_MerkleLL_Integration_Test is Claim_Integration_Test, MerkleLL_Int assertEq(lockup.isTransferable(expectedStreamId), STREAM_TRANSFERABLE, "is transferable"); assertEq(lockup.wasCanceled(expectedStreamId), false, "was canceled"); - assertTrue(merkleLL.hasClaimed(INDEX1), "not claimed"); + assertTrue(merkleLL.hasClaimed(getIndexInMerkleTree()), "not claimed"); // It should create the stream with the correct Lockup model. assertEq(lockup.getLockupModel(expectedStreamId), Lockup.Model.LOCKUP_LINEAR); uint256[] memory expectedClaimedStreamIds = new uint256[](1); expectedClaimedStreamIds[0] = expectedStreamId; - assertEq(merkleLL.claimedStreams(users.recipient1), expectedClaimedStreamIds, "claimed streams"); + assertEq(merkleLL.claimedStreams(users.recipient), expectedClaimedStreamIds, "claimed streams"); assertEq(address(factoryMerkleLL).balance, previousFeeAccrued + MIN_FEE_WEI, "fee collected"); } diff --git a/tests/integration/concrete/campaign/ll/claim/claim.tree b/tests/integration/concrete/campaign/ll/claim/claim.tree index 2e64ac13..2e36298d 100644 --- a/tests/integration/concrete/campaign/ll/claim/claim.tree +++ b/tests/integration/concrete/campaign/ll/claim/claim.tree @@ -1,5 +1,5 @@ Claim_MerkleLL_Integration_Test -└── when valid Merkle proof +└── when merkle proof valid ├── when vesting end time not exceed claim time │ ├── it should transfer the tokens to the recipient │ └── it should emit a {Claim} event diff --git a/tests/integration/concrete/campaign/ll/constructor.t.sol b/tests/integration/concrete/campaign/ll/constructor.t.sol index 9bcf0265..efeedf09 100644 --- a/tests/integration/concrete/campaign/ll/constructor.t.sol +++ b/tests/integration/concrete/campaign/ll/constructor.t.sol @@ -3,6 +3,7 @@ pragma solidity >=0.8.22 <0.9.0; import { SablierMerkleLL } from "src/SablierMerkleLL.sol"; +import { Utilities } from "../../../../utils/Utilities.sol"; import { Integration_Test } from "./../../../Integration.t.sol"; contract Constructor_MerkleLL_Integration_Test is Integration_Test { @@ -20,6 +21,11 @@ contract Constructor_MerkleLL_Integration_Test is Integration_Test { // SablierMerkleBase assertEq(constructedLL.admin(), users.campaignCreator, "admin"); assertEq(constructedLL.CAMPAIGN_START_TIME(), CAMPAIGN_START_TIME, "campaign start time"); + assertEq( + constructedLL.DOMAIN_SEPARATOR(), + Utilities.computeEIP712DomainSeparator(address(constructedLL)), + "domain separator" + ); assertEq(constructedLL.campaignName(), CAMPAIGN_NAME, "campaign name"); assertEq(constructedLL.EXPIRATION(), EXPIRATION, "expiration"); assertEq(address(constructedLL.FACTORY()), address(factoryMerkleLL), "factory"); diff --git a/tests/integration/concrete/campaign/lt/MerkleLT.t.sol b/tests/integration/concrete/campaign/lt/MerkleLT.t.sol index e85fcd3e..25c45469 100644 --- a/tests/integration/concrete/campaign/lt/MerkleLT.t.sol +++ b/tests/integration/concrete/campaign/lt/MerkleLT.t.sol @@ -17,6 +17,7 @@ abstract contract MerkleLT_Integration_Shared_Test is Integration_Test { // Cast the {FactoryMerkleLT} contract as {ISablierFactoryMerkleBase} factoryMerkleBase = factoryMerkleLT; + // Cast the {MerkleLT} contract as {ISablierMerkleBase} merkleBase = merkleLT; diff --git a/tests/integration/concrete/campaign/lt/claim-to/claimTo.t.sol b/tests/integration/concrete/campaign/lt/claim-to/claimTo.t.sol index a76e8fca..eb9c5e23 100644 --- a/tests/integration/concrete/campaign/lt/claim-to/claimTo.t.sol +++ b/tests/integration/concrete/campaign/lt/claim-to/claimTo.t.sol @@ -25,7 +25,7 @@ contract ClaimTo_MerkleLT_Integration_Test is ClaimTo_Integration_Test, MerkleLT // It should emit a {Claim} event. vm.expectEmit({ emitter: address(merkleLT) }); - emit ISablierMerkleLockup.Claim(INDEX1, users.recipient1, CLAIM_AMOUNT, users.eve); + emit ISablierMerkleLockup.Claim(getIndexInMerkleTree(), users.recipient, CLAIM_AMOUNT, users.eve); expectCallToTransfer({ to: users.eve, value: CLAIM_AMOUNT }); expectCallToClaimToWithMsgValue(address(merkleLT), MIN_FEE_WEI); @@ -48,7 +48,9 @@ contract ClaimTo_MerkleLT_Integration_Test is ClaimTo_Integration_Test, MerkleLT params.tranchesWithPercentages[0].unlockPercentage = ud2x18(0.05e18); params.tranchesWithPercentages[1].unlockPercentage = ud2x18(0.2e18); + // Create the MerkleLT campaign and cast it as {ISablierMerkleBase}. merkleLT = factoryMerkleLT.createMerkleLT(params, AGGREGATE_AMOUNT, RECIPIENT_COUNT); + merkleBase = merkleLT; vm.expectRevert(abi.encodeWithSelector(Errors.SablierMerkleLT_TotalPercentageNotOneHundred.selector, 0.25e18)); @@ -63,11 +65,12 @@ contract ClaimTo_MerkleLT_Integration_Test is ClaimTo_Integration_Test, MerkleLT { MerkleLT.ConstructorParams memory params = merkleLTConstructorParams(); - // Create a MerkleLT campaign with a total percentage less than 100. params.tranchesWithPercentages[0].unlockPercentage = ud2x18(0.75e18); params.tranchesWithPercentages[1].unlockPercentage = ud2x18(0.8e18); + // Create the MerkleLT campaign and cast it as {ISablierMerkleBase}. merkleLT = factoryMerkleLT.createMerkleLT(params, AGGREGATE_AMOUNT, RECIPIENT_COUNT); + merkleBase = merkleLT; vm.expectRevert(abi.encodeWithSelector(Errors.SablierMerkleLT_TotalPercentageNotOneHundred.selector, 1.55e18)); @@ -83,7 +86,9 @@ contract ClaimTo_MerkleLT_Integration_Test is ClaimTo_Integration_Test, MerkleLT MerkleLT.ConstructorParams memory params = merkleLTConstructorParams(); params.vestingStartTime = 0; + // Create the MerkleLT campaign and cast it as {ISablierMerkleBase}. merkleLT = factoryMerkleLT.createMerkleLT(params, AGGREGATE_AMOUNT, RECIPIENT_COUNT); + merkleBase = merkleLT; // It should create a stream with `block.timestamp` as stream start time. // It should create a stream with Eve as recipient. @@ -110,7 +115,9 @@ contract ClaimTo_MerkleLT_Integration_Test is ClaimTo_Integration_Test, MerkleLT // It should emit a {Claim} event. vm.expectEmit({ emitter: address(merkleLT) }); - emit ISablierMerkleLockup.Claim(INDEX1, users.recipient1, CLAIM_AMOUNT, expectedStreamId, users.eve); + emit ISablierMerkleLockup.Claim( + getIndexInMerkleTree(), users.recipient, CLAIM_AMOUNT, expectedStreamId, users.eve + ); expectCallToTransferFrom({ from: address(merkleLT), to: address(lockup), value: CLAIM_AMOUNT }); expectCallToClaimToWithMsgValue(address(merkleLT), MIN_FEE_WEI); @@ -136,14 +143,14 @@ contract ClaimTo_MerkleLT_Integration_Test is ClaimTo_Integration_Test, MerkleLT assertEq(lockup.isTransferable(expectedStreamId), STREAM_TRANSFERABLE, "is transferable"); assertEq(lockup.wasCanceled(expectedStreamId), false, "was canceled"); - assertTrue(merkleLT.hasClaimed(INDEX1), "not claimed"); + assertTrue(merkleLT.hasClaimed(getIndexInMerkleTree()), "not claimed"); // It should create the stream with the correct Lockup model. assertEq(lockup.getLockupModel(expectedStreamId), Lockup.Model.LOCKUP_TRANCHED); uint256[] memory expectedClaimedStreamIds = new uint256[](1); expectedClaimedStreamIds[0] = expectedStreamId; - assertEq(merkleLT.claimedStreams(users.recipient1), expectedClaimedStreamIds, "claimed streams"); + assertEq(merkleLT.claimedStreams(users.recipient), expectedClaimedStreamIds, "claimed streams"); assertEq(address(factoryMerkleLT).balance, previousFeeAccrued + MIN_FEE_WEI, "fee collected"); } diff --git a/tests/integration/concrete/campaign/lt/claim-to/claimTo.tree b/tests/integration/concrete/campaign/lt/claim-to/claimTo.tree index 171f86a5..307b0316 100644 --- a/tests/integration/concrete/campaign/lt/claim-to/claimTo.tree +++ b/tests/integration/concrete/campaign/lt/claim-to/claimTo.tree @@ -1,5 +1,5 @@ ClaimTo_MerkleLT_Integration_Test -└── when valid Merkle proof +└── when merkle proof valid ├── when vesting end time not exceed claim time │ ├── it should transfer the tokens to Eve │ └── it should emit a {Claim} event diff --git a/tests/integration/concrete/campaign/lt/claim-via-sign/claimViaSig.t.sol b/tests/integration/concrete/campaign/lt/claim-via-sign/claimViaSig.t.sol new file mode 100644 index 00000000..b5cf4bce --- /dev/null +++ b/tests/integration/concrete/campaign/lt/claim-via-sign/claimViaSig.t.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22 <0.9.0; + +import { ISablierMerkleLockup } from "src/interfaces/ISablierMerkleLockup.sol"; + +import { ClaimViaSig_Integration_Test } from "./../../shared/claim-via-sig/claimViaSig.t.sol"; +import { MerkleLT_Integration_Shared_Test } from "./../MerkleLT.t.sol"; + +contract ClaimViaSig_MerkleLT_Integration_Test is ClaimViaSig_Integration_Test, MerkleLT_Integration_Shared_Test { + function setUp() public virtual override(MerkleLT_Integration_Shared_Test, ClaimViaSig_Integration_Test) { + MerkleLT_Integration_Shared_Test.setUp(); + ClaimViaSig_Integration_Test.setUp(); + } + + function test_WhenSignerSameAsRecipient() + external + override + whenToAddressNotZero + givenRecipientIsEOA + whenSignatureCompatible + { + uint256 expectedStreamId = lockup.nextStreamId(); + uint256 previousFeeAccrued = address(factoryMerkleLT).balance; + uint256 index = getIndexInMerkleTree(); + + eip712Signature = generateSignature(users.recipient, address(merkleLT)); + + vm.expectEmit({ emitter: address(merkleLT) }); + emit ISablierMerkleLockup.Claim(index, users.recipient, CLAIM_AMOUNT, expectedStreamId, users.eve); + + expectCallToTransferFrom({ from: address(merkleLT), to: address(lockup), value: CLAIM_AMOUNT }); + expectCallToClaimViaSigWithMsgValue(address(merkleLT), MIN_FEE_WEI); + + // Claim the airstream. + claimViaSig(); + + assertTrue(merkleLT.hasClaimed(index), "not claimed"); + + assertEq(address(factoryMerkleLT).balance, previousFeeAccrued + MIN_FEE_WEI, "fee collected"); + } + + function test_WhenRecipientImplementsIERC1271Interface() + external + override + whenToAddressNotZero + givenRecipientIsContract + { + uint256 expectedStreamId = lockup.nextStreamId(); + uint256 previousFeeAccrued = address(factoryMerkleLT).balance; + uint256 index = getIndexInMerkleTree(users.smartWalletWithIERC1271); + + eip712Signature = generateSignature(users.smartWalletWithIERC1271, address(merkleLT)); + + vm.expectEmit({ emitter: address(merkleLT) }); + emit ISablierMerkleLockup.Claim(index, users.smartWalletWithIERC1271, CLAIM_AMOUNT, expectedStreamId, users.eve); + + expectCallToTransferFrom({ from: address(merkleLT), to: address(lockup), value: CLAIM_AMOUNT }); + + // Claim the airstream. + claimViaSig({ + msgValue: MIN_FEE_WEI, + index: index, + recipient: users.smartWalletWithIERC1271, + to: users.eve, + amount: CLAIM_AMOUNT, + merkleProof: getMerkleProof(users.smartWalletWithIERC1271), + signature: eip712Signature + }); + + assertTrue(merkleLT.hasClaimed(index), "not claimed"); + + assertEq(address(factoryMerkleLT).balance, previousFeeAccrued + MIN_FEE_WEI, "fee collected"); + } +} diff --git a/tests/integration/concrete/campaign/lt/claim/claim.t.sol b/tests/integration/concrete/campaign/lt/claim/claim.t.sol index 12f3b56c..62b0682e 100644 --- a/tests/integration/concrete/campaign/lt/claim/claim.t.sol +++ b/tests/integration/concrete/campaign/lt/claim/claim.t.sol @@ -20,19 +20,19 @@ contract Claim_MerkleLT_Integration_Test is Claim_Integration_Test, MerkleLT_Int // Forward in time to the end of the vesting period. vm.warp({ newTimestamp: VESTING_END_TIME }); - uint256 expectedRecipientBalance = dai.balanceOf(users.recipient1) + CLAIM_AMOUNT; + uint256 expectedRecipientBalance = dai.balanceOf(users.recipient) + CLAIM_AMOUNT; // It should emit a {Claim} event. vm.expectEmit({ emitter: address(merkleLT) }); - emit ISablierMerkleLockup.Claim(INDEX1, users.recipient1, CLAIM_AMOUNT, users.recipient1); + emit ISablierMerkleLockup.Claim(getIndexInMerkleTree(), users.recipient, CLAIM_AMOUNT, users.recipient); - expectCallToTransfer({ to: users.recipient1, value: CLAIM_AMOUNT }); + expectCallToTransfer({ to: users.recipient, value: CLAIM_AMOUNT }); expectCallToClaimWithMsgValue(address(merkleLT), MIN_FEE_WEI); claim(); // It should transfer the tokens to the recipient. - assertEq(dai.balanceOf(users.recipient1), expectedRecipientBalance, "recipient balance"); + assertEq(dai.balanceOf(users.recipient), expectedRecipientBalance, "recipient balance"); } function test_RevertWhen_TotalPercentageLessThan100() @@ -47,7 +47,9 @@ contract Claim_MerkleLT_Integration_Test is Claim_Integration_Test, MerkleLT_Int params.tranchesWithPercentages[0].unlockPercentage = ud2x18(0.05e18); params.tranchesWithPercentages[1].unlockPercentage = ud2x18(0.2e18); + // Create the MerkleLT campaign and cast it as {ISablierMerkleBase}. merkleLT = factoryMerkleLT.createMerkleLT(params, AGGREGATE_AMOUNT, RECIPIENT_COUNT); + merkleBase = merkleLT; vm.expectRevert(abi.encodeWithSelector(Errors.SablierMerkleLT_TotalPercentageNotOneHundred.selector, 0.25e18)); @@ -66,7 +68,9 @@ contract Claim_MerkleLT_Integration_Test is Claim_Integration_Test, MerkleLT_Int params.tranchesWithPercentages[0].unlockPercentage = ud2x18(0.75e18); params.tranchesWithPercentages[1].unlockPercentage = ud2x18(0.8e18); + // Create the MerkleLT campaign and cast it as {ISablierMerkleBase}. merkleLT = factoryMerkleLT.createMerkleLT(params, AGGREGATE_AMOUNT, RECIPIENT_COUNT); + merkleBase = merkleLT; vm.expectRevert(abi.encodeWithSelector(Errors.SablierMerkleLT_TotalPercentageNotOneHundred.selector, 1.55e18)); @@ -82,7 +86,9 @@ contract Claim_MerkleLT_Integration_Test is Claim_Integration_Test, MerkleLT_Int MerkleLT.ConstructorParams memory params = merkleLTConstructorParams(); params.vestingStartTime = 0; + // Create the MerkleLT campaign and cast it as {ISablierMerkleBase}. merkleLT = factoryMerkleLT.createMerkleLT(params, AGGREGATE_AMOUNT, RECIPIENT_COUNT); + merkleBase = merkleLT; // It should create a stream with `block.timestamp` as vesting start time. _test_Claim({ streamStartTime: getBlockTimestamp() }); @@ -107,7 +113,9 @@ contract Claim_MerkleLT_Integration_Test is Claim_Integration_Test, MerkleLT_Int // It should emit a {Claim} event. vm.expectEmit({ emitter: address(merkleLT) }); - emit ISablierMerkleLockup.Claim(INDEX1, users.recipient1, CLAIM_AMOUNT, expectedStreamId, users.recipient1); + emit ISablierMerkleLockup.Claim( + getIndexInMerkleTree(), users.recipient, CLAIM_AMOUNT, expectedStreamId, users.recipient + ); expectCallToTransferFrom({ from: address(merkleLT), to: address(lockup), value: CLAIM_AMOUNT }); expectCallToClaimWithMsgValue(address(merkleLT), MIN_FEE_WEI); @@ -118,7 +126,7 @@ contract Claim_MerkleLT_Integration_Test is Claim_Integration_Test, MerkleLT_Int // Assert that the stream has been created successfully. assertEq(lockup.getDepositedAmount(expectedStreamId), CLAIM_AMOUNT, "depositedAmount"); assertEq(lockup.getEndTime(expectedStreamId), streamStartTime + VESTING_TOTAL_DURATION, "stream end time"); - assertEq(lockup.getRecipient(expectedStreamId), users.recipient1, "recipient"); + assertEq(lockup.getRecipient(expectedStreamId), users.recipient, "recipient"); assertEq(lockup.getSender(expectedStreamId), users.campaignCreator, "sender"); assertEq(lockup.getStartTime(expectedStreamId), streamStartTime, "stream start time"); // It should create a stream with `VESTING_START_TIME` as vesting start time. @@ -133,14 +141,14 @@ contract Claim_MerkleLT_Integration_Test is Claim_Integration_Test, MerkleLT_Int assertEq(lockup.isTransferable(expectedStreamId), STREAM_TRANSFERABLE, "is transferable"); assertEq(lockup.wasCanceled(expectedStreamId), false, "was canceled"); - assertTrue(merkleLT.hasClaimed(INDEX1), "not claimed"); + assertTrue(merkleLT.hasClaimed(getIndexInMerkleTree()), "not claimed"); // It should create the stream with the correct Lockup model. assertEq(lockup.getLockupModel(expectedStreamId), Lockup.Model.LOCKUP_TRANCHED); uint256[] memory expectedClaimedStreamIds = new uint256[](1); expectedClaimedStreamIds[0] = expectedStreamId; - assertEq(merkleLT.claimedStreams(users.recipient1), expectedClaimedStreamIds, "claimed streams"); + assertEq(merkleLT.claimedStreams(users.recipient), expectedClaimedStreamIds, "claimed streams"); assertEq(address(factoryMerkleLT).balance, previousFeeAccrued + MIN_FEE_WEI, "fee collected"); } diff --git a/tests/integration/concrete/campaign/lt/claim/claim.tree b/tests/integration/concrete/campaign/lt/claim/claim.tree index ce7f13a5..0ac5efbb 100644 --- a/tests/integration/concrete/campaign/lt/claim/claim.tree +++ b/tests/integration/concrete/campaign/lt/claim/claim.tree @@ -1,5 +1,5 @@ Claim_MerkleLT_Integration_Test -└── when valid Merkle proof +└── when merkle proof valid ├── when vesting end time not exceed claim time │ ├── it should transfer the tokens to the recipient │ └── it should emit a {Claim} event diff --git a/tests/integration/concrete/campaign/lt/constructor.t.sol b/tests/integration/concrete/campaign/lt/constructor.t.sol index 445806c5..aaa318c4 100644 --- a/tests/integration/concrete/campaign/lt/constructor.t.sol +++ b/tests/integration/concrete/campaign/lt/constructor.t.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.22 <0.9.0; import { SablierMerkleLT } from "src/SablierMerkleLT.sol"; import { MerkleLT } from "src/types/DataTypes.sol"; +import { Utilities } from "../../../../utils/Utilities.sol"; import { Integration_Test } from "./../../../Integration.t.sol"; contract Constructor_MerkleLT_Integration_Test is Integration_Test { @@ -24,6 +25,11 @@ contract Constructor_MerkleLT_Integration_Test is Integration_Test { assertEq(constructedLT.admin(), users.campaignCreator, "admin"); assertEq(constructedLT.campaignName(), CAMPAIGN_NAME, "campaign name"); assertEq(constructedLT.CAMPAIGN_START_TIME(), CAMPAIGN_START_TIME, "campaign start time"); + assertEq( + constructedLT.DOMAIN_SEPARATOR(), + Utilities.computeEIP712DomainSeparator(address(constructedLT)), + "domain separator" + ); assertEq(constructedLT.EXPIRATION(), EXPIRATION, "expiration"); assertEq(address(constructedLT.FACTORY()), address(factoryMerkleLT), "factory"); assertEq(constructedLT.ipfsCID(), IPFS_CID, "IPFS CID"); diff --git a/tests/integration/concrete/campaign/shared/claim-to/claimTo.t.sol b/tests/integration/concrete/campaign/shared/claim-to/claimTo.t.sol index 6789984a..e6a994dd 100644 --- a/tests/integration/concrete/campaign/shared/claim-to/claimTo.t.sol +++ b/tests/integration/concrete/campaign/shared/claim-to/claimTo.t.sol @@ -1,42 +1,31 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.22 <0.9.0; -import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { Errors } from "src/libraries/Errors.sol"; import { Integration_Test } from "../../../../Integration.t.sol"; abstract contract ClaimTo_Integration_Test is Integration_Test { function setUp() public virtual override { - Integration_Test.setUp(); - - // Make `users.recipient1` the caller for this test. - setMsgSender(users.recipient1); + // Make `users.recipient` the caller for this test. + setMsgSender(users.recipient); } function test_RevertWhen_ToAddressZero() external { - if (Strings.equal(campaignType, "instant")) { - vm.expectRevert(Errors.SablierMerkleInstant_ToZeroAddress.selector); - } else if (Strings.equal(campaignType, "ll")) { - vm.expectRevert(Errors.SablierMerkleLL_ToZeroAddress.selector); - } else if (Strings.equal(campaignType, "lt")) { - vm.expectRevert(Errors.SablierMerkleLT_ToZeroAddress.selector); - } else if (Strings.equal(campaignType, "vca")) { - vm.expectRevert(Errors.SablierMerkleVCA_ToZeroAddress.selector); - } + vm.expectRevert(Errors.SablierMerkleBase_ToZeroAddress.selector); claimTo({ msgValue: MIN_FEE_WEI, - index: INDEX1, + index: getIndexInMerkleTree(), to: address(0), amount: CLAIM_AMOUNT, - merkleProof: index1Proof() + merkleProof: getMerkleProof() }); } function test_RevertGiven_CallerClaimed() external whenToAddressNotZero { claimTo(); - vm.expectRevert(abi.encodeWithSelector(Errors.SablierMerkleBase_IndexClaimed.selector, INDEX1)); + vm.expectRevert(abi.encodeWithSelector(Errors.SablierMerkleBase_IndexClaimed.selector, getIndexInMerkleTree())); claimTo(); } @@ -47,9 +36,15 @@ abstract contract ClaimTo_Integration_Test is Integration_Test { claimTo(); } - /// @dev Since the implementation of `claimTo()` differs in each Merkle campaign, we declare this dummy test. The - /// child contracts implement the rest of the tests. - function test_WhenMerkleProofValid() external whenToAddressNotZero givenCallerNotClaimed whenCallerEligible { + /// @dev Since the implementation of `claimTo()` differs in each Merkle campaign, we declare this virtual dummy + /// test. The child contracts implement it. + function test_WhenMerkleProofValid() + external + virtual + whenToAddressNotZero + givenCallerNotClaimed + whenCallerEligible + { // The child contract must check that the claim event is emitted. // It should mark the index as claimed. // It should transfer the fee from the caller address to the merkle lockup. diff --git a/tests/integration/concrete/campaign/shared/claim-to/claimTo.tree b/tests/integration/concrete/campaign/shared/claim-to/claimTo.tree index 5bb9aa04..cfe4a15e 100644 --- a/tests/integration/concrete/campaign/shared/claim-to/claimTo.tree +++ b/tests/integration/concrete/campaign/shared/claim-to/claimTo.tree @@ -8,7 +8,7 @@ ClaimTo_Integration_Test ├── when caller not eligible │ └── it should revert └── when caller eligible - └── when Merkle proof valid + └── when merkle proof valid ├── it should mark the index as Claimed ├── it should transfer the ETH to the merkle lockup - └── it should emit {Claim} event \ No newline at end of file + └── it should emit {Claim} event diff --git a/tests/integration/concrete/campaign/shared/claim-via-sig/claimViaSig.t.sol b/tests/integration/concrete/campaign/shared/claim-via-sig/claimViaSig.t.sol new file mode 100644 index 00000000..1a17532e --- /dev/null +++ b/tests/integration/concrete/campaign/shared/claim-via-sig/claimViaSig.t.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22 <0.9.0; + +import { Errors } from "src/libraries/Errors.sol"; + +import { Utilities } from "../../../../../utils/Utilities.sol"; +import { Integration_Test } from "../../../../Integration.t.sol"; + +abstract contract ClaimViaSig_Integration_Test is Integration_Test { + function setUp() public virtual override { + // Make `users.campaignCreator` the caller for this test. + setMsgSender(users.campaignCreator); + } + + function test_RevertWhen_ToAddressZero() external { + vm.expectRevert(Errors.SablierMerkleBase_ToZeroAddress.selector); + claimViaSig({ + msgValue: MIN_FEE_WEI, + index: getIndexInMerkleTree(), + recipient: users.recipient, + to: address(0), + amount: CLAIM_AMOUNT, + merkleProof: getMerkleProof(), + signature: abi.encode(0) + }); + } + + function test_RevertWhen_SignatureNotCompatible() external whenToAddressNotZero givenRecipientIsEOA { + uint256 index = getIndexInMerkleTree(); + + // Generate an incompatible signature. + bytes memory incompatibleSignature = + Utilities.generateEIP191Signature(recipientPrivateKey, index, users.eve, users.recipient, CLAIM_AMOUNT); + + // Expect revert. + vm.expectRevert(Errors.SablierMerkleBase_InvalidSignature.selector); + claimViaSig({ + msgValue: MIN_FEE_WEI, + index: index, + recipient: users.recipient, + to: users.eve, + amount: CLAIM_AMOUNT, + merkleProof: getMerkleProof(), + signature: incompatibleSignature + }); + } + + function test_RevertWhen_SignerDifferentFromRecipient() + external + whenToAddressNotZero + givenRecipientIsEOA + whenSignatureCompatible + { + uint256 index = getIndexInMerkleTree(); + + // Create a new user. + (address newSigner, uint256 newSignerPrivateKey) = makeAddrAndKey("new signer"); + + setMsgSender(newSigner); + + // Generate the signature using the new user's private key. + bytes memory signatureFromNewSigner = Utilities.generateEIP712Signature( + newSignerPrivateKey, address(merkleBase), index, users.recipient, users.eve, CLAIM_AMOUNT + ); + + // Expect revert. + vm.expectRevert(Errors.SablierMerkleBase_InvalidSignature.selector); + claimViaSig({ + msgValue: MIN_FEE_WEI, + index: index, + recipient: users.recipient, + to: users.eve, + amount: CLAIM_AMOUNT, + merkleProof: getMerkleProof(), + signature: signatureFromNewSigner + }); + } + + /// @dev Since the implementation of `claimViaSig()` differs in each Merkle campaign, we declare this virtual dummy + /// test. The child contracts implement it. + function test_WhenSignerSameAsRecipient() + external + virtual + whenToAddressNotZero + givenRecipientIsEOA + whenSignatureCompatible + { + // The child contract must check that the claim event is emitted. + // It should mark the index as claimed. + // It should transfer the fee from the caller address to the merkle lockup. + } + + function test_RevertWhen_RecipientNotImplementIERC1271Interface() + external + whenToAddressNotZero + givenRecipientIsContract + { + vm.expectRevert(Errors.SablierMerkleBase_InvalidSignature.selector); + claimViaSig({ + msgValue: MIN_FEE_WEI, + index: getIndexInMerkleTree(users.smartWalletWithoutIERC1271), + recipient: users.smartWalletWithoutIERC1271, + to: users.eve, + amount: CLAIM_AMOUNT, + merkleProof: getMerkleProof(users.smartWalletWithoutIERC1271), + signature: abi.encode(0) + }); + } + + /// @dev Since the implementation of `claimViaSig()` differs in each Merkle campaign, we declare this virtual dummy + /// test. The child contracts implement it. + function test_WhenRecipientImplementsIERC1271Interface() + external + virtual + whenToAddressNotZero + givenRecipientIsContract + { + // The child contract must check that the claim event is emitted. + // It should mark the index as claimed. + // It should transfer the fee from the caller address to the merkle lockup. + } +} diff --git a/tests/integration/concrete/campaign/shared/claim-via-sig/claimViaSig.tree b/tests/integration/concrete/campaign/shared/claim-via-sig/claimViaSig.tree new file mode 100644 index 00000000..8f22f5e2 --- /dev/null +++ b/tests/integration/concrete/campaign/shared/claim-via-sig/claimViaSig.tree @@ -0,0 +1,21 @@ +ClaimViaSig_Integration_Test +├── when to address zero +│ └── it should revert +└── when to address not zero + ├── given recipient is EOA + │ ├── when signature not compatible + │ │ └── it should revert + │ └── when signature compatible + │ ├── when signer different from recipient + │ │ └── it should revert + │ └── when signer same as recipient + │ ├── it should mark the index as Claimed + │ ├── it should transfer the ETH to the merkle lockup + │ └── it should emit {Claim} event + └── given recipient is contract + ├── when recipient not implement IERC1271 interface + │ └── it should revert + └── when recipient implements IERC1271 interface + ├── it should mark the index as Claimed + ├── it should transfer the ETH to the merkle lockup + └── it should emit {Claim} event \ No newline at end of file diff --git a/tests/integration/concrete/campaign/shared/claim/claim.t.sol b/tests/integration/concrete/campaign/shared/claim/claim.t.sol index c486a97e..2e2ec523 100644 --- a/tests/integration/concrete/campaign/shared/claim/claim.t.sol +++ b/tests/integration/concrete/campaign/shared/claim/claim.t.sol @@ -34,10 +34,10 @@ abstract contract Claim_Integration_Test is Integration_Test { ); claim({ msgValue: 0, - index: INDEX1, - recipient: users.recipient1, + index: getIndexInMerkleTree(), + recipient: users.recipient, amount: CLAIM_AMOUNT, - merkleProof: index1Proof() + merkleProof: getMerkleProof() }); } @@ -49,7 +49,7 @@ abstract contract Claim_Integration_Test is Integration_Test { { claim(); - vm.expectRevert(abi.encodeWithSelector(Errors.SablierMerkleBase_IndexClaimed.selector, INDEX1)); + vm.expectRevert(abi.encodeWithSelector(Errors.SablierMerkleBase_IndexClaimed.selector, getIndexInMerkleTree())); claim(); } @@ -66,9 +66,9 @@ abstract contract Claim_Integration_Test is Integration_Test { claim({ msgValue: MIN_FEE_WEI, index: invalidIndex, - recipient: users.recipient1, + recipient: users.recipient, amount: CLAIM_AMOUNT, - merkleProof: index1Proof() + merkleProof: getMerkleProof() }); } @@ -85,10 +85,10 @@ abstract contract Claim_Integration_Test is Integration_Test { vm.expectRevert(Errors.SablierMerkleBase_InvalidProof.selector); claim({ msgValue: MIN_FEE_WEI, - index: INDEX1, + index: getIndexInMerkleTree(), recipient: invalidRecipient, amount: CLAIM_AMOUNT, - merkleProof: index1Proof() + merkleProof: getMerkleProof() }); } @@ -106,10 +106,10 @@ abstract contract Claim_Integration_Test is Integration_Test { vm.expectRevert(Errors.SablierMerkleBase_InvalidProof.selector); claim({ msgValue: MIN_FEE_WEI, - index: INDEX1, - recipient: users.recipient1, + index: getIndexInMerkleTree(), + recipient: users.recipient, amount: invalidAmount, - merkleProof: index1Proof() + merkleProof: getMerkleProof() }); } @@ -126,17 +126,18 @@ abstract contract Claim_Integration_Test is Integration_Test { vm.expectRevert(Errors.SablierMerkleBase_InvalidProof.selector); claim({ msgValue: MIN_FEE_WEI, - index: INDEX1, - recipient: users.recipient1, + index: getIndexInMerkleTree(), + recipient: users.recipient, amount: CLAIM_AMOUNT, - merkleProof: index2Proof() + merkleProof: getMerkleProof(users.unknownRecipient) }); } - /// @dev Since the implementation of `claim()` differs in each Merkle campaign, we declare this dummy test. The - /// child contracts implement the rest of the tests. + /// @dev Since the implementation of `claim()` differs in each Merkle campaign, we declare this virtual dummy test. + /// The child contracts implement it. function test_WhenMerkleProofValid() external + virtual givenCampaignStartTimeNotInFuture givenCampaignNotExpired givenMsgValueNotLessThanFee diff --git a/tests/integration/concrete/campaign/shared/claim/claim.tree b/tests/integration/concrete/campaign/shared/claim/claim.tree index 3fc66a27..0dbd9e57 100644 --- a/tests/integration/concrete/campaign/shared/claim/claim.tree +++ b/tests/integration/concrete/campaign/shared/claim/claim.tree @@ -20,9 +20,9 @@ Claim_Integration_Test ├── when amount not valid │ └── it should revert └── when amount valid - ├── when Merkle proof not valid + ├── when merkle proof not valid │ └── it should revert - └── when Merkle proof valid + └── when merkle proof valid ├── it should mark the index as Claimed ├── it should transfer the ETH to the merkle lockup └── it should emit {Claim} event diff --git a/tests/integration/concrete/campaign/shared/has-claimed/hasClaimed.t.sol b/tests/integration/concrete/campaign/shared/has-claimed/hasClaimed.t.sol index 9d360521..8a4490a8 100644 --- a/tests/integration/concrete/campaign/shared/has-claimed/hasClaimed.t.sol +++ b/tests/integration/concrete/campaign/shared/has-claimed/hasClaimed.t.sol @@ -4,14 +4,14 @@ pragma solidity >=0.8.22 <0.9.0; import { Integration_Test } from "../../../../Integration.t.sol"; abstract contract HasClaimed_Integration_Test is Integration_Test { - function test_WhenIndexNotInMerkleTree() external { + function test_WhenIndexNotInMerkleTree() external view { uint256 indexNotInTree = 1337e18; assertFalse(merkleBase.hasClaimed(indexNotInTree), "claimed"); } - function test_GivenRecipientNotClaimed() external whenIndexInMerkleTree { + function test_GivenRecipientNotClaimed() external view whenIndexInMerkleTree { // It should return false. - assertFalse(merkleBase.hasClaimed(INDEX1), "claimed"); + assertFalse(merkleBase.hasClaimed(getIndexInMerkleTree()), "claimed"); } function test_GivenRecipientClaimed() external whenIndexInMerkleTree { @@ -19,6 +19,6 @@ abstract contract HasClaimed_Integration_Test is Integration_Test { claim(); // It should return true. - assertTrue(merkleBase.hasClaimed(INDEX1), "not claimed"); + assertTrue(merkleBase.hasClaimed(getIndexInMerkleTree()), "not claimed"); } } diff --git a/tests/integration/concrete/campaign/vca/MerkleVCA.t.sol b/tests/integration/concrete/campaign/vca/MerkleVCA.t.sol index 6a177ff2..5b6f1c34 100644 --- a/tests/integration/concrete/campaign/vca/MerkleVCA.t.sol +++ b/tests/integration/concrete/campaign/vca/MerkleVCA.t.sol @@ -16,9 +16,10 @@ abstract contract MerkleVCA_Integration_Shared_Test is Integration_Test { function setUp() public virtual override { Integration_Test.setUp(); - // Cast the {FactoryMerkleVCA} contract as {ISablierFactoryMerkleBase} + // Cast the {FactoryMerkleVCA} contract as {ISablierFactoryMerkleBase}. factoryMerkleBase = factoryMerkleVCA; - // Cast the {MerkleVCA} contract as {ISablierMerkleBase} + + // Cast the {MerkleVCA} contract as {ISablierMerkleBase}. merkleBase = merkleVCA; // Set the campaign type. diff --git a/tests/integration/concrete/campaign/vca/claim-to/claimTo.t.sol b/tests/integration/concrete/campaign/vca/claim-to/claimTo.t.sol index 834e4470..60358555 100644 --- a/tests/integration/concrete/campaign/vca/claim-to/claimTo.t.sol +++ b/tests/integration/concrete/campaign/vca/claim-to/claimTo.t.sol @@ -21,14 +21,14 @@ contract ClaimTo_MerkleVCA_Integration_Test is ClaimTo_Integration_Test, MerkleV merkleVCA = createMerkleVCA(params); // It should revert. - vm.expectRevert(abi.encodeWithSelector(Errors.SablierMerkleVCA_ClaimAmountZero.selector, users.recipient1)); + vm.expectRevert(abi.encodeWithSelector(Errors.SablierMerkleVCA_ClaimAmountZero.selector, users.recipient)); // Claim the airdrop. merkleVCA.claimTo{ value: MIN_FEE_WEI }({ - index: INDEX1, + index: getIndexInMerkleTree(), to: users.eve, fullAmount: CLAIM_AMOUNT, - merkleProof: index1Proof() + merkleProof: getMerkleProof() }); } @@ -53,14 +53,19 @@ contract ClaimTo_MerkleVCA_Integration_Test is ClaimTo_Integration_Test, MerkleV } function _test_ClaimTo(uint128 claimAmount) private { + // Cast the {MerkleVCA} contract as {ISablierMerkleBase}. + merkleBase = merkleVCA; + + uint256 index = getIndexInMerkleTree(); + uint128 forgoneAmount = VCA_FULL_AMOUNT - claimAmount; uint256 previousFeeAccrued = address(factoryMerkleVCA).balance; // It should emit a {Claim} event. vm.expectEmit({ emitter: address(merkleVCA) }); emit ISablierMerkleVCA.Claim({ - index: INDEX1, - recipient: users.recipient1, + index: index, + recipient: users.recipient, claimAmount: claimAmount, forgoneAmount: forgoneAmount, to: users.eve @@ -73,7 +78,7 @@ contract ClaimTo_MerkleVCA_Integration_Test is ClaimTo_Integration_Test, MerkleV claimTo(); // It should update the claimed status. - assertTrue(merkleVCA.hasClaimed(INDEX1), "not claimed"); + assertTrue(merkleVCA.hasClaimed(index), "not claimed"); // It should update the total forgone amount. assertEq(merkleVCA.totalForgoneAmount(), forgoneAmount, "total forgone amount"); diff --git a/tests/integration/concrete/campaign/vca/claim-to/claimTo.tree b/tests/integration/concrete/campaign/vca/claim-to/claimTo.tree index 00691cc3..a08640b1 100644 --- a/tests/integration/concrete/campaign/vca/claim-to/claimTo.tree +++ b/tests/integration/concrete/campaign/vca/claim-to/claimTo.tree @@ -1,5 +1,5 @@ ClaimTo_MerkleVCA_Integration_Test -└── when valid Merkle proof +└── when merkle proof valid ├── when vesting start time in future │ └── it should revert ├── when vesting start time in present diff --git a/tests/integration/concrete/campaign/vca/claim-via-sign/claimViaSig.t.sol b/tests/integration/concrete/campaign/vca/claim-via-sign/claimViaSig.t.sol new file mode 100644 index 00000000..d898b4b3 --- /dev/null +++ b/tests/integration/concrete/campaign/vca/claim-via-sign/claimViaSig.t.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22 <0.9.0; + +import { ISablierMerkleVCA } from "src/interfaces/ISablierMerkleVCA.sol"; + +import { ClaimViaSig_Integration_Test } from "./../../shared/claim-via-sig/claimViaSig.t.sol"; +import { MerkleVCA_Integration_Shared_Test } from "./../MerkleVCA.t.sol"; + +contract ClaimViaSig_MerkleVCA_Integration_Test is ClaimViaSig_Integration_Test, MerkleVCA_Integration_Shared_Test { + function setUp() public virtual override(MerkleVCA_Integration_Shared_Test, ClaimViaSig_Integration_Test) { + MerkleVCA_Integration_Shared_Test.setUp(); + ClaimViaSig_Integration_Test.setUp(); + } + + function test_WhenSignerSameAsRecipient() + external + override + whenToAddressNotZero + givenRecipientIsEOA + whenSignatureCompatible + { + uint128 forgoneAmount = VCA_FULL_AMOUNT - VCA_CLAIM_AMOUNT; + uint256 previousFeeAccrued = address(factoryMerkleVCA).balance; + uint256 index = getIndexInMerkleTree(); + + eip712Signature = generateSignature(users.recipient, address(merkleVCA)); + + vm.expectEmit({ emitter: address(merkleVCA) }); + emit ISablierMerkleVCA.Claim({ + index: index, + recipient: users.recipient, + claimAmount: VCA_CLAIM_AMOUNT, + forgoneAmount: forgoneAmount, + to: users.eve + }); + + expectCallToTransfer({ to: users.eve, value: VCA_CLAIM_AMOUNT }); + expectCallToClaimViaSigWithMsgValue(address(merkleVCA), MIN_FEE_WEI); + + claimViaSig(); + + assertTrue(merkleVCA.hasClaimed(index), "not claimed"); + assertEq(merkleVCA.totalForgoneAmount(), forgoneAmount, "total forgone amount"); + assertEq(address(factoryMerkleVCA).balance, previousFeeAccrued + MIN_FEE_WEI, "fee collected"); + } + + function test_WhenRecipientImplementsIERC1271Interface() + external + override + whenToAddressNotZero + givenRecipientIsContract + { + uint128 forgoneAmount = VCA_FULL_AMOUNT - VCA_CLAIM_AMOUNT; + uint256 previousFeeAccrued = address(factoryMerkleVCA).balance; + uint256 index = getIndexInMerkleTree(users.smartWalletWithIERC1271); + + eip712Signature = generateSignature(users.smartWalletWithIERC1271, address(merkleVCA)); + + vm.expectEmit({ emitter: address(merkleVCA) }); + emit ISablierMerkleVCA.Claim({ + index: index, + recipient: users.smartWalletWithIERC1271, + claimAmount: VCA_CLAIM_AMOUNT, + forgoneAmount: forgoneAmount, + to: users.eve + }); + + expectCallToTransfer({ to: users.eve, value: VCA_CLAIM_AMOUNT }); + + claimViaSig({ + msgValue: MIN_FEE_WEI, + index: index, + recipient: users.smartWalletWithIERC1271, + to: users.eve, + amount: VCA_FULL_AMOUNT, + merkleProof: getMerkleProof(users.smartWalletWithIERC1271), + signature: eip712Signature + }); + + assertTrue(merkleVCA.hasClaimed(index), "not claimed"); + assertEq(merkleVCA.totalForgoneAmount(), forgoneAmount, "total forgone amount"); + assertEq(address(factoryMerkleVCA).balance, previousFeeAccrued + MIN_FEE_WEI, "fee collected"); + } +} diff --git a/tests/integration/concrete/campaign/vca/claim/claim.t.sol b/tests/integration/concrete/campaign/vca/claim/claim.t.sol index bbe7a704..1963acd0 100644 --- a/tests/integration/concrete/campaign/vca/claim/claim.t.sol +++ b/tests/integration/concrete/campaign/vca/claim/claim.t.sol @@ -20,14 +20,14 @@ contract Claim_MerkleVCA_Integration_Test is Claim_Integration_Test, MerkleVCA_I merkleVCA = createMerkleVCA(params); // It should revert. - vm.expectRevert(abi.encodeWithSelector(Errors.SablierMerkleVCA_ClaimAmountZero.selector, users.recipient1)); + vm.expectRevert(abi.encodeWithSelector(Errors.SablierMerkleVCA_ClaimAmountZero.selector, users.recipient)); // Claim the airdrop. merkleVCA.claim{ value: MIN_FEE_WEI }({ - index: INDEX1, - recipient: users.recipient1, + index: getIndexInMerkleTree(), + recipient: users.recipient, fullAmount: CLAIM_AMOUNT, - merkleProof: index1Proof() + merkleProof: getMerkleProof() }); } @@ -52,27 +52,32 @@ contract Claim_MerkleVCA_Integration_Test is Claim_Integration_Test, MerkleVCA_I } function _test_Claim(uint128 claimAmount) private { + // Cast the {MerkleVCA} contract as {ISablierMerkleBase}. + merkleBase = merkleVCA; + + uint256 index = getIndexInMerkleTree(); + uint128 forgoneAmount = VCA_FULL_AMOUNT - claimAmount; uint256 previousFeeAccrued = address(factoryMerkleVCA).balance; // It should emit a {Claim} event. vm.expectEmit({ emitter: address(merkleVCA) }); emit ISablierMerkleVCA.Claim({ - index: INDEX1, - recipient: users.recipient1, + index: index, + recipient: users.recipient, claimAmount: claimAmount, forgoneAmount: forgoneAmount, - to: users.recipient1 + to: users.recipient }); // It should transfer a portion of the amount. - expectCallToTransfer({ to: users.recipient1, value: claimAmount }); + expectCallToTransfer({ to: users.recipient, value: claimAmount }); expectCallToClaimWithMsgValue(address(merkleVCA), MIN_FEE_WEI); claim(); // It should update the claimed status. - assertTrue(merkleVCA.hasClaimed(INDEX1), "not claimed"); + assertTrue(merkleVCA.hasClaimed(index), "not claimed"); // It should update the total forgone amount. assertEq(merkleVCA.totalForgoneAmount(), forgoneAmount, "total forgone amount"); diff --git a/tests/integration/concrete/campaign/vca/claim/claim.tree b/tests/integration/concrete/campaign/vca/claim/claim.tree index 4e1c3900..317c015b 100644 --- a/tests/integration/concrete/campaign/vca/claim/claim.tree +++ b/tests/integration/concrete/campaign/vca/claim/claim.tree @@ -1,5 +1,5 @@ Claim_MerkleVCA_Integration_Test -└── when valid Merkle proof +└── when merkle proof valid ├── when vesting start time in future │ └── it should revert ├── when vesting start time in present diff --git a/tests/integration/concrete/campaign/vca/constructor.t.sol b/tests/integration/concrete/campaign/vca/constructor.t.sol index 3a02fba6..076ad6b9 100644 --- a/tests/integration/concrete/campaign/vca/constructor.t.sol +++ b/tests/integration/concrete/campaign/vca/constructor.t.sol @@ -3,9 +3,10 @@ pragma solidity >=0.8.22 <0.9.0; import { SablierMerkleVCA } from "src/SablierMerkleVCA.sol"; -import { MerkleVCA_Integration_Shared_Test } from "./MerkleVCA.t.sol"; +import { Utilities } from "../../../../utils/Utilities.sol"; +import { Integration_Test } from "./../../../Integration.t.sol"; -contract Constructor_MerkleVCA_Integration_Test is MerkleVCA_Integration_Shared_Test { +contract Constructor_MerkleVCA_Integration_Test is Integration_Test { function test_Constructor() external { // Make Factory the caller for the constructor test. setMsgSender(address(factoryMerkleVCA)); @@ -17,6 +18,11 @@ contract Constructor_MerkleVCA_Integration_Test is MerkleVCA_Integration_Shared_ assertEq(constructedVCA.admin(), users.campaignCreator, "admin"); assertEq(constructedVCA.campaignName(), CAMPAIGN_NAME, "campaign name"); assertEq(constructedVCA.CAMPAIGN_START_TIME(), CAMPAIGN_START_TIME, "campaign start time"); + assertEq( + constructedVCA.DOMAIN_SEPARATOR(), + Utilities.computeEIP712DomainSeparator(address(constructedVCA)), + "domain separator" + ); assertEq(constructedVCA.EXPIRATION(), EXPIRATION, "expiration"); assertEq(address(constructedVCA.FACTORY()), address(factoryMerkleVCA), "factory"); assertEq(constructedVCA.ipfsCID(), IPFS_CID, "IPFS CID"); diff --git a/tests/integration/concrete/factory/shared/collect-fees/collectFees.t.sol b/tests/integration/concrete/factory/shared/collect-fees/collectFees.t.sol index 92d9d78c..0ea3a4e0 100644 --- a/tests/integration/concrete/factory/shared/collect-fees/collectFees.t.sol +++ b/tests/integration/concrete/factory/shared/collect-fees/collectFees.t.sol @@ -11,7 +11,7 @@ abstract contract CollectFees_Integration_Test is Integration_Test { setMsgSender(users.accountant); // It should transfer fee to the fee recipient. - _test_CollectFees({ feeRecipient: users.recipient }); + _test_CollectFees({ feeRecipient: users.accountant }); } function test_RevertWhen_FeeRecipientNotAdmin() external whenCallerNotAdmin whenCallerWithoutFeeCollectorRole { @@ -30,7 +30,7 @@ abstract contract CollectFees_Integration_Test is Integration_Test { function test_WhenFeeRecipientNotContract() external whenCallerAdmin { // It should transfer fee to the fee recipient. - _test_CollectFees({ feeRecipient: users.recipient }); + _test_CollectFees({ feeRecipient: users.accountant }); } function test_RevertWhen_FeeRecipientDoesNotImplementReceiveFunction() diff --git a/tests/unit/SignatureHash.t.sol b/tests/unit/SignatureHash.t.sol new file mode 100644 index 00000000..e2ffd42a --- /dev/null +++ b/tests/unit/SignatureHash.t.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22 <0.9.0; + +import { SignatureHash } from "src/libraries/SignatureHash.sol"; + +import { Base_Test } from "../Base.t.sol"; + +contract SignatureHash_Integration_Test is Base_Test { + function test_Constants() external pure { + assertEq(SignatureHash.PROTOCOL_NAME, keccak256("Sablier Airdrops Protocol")); + assertEq( + SignatureHash.CLAIM_TYPEHASH, keccak256("Claim(uint256 index,address recipient,address to,uint128 amount)") + ); + assertEq( + SignatureHash.DOMAIN_TYPEHASH, + keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)") + ); + } +} diff --git a/tests/utils/BaseScript.t.sol b/tests/utils/BaseScript.t.sol index 9ab0f2d4..6047729d 100644 --- a/tests/utils/BaseScript.t.sol +++ b/tests/utils/BaseScript.t.sol @@ -2,9 +2,9 @@ pragma solidity >=0.8.22 <0.9.0; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { BaseScript } from "@sablier/evm-utils/src/tests/BaseScript.sol"; import { StdAssertions } from "forge-std/src/StdAssertions.sol"; import { StdConstants } from "forge-std/src/StdConstants.sol"; -import { BaseScript } from "scripts/solidity/Base.sol"; contract BaseScriptMock is BaseScript { } diff --git a/tests/utils/Constants.sol b/tests/utils/Constants.sol index 7f7af1c1..d176a83f 100644 --- a/tests/utils/Constants.sol +++ b/tests/utils/Constants.sol @@ -14,7 +14,6 @@ abstract contract Constants { uint256 public constant AGGREGATE_AMOUNT = CLAIM_AMOUNT * RECIPIENT_COUNT; uint128 public constant CLAIM_AMOUNT = 10_000e18; uint128 public constant CLIFF_AMOUNT = (CLAIM_AMOUNT * VESTING_CLIFF_DURATION) / VESTING_TOTAL_DURATION; - uint256 public constant MAX_FEE_USD = 100e8; // $100 uint256 public constant MIN_FEE_USD = 3e8; // $3 fee uint256 public constant MIN_FEE_WEI = (1e18 * MIN_FEE_USD) / 3000e8; // at $3000 per ETH price uint128 public constant VCA_FULL_AMOUNT = CLAIM_AMOUNT; diff --git a/tests/utils/Modifiers.sol b/tests/utils/Modifiers.sol index 17780618..a3dd2d04 100644 --- a/tests/utils/Modifiers.sol +++ b/tests/utils/Modifiers.sol @@ -47,6 +47,14 @@ abstract contract Modifiers is EvmUtilsBase { _; } + modifier givenRecipientIsContract() { + _; + } + + modifier givenRecipientIsEOA() { + _; + } + modifier givenRecipientNotClaimed() { _; } @@ -76,7 +84,7 @@ abstract contract Modifiers is EvmUtilsBase { } modifier whenCallerEligible() { - setMsgSender(users.recipient1); + setMsgSender(users.recipient); _; } @@ -172,6 +180,10 @@ abstract contract Modifiers is EvmUtilsBase { _; } + modifier whenSignatureCompatible() { + _; + } + modifier whenToAddressNotZero() { _; } diff --git a/tests/utils/Types.sol b/tests/utils/Types.sol index cc27b810..28b7b839 100644 --- a/tests/utils/Types.sol +++ b/tests/utils/Types.sol @@ -12,15 +12,16 @@ struct Users { address payable campaignCreator; // Malicious user. address payable eve; - // Default stream recipient. + // The default recipient to be used for claiming during tests. address payable recipient; - // Other recipients. - address payable recipient1; - address payable recipient2; - address payable recipient3; - address payable recipient4; + // A contract recipient supporting the IERC1271 interface. + address payable smartWalletWithIERC1271; + // A contract recipient not supporting the IERC1271 interface. + address payable smartWalletWithoutIERC1271; // Default stream sender. address payable sender; + // An unknown recipient. + address payable unknownRecipient; } /// @dev Struct to hold the common parameters needed for fuzz tests. diff --git a/tests/utils/Utilities.sol b/tests/utils/Utilities.sol new file mode 100644 index 00000000..919cb51f --- /dev/null +++ b/tests/utils/Utilities.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.22; + +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { StdConstants } from "forge-std/src/StdConstants.sol"; +import { SignatureHash } from "src/libraries/SignatureHash.sol"; + +library Utilities { + /// @notice Computes the EIP-712 domain separator for the provided Merkle contract. + function computeEIP712DomainSeparator(address merkleContract) internal view returns (bytes32) { + return keccak256( + abi.encode(SignatureHash.DOMAIN_TYPEHASH, SignatureHash.PROTOCOL_NAME, block.chainid, merkleContract) + ); + } + + /// @notice Generates the EIP-191 signature for the given claim parameters and returns it. + function generateEIP191Signature( + uint256 signerPrivateKey, + uint256 index, + address recipient, + address to, + uint128 amount + ) + internal + pure + returns (bytes memory signature) + { + // Compute the claim hash. + bytes32 claimHash = keccak256(abi.encodePacked(index, recipient, to, amount)); + + // Compute the keccak256 digest of the claim hash. + bytes32 digest = MessageHashUtils.toEthSignedMessageHash(claimHash); + + // Return the signature. + signature = sign(signerPrivateKey, digest); + } + + /// @notice Generates the EIP-712 signature for the given claim parameters and returns it. + function generateEIP712Signature( + uint256 signerPrivateKey, + address merkleContract, + uint256 index, + address recipient, + address to, + uint128 amount + ) + internal + view + returns (bytes memory signature) + { + // Compute the domain separator. + bytes32 domainSeparator = computeEIP712DomainSeparator(merkleContract); + + // Compute the claim hash. + bytes32 claimHash = keccak256(abi.encode(SignatureHash.CLAIM_TYPEHASH, index, recipient, to, amount)); + + // Compute the keccak256 digest of the EIP-712 typed data. + bytes32 digest = MessageHashUtils.toTypedDataHash({ domainSeparator: domainSeparator, structHash: claimHash }); + + // Return the signature. + signature = sign(signerPrivateKey, digest); + } + + /// @notice Signs the provided digest using private key and returns the signature. + function sign(uint256 signerPrivateKey, bytes32 digest) internal pure returns (bytes memory signature) { + // Sign the digest. + (uint8 v, bytes32 r, bytes32 s) = StdConstants.VM.sign(signerPrivateKey, digest); + + // Return the signature. + signature = abi.encodePacked(r, s, v); + } +}