From ed62fee7c7ed23cf2cbfa93f0c46383f32e3bb53 Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Fri, 27 Sep 2024 15:38:32 -0400 Subject: [PATCH 01/21] Beginnings of a DecentSablier module --- contracts/DecentSablier_0_1_0.sol | 42 +++++++ contracts/interfaces/sablier/ISablier.sol | 28 +++++ contracts/mocks/MockSablier.sol | 34 +++++ test/DecentSablier_0_1_0.test.ts | 143 ++++++++++++++++++++++ 4 files changed, 247 insertions(+) create mode 100644 contracts/DecentSablier_0_1_0.sol create mode 100644 contracts/interfaces/sablier/ISablier.sol create mode 100644 contracts/mocks/MockSablier.sol create mode 100644 test/DecentSablier_0_1_0.test.ts diff --git a/contracts/DecentSablier_0_1_0.sol b/contracts/DecentSablier_0_1_0.sol new file mode 100644 index 00000000..1f8bd902 --- /dev/null +++ b/contracts/DecentSablier_0_1_0.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.19; + +import {Enum} from "@gnosis.pm/safe-contracts/contracts/common/Enum.sol"; +import {IAvatar} from "@gnosis.pm/zodiac/contracts/interfaces/IAvatar.sol"; +import {ISablier} from "./interfaces/sablier/ISablier.sol"; + +contract DecentSablier_0_1_0 { + string public constant NAME = "DecentSablier_0_1_0"; + + struct SablierStreamInfo { + uint256 streamId; + } + + function processSablierStreams( + address sablierContract, + SablierStreamInfo[] calldata streams + ) public { + ISablier sablier = ISablier(sablierContract); + + for (uint256 i = 0; i < streams.length; i++) { + uint256 streamId = streams[i].streamId; + + // Get the current balance available for withdrawal + uint256 availableBalance = sablier.balanceOf(streamId, msg.sender); + + if (availableBalance > 0) { + // Proxy the withdrawal call through the Safe + IAvatar(msg.sender).execTransactionFromModule( + sablierContract, + 0, + abi.encodeWithSelector( + ISablier.withdrawFromStream.selector, + streamId, + availableBalance + ), + Enum.Operation.Call + ); + } + } + } +} diff --git a/contracts/interfaces/sablier/ISablier.sol b/contracts/interfaces/sablier/ISablier.sol new file mode 100644 index 00000000..a11535ab --- /dev/null +++ b/contracts/interfaces/sablier/ISablier.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.19; + +interface ISablier { + function getStream( + uint256 streamId + ) + external + view + returns ( + address sender, + address recipient, + uint256 deposit, + address tokenAddress, + uint256 startTime, + uint256 stopTime, + uint256 remainingBalance, + uint256 ratePerSecond + ); + function balanceOf( + uint256 streamId, + address who + ) external view returns (uint256 balance); + function withdrawFromStream( + uint256 streamId, + uint256 amount + ) external returns (bool); +} diff --git a/contracts/mocks/MockSablier.sol b/contracts/mocks/MockSablier.sol new file mode 100644 index 00000000..0d823195 --- /dev/null +++ b/contracts/mocks/MockSablier.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.19; + +contract MockSablier { + mapping(uint256 => uint256) private streamBalances; + mapping(uint256 => uint256) private withdrawnAmounts; + + function setStreamBalance(uint256 streamId, uint256 balance) external { + streamBalances[streamId] = balance; + } + + function balanceOf( + uint256 streamId, + address + ) external view returns (uint256) { + return streamBalances[streamId]; + } + + function withdrawFromStream( + uint256 streamId, + uint256 amount + ) external returns (bool) { + require(streamBalances[streamId] >= amount, "Insufficient balance"); + streamBalances[streamId] -= amount; + withdrawnAmounts[streamId] += amount; + return true; + } + + function getWithdrawnAmount( + uint256 streamId + ) external view returns (uint256) { + return withdrawnAmounts[streamId]; + } +} diff --git a/test/DecentSablier_0_1_0.test.ts b/test/DecentSablier_0_1_0.test.ts new file mode 100644 index 00000000..05884254 --- /dev/null +++ b/test/DecentSablier_0_1_0.test.ts @@ -0,0 +1,143 @@ +import { + GnosisSafeL2, + GnosisSafeL2__factory, + DecentSablier_0_1_0__factory, + DecentSablier_0_1_0, +} from "../typechain-types"; + +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { expect } from "chai"; +import { ethers } from "ethers"; +import hre from "hardhat"; + +import { + getGnosisSafeL2Singleton, + getGnosisSafeProxyFactory, +} from "./GlobalSafeDeployments.test"; +import { + buildSafeTransaction, + buildSignatureBytes, + predictGnosisSafeAddress, + safeSignTypedData, +} from "./helpers"; + +import { MockSablier__factory } from "../typechain-types"; + +async function executeSafeTransaction({ + safe, + to, + value, + data, + operation, + signers, +}: { + safe: GnosisSafeL2; + to: string; + value?: bigint; + data?: string; + operation?: number; + signers: SignerWithAddress[]; +}) { + const safeTransactionData = { + to, + value: value || 0n, + data: data || "0x", + operation: operation || 0, + // Add the missing 'nonce' property + nonce: await safe.nonce(), + }; + const safeTransaction = await buildSafeTransaction(safeTransactionData); + const senderSignature = await safeSignTypedData( + signers[0], + safe, + safeTransaction + ); + const signatureBytes = buildSignatureBytes([senderSignature]); + // Change 'executeTransaction' to 'execTransaction' + return safe.execTransaction(safeTransaction, signatureBytes); +} + +describe("DecentSablier", () => { + let dao: SignerWithAddress; + let gnosisSafe: GnosisSafeL2; + let decentSablier: DecentSablier_0_1_0; + let decentSablierAddress: string; + let gnosisSafeAddress: string; + + let mockSablier: MockSablier; + + beforeEach(async () => { + // ... (setup code similar to DecentHats.test.ts) + // Deploy MockSablier + const MockSablier = await ethers.getContractFactory("MockSablier"); + mockSablier = await MockSablier.deploy(); + await mockSablier.deployed(); + }); + + describe("DecentSablier as a Module", () => { + let enableModuleTx: ethers.ContractTransactionResponse; + + beforeEach(async () => { + // ... (enable module code similar to DecentHats.test.ts) + }); + + it("Emits an ExecutionSuccess event", async () => { + await expect(enableModuleTx).to.emit(gnosisSafe, "ExecutionSuccess"); + }); + + it("Emits an EnabledModule event", async () => { + await expect(enableModuleTx) + .to.emit(gnosisSafe, "EnabledModule") + .withArgs(decentSablierAddress); + }); + + describe("Processing Sablier Streams", () => { + let processSablierStreamsTx: ethers.ContractTransactionResponse; + + beforeEach(async () => { + // Set up mock stream balances + await mockSablier.setStreamBalance(1, ethers.utils.parseEther("100")); + await mockSablier.setStreamBalance(2, ethers.utils.parseEther("200")); + await mockSablier.setStreamBalance(3, ethers.utils.parseEther("300")); + + processSablierStreamsTx = await executeSafeTransaction({ + safe: gnosisSafe, + to: decentSablierAddress, + data: DecentSablier_0_1_0__factory.createInterface().encodeFunctionData( + "processSablierStreams", + [ + mockSablier.address, + [{ streamId: 1 }, { streamId: 2 }, { streamId: 3 }], + ] + ), + signers: [dao], + }); + }); + + it("Emits an ExecutionSuccess event", async () => { + await expect(processSablierStreamsTx).to.emit( + gnosisSafe, + "ExecutionSuccess" + ); + }); + + it("Emits an ExecutionFromModuleSuccess event", async () => { + await expect(processSablierStreamsTx) + .to.emit(gnosisSafe, "ExecutionFromModuleSuccess") + .withArgs(decentSablierAddress); + }); + + it("Withdraws from streams correctly", async () => { + expect(await mockSablier.getWithdrawnAmount(1)).to.equal( + ethers.utils.parseEther("100") + ); + expect(await mockSablier.getWithdrawnAmount(2)).to.equal( + ethers.utils.parseEther("200") + ); + expect(await mockSablier.getWithdrawnAmount(3)).to.equal( + ethers.utils.parseEther("300") + ); + }); + }); + }); +}); From ff7adfafc9bb4eda44c1cdd9ad1bcea81ab1194f Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Fri, 27 Sep 2024 15:39:31 -0400 Subject: [PATCH 02/21] Update contract and create some new Sablier interfaces --- contracts/DecentSablier_0_1_0.sol | 53 ++++++++----------- .../interfaces/sablier/ISablierV2Lockup.sol | 12 +++++ .../sablier/ISablierV2LockupLinear.sol | 3 +- 3 files changed, 36 insertions(+), 32 deletions(-) create mode 100644 contracts/interfaces/sablier/ISablierV2Lockup.sol diff --git a/contracts/DecentSablier_0_1_0.sol b/contracts/DecentSablier_0_1_0.sol index 1f8bd902..5662ac63 100644 --- a/contracts/DecentSablier_0_1_0.sol +++ b/contracts/DecentSablier_0_1_0.sol @@ -3,40 +3,31 @@ pragma solidity =0.8.19; import {Enum} from "@gnosis.pm/safe-contracts/contracts/common/Enum.sol"; import {IAvatar} from "@gnosis.pm/zodiac/contracts/interfaces/IAvatar.sol"; -import {ISablier} from "./interfaces/sablier/ISablier.sol"; +import {ISablierV2LockupLinear} from "./interfaces/sablier/ISablierV2LockupLinear.sol"; -contract DecentSablier_0_1_0 { - string public constant NAME = "DecentSablier_0_1_0"; +contract DecentSablierStreamManagement { + string public constant NAME = "DecentSablierStreamManagement"; - struct SablierStreamInfo { - uint256 streamId; - } - - function processSablierStreams( - address sablierContract, - SablierStreamInfo[] calldata streams + function withdrawMaxFromStream( + ISablierV2LockupLinear sablier, + uint256 streamId ) public { - ISablier sablier = ISablier(sablierContract); - - for (uint256 i = 0; i < streams.length; i++) { - uint256 streamId = streams[i].streamId; - - // Get the current balance available for withdrawal - uint256 availableBalance = sablier.balanceOf(streamId, msg.sender); - - if (availableBalance > 0) { - // Proxy the withdrawal call through the Safe - IAvatar(msg.sender).execTransactionFromModule( - sablierContract, - 0, - abi.encodeWithSelector( - ISablier.withdrawFromStream.selector, - streamId, - availableBalance - ), - Enum.Operation.Call - ); - } + // Check if there are funds to withdraw + uint128 withdrawableAmount = sablier.withdrawableAmountOf(streamId); + if (withdrawableAmount == 0) { + return; } + + // Proxy the Sablier withdrawMax call through IAvatar (Safe) + IAvatar(msg.sender).execTransactionFromModule( + address(sablier), + 0, + abi.encodeWithSignature( + "withdrawMax(uint256,address)", + streamId, + msg.sender + ), + Enum.Operation.Call + ); } } diff --git a/contracts/interfaces/sablier/ISablierV2Lockup.sol b/contracts/interfaces/sablier/ISablierV2Lockup.sol new file mode 100644 index 00000000..a6ee2545 --- /dev/null +++ b/contracts/interfaces/sablier/ISablierV2Lockup.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface ISablierV2Lockup { + function getRecipient( + uint256 streamId + ) external view returns (address recipient); + + function withdrawableAmountOf( + uint256 streamId + ) external view returns (uint128 withdrawableAmount); +} diff --git a/contracts/interfaces/sablier/ISablierV2LockupLinear.sol b/contracts/interfaces/sablier/ISablierV2LockupLinear.sol index 0aa6cac9..ebcc6d50 100644 --- a/contracts/interfaces/sablier/ISablierV2LockupLinear.sol +++ b/contracts/interfaces/sablier/ISablierV2LockupLinear.sol @@ -2,9 +2,10 @@ pragma solidity ^0.8.0; import {LockupLinear} from "./LockupLinear.sol"; +import {ISablierV2Lockup} from "./ISablierV2Lockup.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -interface ISablierV2LockupLinear { +interface ISablierV2LockupLinear is ISablierV2Lockup { function createWithTimestamps( LockupLinear.CreateWithTimestamps calldata params ) external returns (uint256 streamId); From e0df21ee1403be77aa84de19e389c63b6fa1a462 Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Fri, 27 Sep 2024 15:42:36 -0400 Subject: [PATCH 03/21] Remove unused interface, finish mock implementation --- contracts/interfaces/sablier/ISablier.sol | 28 ------------------- contracts/mocks/MockSablierV2LockupLinear.sol | 4 +++ 2 files changed, 4 insertions(+), 28 deletions(-) delete mode 100644 contracts/interfaces/sablier/ISablier.sol diff --git a/contracts/interfaces/sablier/ISablier.sol b/contracts/interfaces/sablier/ISablier.sol deleted file mode 100644 index a11535ab..00000000 --- a/contracts/interfaces/sablier/ISablier.sol +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity =0.8.19; - -interface ISablier { - function getStream( - uint256 streamId - ) - external - view - returns ( - address sender, - address recipient, - uint256 deposit, - address tokenAddress, - uint256 startTime, - uint256 stopTime, - uint256 remainingBalance, - uint256 ratePerSecond - ); - function balanceOf( - uint256 streamId, - address who - ) external view returns (uint256 balance); - function withdrawFromStream( - uint256 streamId, - uint256 amount - ) external returns (bool); -} diff --git a/contracts/mocks/MockSablierV2LockupLinear.sol b/contracts/mocks/MockSablierV2LockupLinear.sol index 7d737bb4..f0ad0030 100644 --- a/contracts/mocks/MockSablierV2LockupLinear.sol +++ b/contracts/mocks/MockSablierV2LockupLinear.sol @@ -156,4 +156,8 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { stream.recipient = recipient; } + + function getRecipient(uint256 streamId) external view returns (address) { + return streams[streamId].recipient; + } } From fa0369142f20a0989814e5a8ddd4eac550d4ed5d Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Fri, 27 Sep 2024 15:44:31 -0400 Subject: [PATCH 04/21] Comment out new tests, for now --- test/DecentSablier_0_1_0.test.ts | 286 +++++++++++++++---------------- 1 file changed, 143 insertions(+), 143 deletions(-) diff --git a/test/DecentSablier_0_1_0.test.ts b/test/DecentSablier_0_1_0.test.ts index 05884254..935f46f5 100644 --- a/test/DecentSablier_0_1_0.test.ts +++ b/test/DecentSablier_0_1_0.test.ts @@ -1,143 +1,143 @@ -import { - GnosisSafeL2, - GnosisSafeL2__factory, - DecentSablier_0_1_0__factory, - DecentSablier_0_1_0, -} from "../typechain-types"; - -import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; -import { expect } from "chai"; -import { ethers } from "ethers"; -import hre from "hardhat"; - -import { - getGnosisSafeL2Singleton, - getGnosisSafeProxyFactory, -} from "./GlobalSafeDeployments.test"; -import { - buildSafeTransaction, - buildSignatureBytes, - predictGnosisSafeAddress, - safeSignTypedData, -} from "./helpers"; - -import { MockSablier__factory } from "../typechain-types"; - -async function executeSafeTransaction({ - safe, - to, - value, - data, - operation, - signers, -}: { - safe: GnosisSafeL2; - to: string; - value?: bigint; - data?: string; - operation?: number; - signers: SignerWithAddress[]; -}) { - const safeTransactionData = { - to, - value: value || 0n, - data: data || "0x", - operation: operation || 0, - // Add the missing 'nonce' property - nonce: await safe.nonce(), - }; - const safeTransaction = await buildSafeTransaction(safeTransactionData); - const senderSignature = await safeSignTypedData( - signers[0], - safe, - safeTransaction - ); - const signatureBytes = buildSignatureBytes([senderSignature]); - // Change 'executeTransaction' to 'execTransaction' - return safe.execTransaction(safeTransaction, signatureBytes); -} - -describe("DecentSablier", () => { - let dao: SignerWithAddress; - let gnosisSafe: GnosisSafeL2; - let decentSablier: DecentSablier_0_1_0; - let decentSablierAddress: string; - let gnosisSafeAddress: string; - - let mockSablier: MockSablier; - - beforeEach(async () => { - // ... (setup code similar to DecentHats.test.ts) - // Deploy MockSablier - const MockSablier = await ethers.getContractFactory("MockSablier"); - mockSablier = await MockSablier.deploy(); - await mockSablier.deployed(); - }); - - describe("DecentSablier as a Module", () => { - let enableModuleTx: ethers.ContractTransactionResponse; - - beforeEach(async () => { - // ... (enable module code similar to DecentHats.test.ts) - }); - - it("Emits an ExecutionSuccess event", async () => { - await expect(enableModuleTx).to.emit(gnosisSafe, "ExecutionSuccess"); - }); - - it("Emits an EnabledModule event", async () => { - await expect(enableModuleTx) - .to.emit(gnosisSafe, "EnabledModule") - .withArgs(decentSablierAddress); - }); - - describe("Processing Sablier Streams", () => { - let processSablierStreamsTx: ethers.ContractTransactionResponse; - - beforeEach(async () => { - // Set up mock stream balances - await mockSablier.setStreamBalance(1, ethers.utils.parseEther("100")); - await mockSablier.setStreamBalance(2, ethers.utils.parseEther("200")); - await mockSablier.setStreamBalance(3, ethers.utils.parseEther("300")); - - processSablierStreamsTx = await executeSafeTransaction({ - safe: gnosisSafe, - to: decentSablierAddress, - data: DecentSablier_0_1_0__factory.createInterface().encodeFunctionData( - "processSablierStreams", - [ - mockSablier.address, - [{ streamId: 1 }, { streamId: 2 }, { streamId: 3 }], - ] - ), - signers: [dao], - }); - }); - - it("Emits an ExecutionSuccess event", async () => { - await expect(processSablierStreamsTx).to.emit( - gnosisSafe, - "ExecutionSuccess" - ); - }); - - it("Emits an ExecutionFromModuleSuccess event", async () => { - await expect(processSablierStreamsTx) - .to.emit(gnosisSafe, "ExecutionFromModuleSuccess") - .withArgs(decentSablierAddress); - }); - - it("Withdraws from streams correctly", async () => { - expect(await mockSablier.getWithdrawnAmount(1)).to.equal( - ethers.utils.parseEther("100") - ); - expect(await mockSablier.getWithdrawnAmount(2)).to.equal( - ethers.utils.parseEther("200") - ); - expect(await mockSablier.getWithdrawnAmount(3)).to.equal( - ethers.utils.parseEther("300") - ); - }); - }); - }); -}); +// import { +// GnosisSafeL2, +// GnosisSafeL2__factory, +// DecentSablier_0_1_0__factory, +// DecentSablier_0_1_0, +// } from "../typechain-types"; + +// import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +// import { expect } from "chai"; +// import { ethers } from "ethers"; +// import hre from "hardhat"; + +// import { +// getGnosisSafeL2Singleton, +// getGnosisSafeProxyFactory, +// } from "./GlobalSafeDeployments.test"; +// import { +// buildSafeTransaction, +// buildSignatureBytes, +// predictGnosisSafeAddress, +// safeSignTypedData, +// } from "./helpers"; + +// import { MockSablier__factory } from "../typechain-types"; + +// async function executeSafeTransaction({ +// safe, +// to, +// value, +// data, +// operation, +// signers, +// }: { +// safe: GnosisSafeL2; +// to: string; +// value?: bigint; +// data?: string; +// operation?: number; +// signers: SignerWithAddress[]; +// }) { +// const safeTransactionData = { +// to, +// value: value || 0n, +// data: data || "0x", +// operation: operation || 0, +// // Add the missing 'nonce' property +// nonce: await safe.nonce(), +// }; +// const safeTransaction = await buildSafeTransaction(safeTransactionData); +// const senderSignature = await safeSignTypedData( +// signers[0], +// safe, +// safeTransaction +// ); +// const signatureBytes = buildSignatureBytes([senderSignature]); +// // Change 'executeTransaction' to 'execTransaction' +// return safe.execTransaction(safeTransaction, signatureBytes); +// } + +// describe("DecentSablier", () => { +// let dao: SignerWithAddress; +// let gnosisSafe: GnosisSafeL2; +// let decentSablier: DecentSablier_0_1_0; +// let decentSablierAddress: string; +// let gnosisSafeAddress: string; + +// let mockSablier: MockSablier; + +// beforeEach(async () => { +// // ... (setup code similar to DecentHats.test.ts) +// // Deploy MockSablier +// const MockSablier = await ethers.getContractFactory("MockSablier"); +// mockSablier = await MockSablier.deploy(); +// await mockSablier.deployed(); +// }); + +// describe("DecentSablier as a Module", () => { +// let enableModuleTx: ethers.ContractTransactionResponse; + +// beforeEach(async () => { +// // ... (enable module code similar to DecentHats.test.ts) +// }); + +// it("Emits an ExecutionSuccess event", async () => { +// await expect(enableModuleTx).to.emit(gnosisSafe, "ExecutionSuccess"); +// }); + +// it("Emits an EnabledModule event", async () => { +// await expect(enableModuleTx) +// .to.emit(gnosisSafe, "EnabledModule") +// .withArgs(decentSablierAddress); +// }); + +// describe("Processing Sablier Streams", () => { +// let processSablierStreamsTx: ethers.ContractTransactionResponse; + +// beforeEach(async () => { +// // Set up mock stream balances +// await mockSablier.setStreamBalance(1, ethers.utils.parseEther("100")); +// await mockSablier.setStreamBalance(2, ethers.utils.parseEther("200")); +// await mockSablier.setStreamBalance(3, ethers.utils.parseEther("300")); + +// processSablierStreamsTx = await executeSafeTransaction({ +// safe: gnosisSafe, +// to: decentSablierAddress, +// data: DecentSablier_0_1_0__factory.createInterface().encodeFunctionData( +// "processSablierStreams", +// [ +// mockSablier.address, +// [{ streamId: 1 }, { streamId: 2 }, { streamId: 3 }], +// ] +// ), +// signers: [dao], +// }); +// }); + +// it("Emits an ExecutionSuccess event", async () => { +// await expect(processSablierStreamsTx).to.emit( +// gnosisSafe, +// "ExecutionSuccess" +// ); +// }); + +// it("Emits an ExecutionFromModuleSuccess event", async () => { +// await expect(processSablierStreamsTx) +// .to.emit(gnosisSafe, "ExecutionFromModuleSuccess") +// .withArgs(decentSablierAddress); +// }); + +// it("Withdraws from streams correctly", async () => { +// expect(await mockSablier.getWithdrawnAmount(1)).to.equal( +// ethers.utils.parseEther("100") +// ); +// expect(await mockSablier.getWithdrawnAmount(2)).to.equal( +// ethers.utils.parseEther("200") +// ); +// expect(await mockSablier.getWithdrawnAmount(3)).to.equal( +// ethers.utils.parseEther("300") +// ); +// }); +// }); +// }); +// }); From 0f747ac11773ec365a909f13b02b684163c1afb8 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Fri, 4 Oct 2024 13:12:02 +0100 Subject: [PATCH 05/21] Update withdrawMaxFromStream --- contracts/DecentSablier_0_1_0.sol | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/contracts/DecentSablier_0_1_0.sol b/contracts/DecentSablier_0_1_0.sol index 5662ac63..2fe0e115 100644 --- a/contracts/DecentSablier_0_1_0.sol +++ b/contracts/DecentSablier_0_1_0.sol @@ -10,7 +10,9 @@ contract DecentSablierStreamManagement { function withdrawMaxFromStream( ISablierV2LockupLinear sablier, - uint256 streamId + HatsAccount1ofNAbi smartAccount, + uint256 streamId, + address to, ) public { // Check if there are funds to withdraw uint128 withdrawableAmount = sablier.withdrawableAmountOf(streamId); @@ -20,12 +22,18 @@ contract DecentSablierStreamManagement { // Proxy the Sablier withdrawMax call through IAvatar (Safe) IAvatar(msg.sender).execTransactionFromModule( - address(sablier), + address(smartAccount), 0, abi.encodeWithSignature( - "withdrawMax(uint256,address)", - streamId, - msg.sender + "execute(address,uint256,bytes,uint8)", + address(sablier), + 0, + abi.encodeWithSignature( + "withdrawMax(uint256,address)", + streamId, + to + ), + 0 ), Enum.Operation.Call ); From 67601b11914382e5c5edc2a44b6dbecfb14c8260 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Fri, 4 Oct 2024 16:56:38 +0100 Subject: [PATCH 06/21] Add `cancelStream` --- contracts/DecentSablier_0_1_0.sol | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/contracts/DecentSablier_0_1_0.sol b/contracts/DecentSablier_0_1_0.sol index 2fe0e115..8bcbeefd 100644 --- a/contracts/DecentSablier_0_1_0.sol +++ b/contracts/DecentSablier_0_1_0.sol @@ -38,4 +38,21 @@ contract DecentSablierStreamManagement { Enum.Operation.Call ); } + + function cancelStream(ISablierV2LockupLinear sablier, uint256 streamId) public { + // Check if the stream is still active + if (!sablier.isCancelable(streamId)) { + return; + } + + IAvatar(msg.sender).execTransactionFromModule( + address(sablier), + 0, + abi.encodeWithSignature( + "cancel(uint256)", + streamId, + ), + Enum.Operation.Call + ); + } } From da60def6f7c9864b12b6ab767b7c729bb9f89f8a Mon Sep 17 00:00:00 2001 From: Kellar Date: Tue, 8 Oct 2024 14:27:40 +0100 Subject: [PATCH 07/21] Add (probably inaccurate) withdrawMaxFromStream tests --- ....sol => DecentSablierStreamManagement.sol} | 27 +- .../interfaces/sablier/ISablierV2Lockup.sol | 13 +- contracts/mocks/MockSablier.sol | 34 -- contracts/mocks/MockSablierV2LockupLinear.sol | 47 +- test/DecentHats_0_1_0.test.ts | 518 +++++++----------- test/DecentSablierStreamManagement.test.ts | 302 ++++++++++ test/DecentSablier_0_1_0.test.ts | 143 ----- test/helpers.ts | 128 +++-- 8 files changed, 623 insertions(+), 589 deletions(-) rename contracts/{DecentSablier_0_1_0.sol => DecentSablierStreamManagement.sol} (69%) delete mode 100644 contracts/mocks/MockSablier.sol create mode 100644 test/DecentSablierStreamManagement.test.ts delete mode 100644 test/DecentSablier_0_1_0.test.ts diff --git a/contracts/DecentSablier_0_1_0.sol b/contracts/DecentSablierStreamManagement.sol similarity index 69% rename from contracts/DecentSablier_0_1_0.sol rename to contracts/DecentSablierStreamManagement.sol index 8bcbeefd..7528b255 100644 --- a/contracts/DecentSablier_0_1_0.sol +++ b/contracts/DecentSablierStreamManagement.sol @@ -10,9 +10,8 @@ contract DecentSablierStreamManagement { function withdrawMaxFromStream( ISablierV2LockupLinear sablier, - HatsAccount1ofNAbi smartAccount, uint256 streamId, - address to, + address to ) public { // Check if there are funds to withdraw uint128 withdrawableAmount = sablier.withdrawableAmountOf(streamId); @@ -22,24 +21,21 @@ contract DecentSablierStreamManagement { // Proxy the Sablier withdrawMax call through IAvatar (Safe) IAvatar(msg.sender).execTransactionFromModule( - address(smartAccount), + address(sablier), 0, abi.encodeWithSignature( - "execute(address,uint256,bytes,uint8)", - address(sablier), - 0, - abi.encodeWithSignature( - "withdrawMax(uint256,address)", - streamId, - to - ), - 0 + "withdrawMax(uint256,address)", + streamId, + to ), Enum.Operation.Call ); } - function cancelStream(ISablierV2LockupLinear sablier, uint256 streamId) public { + function cancelStream( + ISablierV2LockupLinear sablier, + uint256 streamId + ) public { // Check if the stream is still active if (!sablier.isCancelable(streamId)) { return; @@ -48,10 +44,7 @@ contract DecentSablierStreamManagement { IAvatar(msg.sender).execTransactionFromModule( address(sablier), 0, - abi.encodeWithSignature( - "cancel(uint256)", - streamId, - ), + abi.encodeWithSignature("cancel(uint256)", streamId), Enum.Operation.Call ); } diff --git a/contracts/interfaces/sablier/ISablierV2Lockup.sol b/contracts/interfaces/sablier/ISablierV2Lockup.sol index a6ee2545..124e767f 100644 --- a/contracts/interfaces/sablier/ISablierV2Lockup.sol +++ b/contracts/interfaces/sablier/ISablierV2Lockup.sol @@ -2,11 +2,16 @@ pragma solidity ^0.8.0; interface ISablierV2Lockup { - function getRecipient( - uint256 streamId - ) external view returns (address recipient); - function withdrawableAmountOf( uint256 streamId ) external view returns (uint128 withdrawableAmount); + + function isCancelable(uint256 streamId) external view returns (bool result); + + function withdrawMax( + uint256 streamId, + address to + ) external returns (uint128 withdrawnAmount); + + function cancel(uint256 streamId) external; } diff --git a/contracts/mocks/MockSablier.sol b/contracts/mocks/MockSablier.sol deleted file mode 100644 index 0d823195..00000000 --- a/contracts/mocks/MockSablier.sol +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity =0.8.19; - -contract MockSablier { - mapping(uint256 => uint256) private streamBalances; - mapping(uint256 => uint256) private withdrawnAmounts; - - function setStreamBalance(uint256 streamId, uint256 balance) external { - streamBalances[streamId] = balance; - } - - function balanceOf( - uint256 streamId, - address - ) external view returns (uint256) { - return streamBalances[streamId]; - } - - function withdrawFromStream( - uint256 streamId, - uint256 amount - ) external returns (bool) { - require(streamBalances[streamId] >= amount, "Insufficient balance"); - streamBalances[streamId] -= amount; - withdrawnAmounts[streamId] += amount; - return true; - } - - function getWithdrawnAmount( - uint256 streamId - ) external view returns (uint256) { - return withdrawnAmounts[streamId]; - } -} diff --git a/contracts/mocks/MockSablierV2LockupLinear.sol b/contracts/mocks/MockSablierV2LockupLinear.sol index f0ad0030..c24b136f 100644 --- a/contracts/mocks/MockSablierV2LockupLinear.sol +++ b/contracts/mocks/MockSablierV2LockupLinear.sol @@ -99,16 +99,21 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { ); } - function withdraw(uint256 streamId, uint128 amount) external { + function withdrawMax( + uint256 streamId, + address to + ) external returns (uint128 withdrawnAmount) { + withdrawnAmount = withdrawableAmountOf(streamId); Stream storage stream = streams[streamId]; - require(msg.sender == stream.recipient, "Only recipient can withdraw"); + + require(to == stream.recipient, "Only recipient can withdraw"); require( - amount <= withdrawableAmountOf(streamId), + withdrawnAmount <= withdrawableAmountOf(streamId), "Insufficient withdrawable amount" ); - stream.totalAmount -= amount; - IERC20(stream.asset).transfer(stream.recipient, amount); + stream.totalAmount -= withdrawnAmount; + IERC20(stream.asset).transfer(stream.recipient, withdrawnAmount); } function cancel(uint256 streamId) external { @@ -129,35 +134,7 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { } } - function renounce(uint256 streamId) external { - Stream memory stream = streams[streamId]; - require(msg.sender == stream.recipient, "Only recipient can renounce"); - - uint128 withdrawableAmount = withdrawableAmountOf(streamId); - uint128 refundAmount = stream.totalAmount - withdrawableAmount; - - delete streams[streamId]; - - if (withdrawableAmount > 0) { - IERC20(stream.asset).transfer(stream.recipient, withdrawableAmount); - } - if (refundAmount > 0) { - IERC20(stream.asset).transfer(stream.sender, refundAmount); - } - } - - function transferFrom(uint256 streamId, address recipient) external { - Stream storage stream = streams[streamId]; - require(stream.transferable, "Stream is not transferable"); - require( - msg.sender == stream.recipient, - "Only current recipient can transfer" - ); - - stream.recipient = recipient; - } - - function getRecipient(uint256 streamId) external view returns (address) { - return streams[streamId].recipient; + function isCancelable(uint256 streamId) external view returns (bool) { + return streams[streamId].cancelable; } } diff --git a/test/DecentHats_0_1_0.test.ts b/test/DecentHats_0_1_0.test.ts index afaa5c95..f0da3930 100644 --- a/test/DecentHats_0_1_0.test.ts +++ b/test/DecentHats_0_1_0.test.ts @@ -22,54 +22,15 @@ import { expect } from "chai"; import { ethers, solidityPackedKeccak256 } from "ethers"; import hre from "hardhat"; -import { - getGnosisSafeL2Singleton, - getGnosisSafeProxyFactory, -} from "./GlobalSafeDeployments.test"; +import { getGnosisSafeL2Singleton, getGnosisSafeProxyFactory } from "./GlobalSafeDeployments.test"; import { buildSafeTransaction, buildSignatureBytes, + executeSafeTransaction, predictGnosisSafeAddress, safeSignTypedData, } from "./helpers"; -const executeSafeTransaction = async ({ - safe, - to, - transactionData, - signers, -}: { - safe: GnosisSafeL2; - to: string; - transactionData: string; - signers: SignerWithAddress[]; -}) => { - const safeTx = buildSafeTransaction({ - to, - data: transactionData, - nonce: await safe.nonce(), - }); - - const sigs = await Promise.all( - signers.map(async (signer) => await safeSignTypedData(signer, safe, safeTx)) - ); - - const tx = await safe.execTransaction( - safeTx.to, - safeTx.value, - safeTx.data, - safeTx.operation, - safeTx.safeTxGas, - safeTx.baseGas, - safeTx.gasPrice, - safeTx.gasToken, - safeTx.refundReceiver, - buildSignatureBytes(sigs) - ); - - return tx; -}; - describe("DecentHats_0_1_0", () => { let dao: SignerWithAddress; @@ -103,34 +64,27 @@ describe("DecentHats_0_1_0", () => { mockHatsAddress = await mockHats.getAddress(); keyValuePairs = await new KeyValuePairs__factory(deployer).deploy(); erc6551Registry = await new ERC6551Registry__factory(deployer).deploy(); - mockHatsAccountImplementation = await new MockHatsAccount__factory( - deployer - ).deploy(); - mockHatsAccountImplementationAddress = - await mockHatsAccountImplementation.getAddress(); + mockHatsAccountImplementation = await new MockHatsAccount__factory(deployer).deploy(); + mockHatsAccountImplementationAddress = await mockHatsAccountImplementation.getAddress(); decentHats = await new DecentHats_0_1_0__factory(deployer).deploy(); decentHatsAddress = await decentHats.getAddress(); const gnosisSafeProxyFactory = getGnosisSafeProxyFactory(); const gnosisSafeL2Singleton = getGnosisSafeL2Singleton(); - const gnosisSafeL2SingletonAddress = - await gnosisSafeL2Singleton.getAddress(); - - const createGnosisSetupCalldata = - GnosisSafeL2__factory.createInterface().encodeFunctionData("setup", [ - [dao.address], - 1, - hre.ethers.ZeroAddress, - hre.ethers.ZeroHash, - hre.ethers.ZeroAddress, - hre.ethers.ZeroAddress, - 0, - hre.ethers.ZeroAddress, - ]); - - const saltNum = BigInt( - `0x${Buffer.from(hre.ethers.randomBytes(32)).toString("hex")}` - ); + const gnosisSafeL2SingletonAddress = await gnosisSafeL2Singleton.getAddress(); + + const createGnosisSetupCalldata = GnosisSafeL2__factory.createInterface().encodeFunctionData("setup", [ + [dao.address], + 1, + hre.ethers.ZeroAddress, + hre.ethers.ZeroHash, + hre.ethers.ZeroAddress, + hre.ethers.ZeroAddress, + 0, + hre.ethers.ZeroAddress, + ]); + + const saltNum = BigInt(`0x${Buffer.from(hre.ethers.randomBytes(32)).toString("hex")}`); const predictedGnosisSafeAddress = await predictGnosisSafeAddress( createGnosisSetupCalldata, @@ -140,56 +94,42 @@ describe("DecentHats_0_1_0", () => { ); gnosisSafeAddress = predictedGnosisSafeAddress; - await gnosisSafeProxyFactory.createProxyWithNonce( - gnosisSafeL2SingletonAddress, - createGnosisSetupCalldata, - saltNum - ); + await gnosisSafeProxyFactory.createProxyWithNonce(gnosisSafeL2SingletonAddress, createGnosisSetupCalldata, saltNum); - gnosisSafe = GnosisSafeL2__factory.connect( - predictedGnosisSafeAddress, - deployer - ); + gnosisSafe = GnosisSafeL2__factory.connect(predictedGnosisSafeAddress, deployer); // Deploy MockSablierV2LockupLinear - mockSablier = await new MockSablierV2LockupLinear__factory( - deployer - ).deploy(); + mockSablier = await new MockSablierV2LockupLinear__factory(deployer).deploy(); mockSablierAddress = await mockSablier.getAddress(); - mockERC20 = await new MockERC20__factory(deployer).deploy( - "MockERC20", - "MCK" - ); + mockERC20 = await new MockERC20__factory(deployer).deploy("MockERC20", "MCK"); mockERC20Address = await mockERC20.getAddress(); await mockERC20.mint(gnosisSafeAddress, ethers.parseEther("1000000")); }); - describe("DecentHats as a Module", () => { + describe("DecentHats", () => { let enableModuleTx: ethers.ContractTransactionResponse; beforeEach(async () => { enableModuleTx = await executeSafeTransaction({ safe: gnosisSafe, to: gnosisSafeAddress, - transactionData: - GnosisSafeL2__factory.createInterface().encodeFunctionData( - "enableModule", - [decentHatsAddress] - ), + transactionData: GnosisSafeL2__factory.createInterface().encodeFunctionData("enableModule", [ + decentHatsAddress, + ]), signers: [dao], }); }); - it("Emits an ExecutionSuccess event", async () => { - await expect(enableModuleTx).to.emit(gnosisSafe, "ExecutionSuccess"); - }); + describe("Enabled as a module", () => { + it("Emits an ExecutionSuccess event", async () => { + await expect(enableModuleTx).to.emit(gnosisSafe, "ExecutionSuccess"); + }); - it("Emits an EnabledModule event", async () => { - await expect(enableModuleTx) - .to.emit(gnosisSafe, "EnabledModule") - .withArgs(decentHatsAddress); + it("Emits an EnabledModule event", async () => { + await expect(enableModuleTx).to.emit(gnosisSafe, "EnabledModule").withArgs(decentHatsAddress); + }); }); describe("Creating a new Top Hat and Tree", () => { @@ -199,56 +139,48 @@ describe("DecentHats_0_1_0", () => { createAndDeclareTreeTx = await executeSafeTransaction({ safe: gnosisSafe, to: decentHatsAddress, - transactionData: - DecentHats_0_1_0__factory.createInterface().encodeFunctionData( - "createAndDeclareTree", - [ + transactionData: DecentHats_0_1_0__factory.createInterface().encodeFunctionData("createAndDeclareTree", [ + { + hatsProtocol: mockHatsAddress, + hatsAccountImplementation: mockHatsAccountImplementationAddress, + registry: await erc6551Registry.getAddress(), + keyValuePairs: await keyValuePairs.getAddress(), + topHatDetails: "", + topHatImageURI: "", + adminHat: { + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [], + }, + hats: [ { - hatsProtocol: mockHatsAddress, - hatsAccountImplementation: - mockHatsAccountImplementationAddress, - registry: await erc6551Registry.getAddress(), - keyValuePairs: await keyValuePairs.getAddress(), - topHatDetails: "", - topHatImageURI: "", - adminHat: { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [], - }, - hats: [ - { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [], - }, - { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [], - }, - ], + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [], }, - ] - ), + { + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [], + }, + ], + }, + ]), signers: [dao], }); }); it("Emits an ExecutionSuccess event", async () => { - await expect(createAndDeclareTreeTx).to.emit( - gnosisSafe, - "ExecutionSuccess" - ); + await expect(createAndDeclareTreeTx).to.emit(gnosisSafe, "ExecutionSuccess"); }); it("Emits an ExecutionFromModuleSuccess event", async () => { @@ -270,39 +202,31 @@ describe("DecentHats_0_1_0", () => { createAndDeclareTreeTx2 = await executeSafeTransaction({ safe: gnosisSafe, to: decentHatsAddress, - transactionData: - DecentHats_0_1_0__factory.createInterface().encodeFunctionData( - "createAndDeclareTree", - [ - { - hatsProtocol: mockHatsAddress, - hatsAccountImplementation: - mockHatsAccountImplementationAddress, - registry: await erc6551Registry.getAddress(), - keyValuePairs: await keyValuePairs.getAddress(), - topHatDetails: "", - topHatImageURI: "", - adminHat: { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [], - }, - hats: [], - }, - ] - ), + transactionData: DecentHats_0_1_0__factory.createInterface().encodeFunctionData("createAndDeclareTree", [ + { + hatsProtocol: mockHatsAddress, + hatsAccountImplementation: mockHatsAccountImplementationAddress, + registry: await erc6551Registry.getAddress(), + keyValuePairs: await keyValuePairs.getAddress(), + topHatDetails: "", + topHatImageURI: "", + adminHat: { + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [], + }, + hats: [], + }, + ]), signers: [dao], }); }); it("Emits an ExecutionSuccess event", async () => { - await expect(createAndDeclareTreeTx2).to.emit( - gnosisSafe, - "ExecutionSuccess" - ); + await expect(createAndDeclareTreeTx2).to.emit(gnosisSafe, "ExecutionSuccess"); }); it("Emits an ExecutionFromModuleSuccess event", async () => { @@ -337,10 +261,7 @@ describe("DecentHats_0_1_0", () => { hatId ); - const hatAccount = MockHatsAccount__factory.connect( - hatAccountAddress, - hre.ethers.provider - ); + const hatAccount = MockHatsAccount__factory.connect(hatAccountAddress, hre.ethers.provider); return hatAccount; }; @@ -351,9 +272,7 @@ describe("DecentHats_0_1_0", () => { for (let i = 0n; i < currentCount; i++) { const topHatAccount = await getHatAccount(i); expect(await topHatAccount.tokenId()).eq(i); - expect(await topHatAccount.tokenImplementation()).eq( - mockHatsAddress - ); + expect(await topHatAccount.tokenImplementation()).eq(mockHatsAddress); } }); }); @@ -364,77 +283,68 @@ describe("DecentHats_0_1_0", () => { let currentBlockTimestamp: number; beforeEach(async () => { - currentBlockTimestamp = (await hre.ethers.provider.getBlock("latest"))! - .timestamp; + currentBlockTimestamp = (await hre.ethers.provider.getBlock("latest"))!.timestamp; createAndDeclareTreeTx = await executeSafeTransaction({ safe: gnosisSafe, to: decentHatsAddress, - transactionData: - DecentHats_0_1_0__factory.createInterface().encodeFunctionData( - "createAndDeclareTree", - [ + transactionData: DecentHats_0_1_0__factory.createInterface().encodeFunctionData("createAndDeclareTree", [ + { + hatsProtocol: mockHatsAddress, + hatsAccountImplementation: mockHatsAccountImplementationAddress, + registry: await erc6551Registry.getAddress(), + keyValuePairs: await keyValuePairs.getAddress(), + topHatDetails: "", + topHatImageURI: "", + adminHat: { + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [], + }, + hats: [ { - hatsProtocol: mockHatsAddress, - hatsAccountImplementation: - mockHatsAccountImplementationAddress, - registry: await erc6551Registry.getAddress(), - keyValuePairs: await keyValuePairs.getAddress(), - topHatDetails: "", - topHatImageURI: "", - adminHat: { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [], - }, - hats: [ - { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [ - { - sablier: mockSablierAddress, - sender: gnosisSafeAddress, - totalAmount: ethers.parseEther("100"), - asset: mockERC20Address, - cancelable: true, - transferable: false, - timestamps: { - start: currentBlockTimestamp, - cliff: 0, - end: currentBlockTimestamp + 2592000, // 30 days from now - }, - broker: { account: ethers.ZeroAddress, fee: 0 }, - }, - ], - }, + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [ { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [], + sablier: mockSablierAddress, + sender: gnosisSafeAddress, + totalAmount: ethers.parseEther("100"), + asset: mockERC20Address, + cancelable: true, + transferable: false, + timestamps: { + start: currentBlockTimestamp, + cliff: 0, + end: currentBlockTimestamp + 2592000, // 30 days from now + }, + broker: { account: ethers.ZeroAddress, fee: 0 }, }, ], }, - ] - ), + { + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [], + }, + ], + }, + ]), signers: [dao], }); }); it("Emits an ExecutionSuccess event", async () => { - await expect(createAndDeclareTreeTx).to.emit( - gnosisSafe, - "ExecutionSuccess" - ); + await expect(createAndDeclareTreeTx).to.emit(gnosisSafe, "ExecutionSuccess"); }); it("Emits an ExecutionFromModuleSuccess event", async () => { @@ -450,9 +360,7 @@ describe("DecentHats_0_1_0", () => { }); it("Creates a Sablier stream for the hat with stream parameters", async () => { - const streamCreatedEvents = await mockSablier.queryFilter( - mockSablier.filters.StreamCreated() - ); + const streamCreatedEvents = await mockSablier.queryFilter(mockSablier.filters.StreamCreated()); expect(streamCreatedEvents.length).to.equal(1); const event = streamCreatedEvents[0]; @@ -462,16 +370,12 @@ describe("DecentHats_0_1_0", () => { }); it("Does not create a Sablier stream for hats without stream parameters", async () => { - const streamCreatedEvents = await mockSablier.queryFilter( - mockSablier.filters.StreamCreated() - ); + const streamCreatedEvents = await mockSablier.queryFilter(mockSablier.filters.StreamCreated()); expect(streamCreatedEvents.length).to.equal(1); // Only one stream should be created }); it("Creates a Sablier stream with correct timestamps", async () => { - const streamCreatedEvents = await mockSablier.queryFilter( - mockSablier.filters.StreamCreated() - ); + const streamCreatedEvents = await mockSablier.queryFilter(mockSablier.filters.StreamCreated()); expect(streamCreatedEvents.length).to.equal(1); const streamId = streamCreatedEvents[0].args.streamId; @@ -486,82 +390,74 @@ describe("DecentHats_0_1_0", () => { let currentBlockTimestamp: number; beforeEach(async () => { - currentBlockTimestamp = (await hre.ethers.provider.getBlock("latest"))! - .timestamp; + currentBlockTimestamp = (await hre.ethers.provider.getBlock("latest"))!.timestamp; await executeSafeTransaction({ safe: gnosisSafe, to: decentHatsAddress, - transactionData: - DecentHats_0_1_0__factory.createInterface().encodeFunctionData( - "createAndDeclareTree", - [ + transactionData: DecentHats_0_1_0__factory.createInterface().encodeFunctionData("createAndDeclareTree", [ + { + hatsProtocol: mockHatsAddress, + hatsAccountImplementation: mockHatsAccountImplementationAddress, + registry: await erc6551Registry.getAddress(), + keyValuePairs: await keyValuePairs.getAddress(), + topHatDetails: "", + topHatImageURI: "", + adminHat: { + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [], + }, + hats: [ { - hatsProtocol: mockHatsAddress, - hatsAccountImplementation: - mockHatsAccountImplementationAddress, - registry: await erc6551Registry.getAddress(), - keyValuePairs: await keyValuePairs.getAddress(), - topHatDetails: "", - topHatImageURI: "", - adminHat: { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [], - }, - hats: [ + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [ + { + sablier: mockSablierAddress, + sender: gnosisSafeAddress, + totalAmount: ethers.parseEther("100"), + asset: mockERC20Address, + cancelable: true, + transferable: false, + timestamps: { + start: currentBlockTimestamp, + cliff: currentBlockTimestamp + 86400, // 1 day cliff + end: currentBlockTimestamp + 2592000, // 30 days from now + }, + broker: { account: ethers.ZeroAddress, fee: 0 }, + }, { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [ - { - sablier: mockSablierAddress, - sender: gnosisSafeAddress, - totalAmount: ethers.parseEther("100"), - asset: mockERC20Address, - cancelable: true, - transferable: false, - timestamps: { - start: currentBlockTimestamp, - cliff: currentBlockTimestamp + 86400, // 1 day cliff - end: currentBlockTimestamp + 2592000, // 30 days from now - }, - broker: { account: ethers.ZeroAddress, fee: 0 }, - }, - { - sablier: mockSablierAddress, - sender: gnosisSafeAddress, - totalAmount: ethers.parseEther("50"), - asset: mockERC20Address, - cancelable: false, - transferable: true, - timestamps: { - start: currentBlockTimestamp, - cliff: 0, // No cliff - end: currentBlockTimestamp + 1296000, // 15 days from now - }, - broker: { account: ethers.ZeroAddress, fee: 0 }, - }, - ], + sablier: mockSablierAddress, + sender: gnosisSafeAddress, + totalAmount: ethers.parseEther("50"), + asset: mockERC20Address, + cancelable: false, + transferable: true, + timestamps: { + start: currentBlockTimestamp, + cliff: 0, // No cliff + end: currentBlockTimestamp + 1296000, // 15 days from now + }, + broker: { account: ethers.ZeroAddress, fee: 0 }, }, ], }, - ] - ), + ], + }, + ]), signers: [dao], }); }); it("Creates multiple Sablier streams for a single hat", async () => { - const streamCreatedEvents = await mockSablier.queryFilter( - mockSablier.filters.StreamCreated() - ); + const streamCreatedEvents = await mockSablier.queryFilter(mockSablier.filters.StreamCreated()); expect(streamCreatedEvents.length).to.equal(2); const event1 = streamCreatedEvents[0]; @@ -576,39 +472,27 @@ describe("DecentHats_0_1_0", () => { }); it("Creates streams with correct parameters", async () => { - const streamCreatedEvents = await mockSablier.queryFilter( - mockSablier.filters.StreamCreated() - ); + const streamCreatedEvents = await mockSablier.queryFilter(mockSablier.filters.StreamCreated()); - const stream1 = await mockSablier.getStream( - streamCreatedEvents[0].args.streamId - ); + const stream1 = await mockSablier.getStream(streamCreatedEvents[0].args.streamId); expect(stream1.cancelable).to.be.true; expect(stream1.transferable).to.be.false; expect(stream1.endTime - stream1.startTime).to.equal(2592000); - const stream2 = await mockSablier.getStream( - streamCreatedEvents[1].args.streamId - ); + const stream2 = await mockSablier.getStream(streamCreatedEvents[1].args.streamId); expect(stream2.cancelable).to.be.false; expect(stream2.transferable).to.be.true; expect(stream2.endTime - stream2.startTime).to.equal(1296000); }); it("Creates streams with correct timestamps", async () => { - const streamCreatedEvents = await mockSablier.queryFilter( - mockSablier.filters.StreamCreated() - ); + const streamCreatedEvents = await mockSablier.queryFilter(mockSablier.filters.StreamCreated()); - const stream1 = await mockSablier.getStream( - streamCreatedEvents[0].args.streamId - ); + const stream1 = await mockSablier.getStream(streamCreatedEvents[0].args.streamId); expect(stream1.startTime).to.equal(currentBlockTimestamp); expect(stream1.endTime).to.equal(currentBlockTimestamp + 2592000); - const stream2 = await mockSablier.getStream( - streamCreatedEvents[1].args.streamId - ); + const stream2 = await mockSablier.getStream(streamCreatedEvents[1].args.streamId); expect(stream2.startTime).to.equal(currentBlockTimestamp); expect(stream2.endTime).to.equal(currentBlockTimestamp + 1296000); }); diff --git a/test/DecentSablierStreamManagement.test.ts b/test/DecentSablierStreamManagement.test.ts new file mode 100644 index 00000000..6d15648d --- /dev/null +++ b/test/DecentSablierStreamManagement.test.ts @@ -0,0 +1,302 @@ +import { + DecentHats_0_1_0, + DecentHats_0_1_0__factory, + DecentSablierStreamManagement, + DecentSablierStreamManagement__factory, + ERC6551Registry, + ERC6551Registry__factory, + GnosisSafeL2, + GnosisSafeL2__factory, + KeyValuePairs__factory, + MockERC20, + MockERC20__factory, + MockHats, + MockHats__factory, + MockHatsAccount, + MockHatsAccount__factory, + MockSablierV2LockupLinear, + MockSablierV2LockupLinear__factory, +} from "../typechain-types"; + +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { expect } from "chai"; +import { ethers } from "ethers"; +import hre from "hardhat"; + +import { executeSafeTransaction, getHatAccount, predictGnosisSafeAddress } from "./helpers"; + +import { getGnosisSafeProxyFactory, getGnosisSafeL2Singleton } from "./GlobalSafeDeployments.test"; + +describe.only("DecentSablierStreamManagement", () => { + let dao: SignerWithAddress; + let gnosisSafe: GnosisSafeL2; + + let mockHats: MockHats; + let mockHatsAddress: string; + + let decentHats: DecentHats_0_1_0; + let decentHatsAddress: string; + + let decentSablierManagement: DecentSablierStreamManagement; + let decentSablierManagementAddress: string; + + let mockHatsAccountImplementation: MockHatsAccount; + let mockHatsAccountImplementationAddress: string; + + let mockERC20: MockERC20; + let mockERC20Address: string; + + let gnosisSafeAddress: string; + + let mockSablier: MockSablierV2LockupLinear; + let mockSablierAddress: string; + + let erc6551Registry: ERC6551Registry; + + let currentBlockTimestamp: number; + + let streamId: ethers.BigNumberish; + + let enableModuleTx: ethers.ContractTransactionResponse; + let createAndDeclareTreeWithRolesAndStreamsTx: ethers.ContractTransactionResponse; + + beforeEach(async () => { + const signers = await hre.ethers.getSigners(); + const [deployer] = signers; + [, dao] = signers; + + decentSablierManagement = await new DecentSablierStreamManagement__factory(deployer).deploy(); + decentSablierManagementAddress = await decentSablierManagement.getAddress(); + + mockHatsAccountImplementation = await new MockHatsAccount__factory(deployer).deploy(); + mockHatsAccountImplementationAddress = await mockHatsAccountImplementation.getAddress(); + + decentHats = await new DecentHats_0_1_0__factory(deployer).deploy(); + decentHatsAddress = await decentHats.getAddress(); + + const gnosisSafeProxyFactory = getGnosisSafeProxyFactory(); + const gnosisSafeL2Singleton = getGnosisSafeL2Singleton(); + const gnosisSafeL2SingletonAddress = await gnosisSafeL2Singleton.getAddress(); + + const createGnosisSetupCalldata = GnosisSafeL2__factory.createInterface().encodeFunctionData("setup", [ + [dao.address], + 1, + hre.ethers.ZeroAddress, + hre.ethers.ZeroHash, + hre.ethers.ZeroAddress, + hre.ethers.ZeroAddress, + 0, + hre.ethers.ZeroAddress, + ]); + + const saltNum = BigInt(`0x${Buffer.from(hre.ethers.randomBytes(32)).toString("hex")}`); + + const predictedGnosisSafeAddress = await predictGnosisSafeAddress( + createGnosisSetupCalldata, + saltNum, + gnosisSafeL2SingletonAddress, + gnosisSafeProxyFactory + ); + gnosisSafeAddress = predictedGnosisSafeAddress; + + await gnosisSafeProxyFactory.createProxyWithNonce(gnosisSafeL2SingletonAddress, createGnosisSetupCalldata, saltNum); + + gnosisSafe = GnosisSafeL2__factory.connect(predictedGnosisSafeAddress, deployer); + + // Deploy MockSablierV2LockupLinear + mockSablier = await new MockSablierV2LockupLinear__factory(deployer).deploy(); + mockSablierAddress = await mockSablier.getAddress(); + + mockERC20 = await new MockERC20__factory(deployer).deploy("MockERC20", "MCK"); + mockERC20Address = await mockERC20.getAddress(); + + await mockERC20.mint(gnosisSafeAddress, ethers.parseEther("1000000")); + + // Set up the Safe with roles and streams + await executeSafeTransaction({ + safe: gnosisSafe, + to: gnosisSafeAddress, + transactionData: GnosisSafeL2__factory.createInterface().encodeFunctionData("enableModule", [decentHatsAddress]), + signers: [dao], + }); + + currentBlockTimestamp = (await hre.ethers.provider.getBlock("latest"))!.timestamp; + + mockHats = await new MockHats__factory(deployer).deploy(); + mockHatsAddress = await mockHats.getAddress(); + let keyValuePairs = await new KeyValuePairs__factory(deployer).deploy(); + erc6551Registry = await new ERC6551Registry__factory(deployer).deploy(); + + createAndDeclareTreeWithRolesAndStreamsTx = await executeSafeTransaction({ + safe: gnosisSafe, + to: decentHatsAddress, + transactionData: DecentHats_0_1_0__factory.createInterface().encodeFunctionData("createAndDeclareTree", [ + { + hatsProtocol: mockHatsAddress, + hatsAccountImplementation: mockHatsAccountImplementationAddress, + registry: await erc6551Registry.getAddress(), + keyValuePairs: await keyValuePairs.getAddress(), + topHatDetails: "", + topHatImageURI: "", + adminHat: { + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [], + }, + hats: [ + { + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: dao.address, + sablierParams: [ + { + sablier: mockSablierAddress, + sender: gnosisSafeAddress, + totalAmount: ethers.parseEther("100"), + asset: mockERC20Address, + cancelable: true, + transferable: false, + timestamps: { + start: currentBlockTimestamp, + cliff: 0, + end: currentBlockTimestamp + 2592000, // 30 days from now + }, + broker: { account: ethers.ZeroAddress, fee: 0 }, + }, + ], + }, + ], + }, + ]), + signers: [dao], + }); + + await expect(createAndDeclareTreeWithRolesAndStreamsTx).to.emit(gnosisSafe, "ExecutionSuccess"); + await expect(createAndDeclareTreeWithRolesAndStreamsTx).to.emit(gnosisSafe, "ExecutionFromModuleSuccess"); + + const streamCreatedEvents = await mockSablier.queryFilter(mockSablier.filters.StreamCreated()); + expect(streamCreatedEvents.length).to.equal(1); + + streamId = streamCreatedEvents[0].args.streamId; + + // Enable the module + enableModuleTx = await executeSafeTransaction({ + safe: gnosisSafe, + to: gnosisSafeAddress, + transactionData: GnosisSafeL2__factory.createInterface().encodeFunctionData("enableModule", [ + decentSablierManagementAddress, + ]), + signers: [dao], + }); + }); + + describe("Enabled as a Module", () => { + it("Emits an ExecutionSuccess event", async () => { + await expect(enableModuleTx).to.emit(gnosisSafe, "ExecutionSuccess"); + }); + + it("Emits an EnabledModule event", async () => { + await expect(enableModuleTx).to.emit(gnosisSafe, "EnabledModule").withArgs(decentSablierManagementAddress); + }); + }); + + describe("Withdrawing From Stream", () => { + let withdrawTx: ethers.ContractTransactionResponse; + + describe("When the stream has funds", () => { + beforeEach(async () => { + // No action has been taken yet on the stream. Balance should be untouched. + expect(await mockSablier.withdrawableAmountOf(streamId)).to.not.eq(0); + + // Advance time to the end of the stream + await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 2592000]); + await hre.ethers.provider.send("evm_mine", []); + + const recipientHatAccount = await getHatAccount( + 2n, + erc6551Registry, + mockHatsAccountImplementationAddress, + mockHatsAddress, + decentHatsAddress + ); + + withdrawTx = await executeSafeTransaction({ + safe: gnosisSafe, + to: decentSablierManagementAddress, + transactionData: DecentSablierStreamManagement__factory.createInterface().encodeFunctionData( + "withdrawMaxFromStream", + [mockSablierAddress, streamId, await recipientHatAccount.getAddress()] + ), + signers: [dao], + }); + + await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 2692000]); + await hre.ethers.provider.send("evm_mine", []); + }); + + it("Emits an ExecutionSuccess event", async () => { + await expect(withdrawTx).to.emit(gnosisSafe, "ExecutionSuccess"); + }); + + it("Emits an ExecutionFromModuleSuccess event", async () => { + await expect(withdrawTx) + .to.emit(gnosisSafe, "ExecutionFromModuleSuccess") + .withArgs(decentSablierManagementAddress); + }); + + it("Withdraws the maximum amount from the stream", async () => { + expect(await mockSablier.withdrawableAmountOf(streamId)).to.equal(0); + }); + }); + + describe("When the stream has no funds", () => { + beforeEach(async () => { + // Advance time to the end of the stream + await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 2592000]); + await hre.ethers.provider.send("evm_mine", []); + + const recipientHatAccount = await getHatAccount( + 2n, + erc6551Registry, + mockHatsAccountImplementationAddress, + mockHatsAddress, + decentHatsAddress + ); + + // The recipient withdraws the full amount + await MockSablierV2LockupLinear__factory.connect(mockSablierAddress, dao).withdrawMax( + streamId, + await recipientHatAccount.getAddress() + ); + expect(await mockSablier.withdrawableAmountOf(streamId)).to.equal(0); + + withdrawTx = await executeSafeTransaction({ + safe: gnosisSafe, + to: decentSablierManagementAddress, + transactionData: DecentSablierStreamManagement__factory.createInterface().encodeFunctionData( + "withdrawMaxFromStream", + [mockSablierAddress, streamId, await recipientHatAccount.getAddress()] + ), + signers: [dao], + }); + }); + + it("Emits an ExecutionSuccess event", async () => { + await expect(withdrawTx).to.emit(gnosisSafe, "ExecutionSuccess"); + }); + + it("Emits an ExecutionFromModuleSuccess event", async () => { + await expect(withdrawTx).to.not.emit(gnosisSafe, "ExecutionFromModuleSuccess"); + }); + + it("Does not revert", async () => { + expect(withdrawTx).to.not.reverted; + }); + }); + }); +}); diff --git a/test/DecentSablier_0_1_0.test.ts b/test/DecentSablier_0_1_0.test.ts deleted file mode 100644 index 935f46f5..00000000 --- a/test/DecentSablier_0_1_0.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -// import { -// GnosisSafeL2, -// GnosisSafeL2__factory, -// DecentSablier_0_1_0__factory, -// DecentSablier_0_1_0, -// } from "../typechain-types"; - -// import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; -// import { expect } from "chai"; -// import { ethers } from "ethers"; -// import hre from "hardhat"; - -// import { -// getGnosisSafeL2Singleton, -// getGnosisSafeProxyFactory, -// } from "./GlobalSafeDeployments.test"; -// import { -// buildSafeTransaction, -// buildSignatureBytes, -// predictGnosisSafeAddress, -// safeSignTypedData, -// } from "./helpers"; - -// import { MockSablier__factory } from "../typechain-types"; - -// async function executeSafeTransaction({ -// safe, -// to, -// value, -// data, -// operation, -// signers, -// }: { -// safe: GnosisSafeL2; -// to: string; -// value?: bigint; -// data?: string; -// operation?: number; -// signers: SignerWithAddress[]; -// }) { -// const safeTransactionData = { -// to, -// value: value || 0n, -// data: data || "0x", -// operation: operation || 0, -// // Add the missing 'nonce' property -// nonce: await safe.nonce(), -// }; -// const safeTransaction = await buildSafeTransaction(safeTransactionData); -// const senderSignature = await safeSignTypedData( -// signers[0], -// safe, -// safeTransaction -// ); -// const signatureBytes = buildSignatureBytes([senderSignature]); -// // Change 'executeTransaction' to 'execTransaction' -// return safe.execTransaction(safeTransaction, signatureBytes); -// } - -// describe("DecentSablier", () => { -// let dao: SignerWithAddress; -// let gnosisSafe: GnosisSafeL2; -// let decentSablier: DecentSablier_0_1_0; -// let decentSablierAddress: string; -// let gnosisSafeAddress: string; - -// let mockSablier: MockSablier; - -// beforeEach(async () => { -// // ... (setup code similar to DecentHats.test.ts) -// // Deploy MockSablier -// const MockSablier = await ethers.getContractFactory("MockSablier"); -// mockSablier = await MockSablier.deploy(); -// await mockSablier.deployed(); -// }); - -// describe("DecentSablier as a Module", () => { -// let enableModuleTx: ethers.ContractTransactionResponse; - -// beforeEach(async () => { -// // ... (enable module code similar to DecentHats.test.ts) -// }); - -// it("Emits an ExecutionSuccess event", async () => { -// await expect(enableModuleTx).to.emit(gnosisSafe, "ExecutionSuccess"); -// }); - -// it("Emits an EnabledModule event", async () => { -// await expect(enableModuleTx) -// .to.emit(gnosisSafe, "EnabledModule") -// .withArgs(decentSablierAddress); -// }); - -// describe("Processing Sablier Streams", () => { -// let processSablierStreamsTx: ethers.ContractTransactionResponse; - -// beforeEach(async () => { -// // Set up mock stream balances -// await mockSablier.setStreamBalance(1, ethers.utils.parseEther("100")); -// await mockSablier.setStreamBalance(2, ethers.utils.parseEther("200")); -// await mockSablier.setStreamBalance(3, ethers.utils.parseEther("300")); - -// processSablierStreamsTx = await executeSafeTransaction({ -// safe: gnosisSafe, -// to: decentSablierAddress, -// data: DecentSablier_0_1_0__factory.createInterface().encodeFunctionData( -// "processSablierStreams", -// [ -// mockSablier.address, -// [{ streamId: 1 }, { streamId: 2 }, { streamId: 3 }], -// ] -// ), -// signers: [dao], -// }); -// }); - -// it("Emits an ExecutionSuccess event", async () => { -// await expect(processSablierStreamsTx).to.emit( -// gnosisSafe, -// "ExecutionSuccess" -// ); -// }); - -// it("Emits an ExecutionFromModuleSuccess event", async () => { -// await expect(processSablierStreamsTx) -// .to.emit(gnosisSafe, "ExecutionFromModuleSuccess") -// .withArgs(decentSablierAddress); -// }); - -// it("Withdraws from streams correctly", async () => { -// expect(await mockSablier.getWithdrawnAmount(1)).to.equal( -// ethers.utils.parseEther("100") -// ); -// expect(await mockSablier.getWithdrawnAmount(2)).to.equal( -// ethers.utils.parseEther("200") -// ); -// expect(await mockSablier.getWithdrawnAmount(3)).to.equal( -// ethers.utils.parseEther("300") -// ); -// }); -// }); -// }); -// }); diff --git a/test/helpers.ts b/test/helpers.ts index 54ef666f..0e0626ff 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,10 +1,15 @@ -import { ethers } from "ethers"; +import { ethers, solidityPackedKeccak256 } from "ethers"; import { + ERC6551Registry, + GnosisSafeL2, GnosisSafeProxyFactory, IAzorius, MockContract__factory, + MockHatsAccount__factory, } from "../typechain-types"; import { getMockContract } from "./GlobalSafeDeployments.test"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import hre from "hardhat"; export interface MetaTransaction { to: string; @@ -39,10 +44,7 @@ export const predictGnosisSafeAddress = async ( ["bytes", "uint256"], [ethers.solidityPackedKeccak256(["bytes"], [calldata]), saltNum] ), - ethers.solidityPackedKeccak256( - ["bytes", "uint256"], - [await gnosisFactory.proxyCreationCode(), singleton] - ) + ethers.solidityPackedKeccak256(["bytes", "uint256"], [await gnosisFactory.proxyCreationCode(), singleton]) ); }; @@ -53,21 +55,14 @@ export const calculateProxyAddress = async ( saltNonce: string ): Promise => { const masterCopyAddress = masterCopy.toLowerCase().replace(/^0x/, ""); - const byteCode = - "0x602d8060093d393df3363d3d373d3d3d363d73" + - masterCopyAddress + - "5af43d82803e903d91602b57fd5bf3"; + const byteCode = "0x602d8060093d393df3363d3d373d3d3d363d73" + masterCopyAddress + "5af43d82803e903d91602b57fd5bf3"; const salt = ethers.solidityPackedKeccak256( ["bytes32", "uint256"], [ethers.solidityPackedKeccak256(["bytes"], [initData]), saltNonce] ); - return ethers.getCreate2Address( - await factory.getAddress(), - salt, - ethers.keccak256(byteCode) - ); + return ethers.getCreate2Address(await factory.getAddress(), salt, ethers.keccak256(byteCode)); }; export const safeSignTypedData = async ( @@ -105,9 +100,7 @@ export const safeSignTypedData = async ( }; export const buildSignatureBytes = (signatures: SafeSignature[]): string => { - signatures.sort((left, right) => - left.signer.toLowerCase().localeCompare(right.signer.toLowerCase()) - ); + signatures.sort((left, right) => left.signer.toLowerCase().localeCompare(right.signer.toLowerCase())); let signatureBytes = "0x"; for (const sig of signatures) { signatureBytes += sig.data.slice(2); @@ -179,28 +172,85 @@ export const encodeMultiSend = (txs: MetaTransaction[]): string => { ); }; -export const mockTransaction = - async (): Promise => { - return { - to: await getMockContract().getAddress(), - value: 0n, - // eslint-disable-next-line camelcase - data: MockContract__factory.createInterface().encodeFunctionData( - "doSomething" - ), - operation: 0, - }; +export const mockTransaction = async (): Promise => { + return { + to: await getMockContract().getAddress(), + value: 0n, + // eslint-disable-next-line camelcase + data: MockContract__factory.createInterface().encodeFunctionData("doSomething"), + operation: 0, }; +}; -export const mockRevertTransaction = - async (): Promise => { - return { - to: await getMockContract().getAddress(), - value: 0n, - // eslint-disable-next-line camelcase - data: MockContract__factory.createInterface().encodeFunctionData( - "revertSomething" - ), - operation: 0, - }; +export const mockRevertTransaction = async (): Promise => { + return { + to: await getMockContract().getAddress(), + value: 0n, + // eslint-disable-next-line camelcase + data: MockContract__factory.createInterface().encodeFunctionData("revertSomething"), + operation: 0, }; +}; + +export const executeSafeTransaction = async ({ + safe, + to, + transactionData, + signers, +}: { + safe: GnosisSafeL2; + to: string; + transactionData: string; + signers: SignerWithAddress[]; +}) => { + const safeTx = buildSafeTransaction({ + to, + data: transactionData, + nonce: await safe.nonce(), + }); + console.log("safeIx"); + + const sigs = await Promise.all(signers.map(async (signer) => await safeSignTypedData(signer, safe, safeTx))); + + const tx = await safe.execTransaction( + safeTx.to, + safeTx.value, + safeTx.data, + safeTx.operation, + safeTx.safeTxGas, + safeTx.baseGas, + safeTx.gasPrice, + safeTx.gasToken, + safeTx.refundReceiver, + buildSignatureBytes(sigs) + ); + + console.log("done?"); + + return tx; +}; + +export const getHatAccount = async ( + hatId: bigint, + erc6551RegistryImplementation: ERC6551Registry, + mockHatsAccountImplementationAddress: string, + mockHatsAddress: string, + decentHatsAddress: string +) => { + const salt = solidityPackedKeccak256( + ["string", "uint256", "address"], + ["DecentHats_0_1_0", await hre.getChainId(), decentHatsAddress] + ); + + const hatAccountAddress = await erc6551RegistryImplementation.account( + mockHatsAccountImplementationAddress, + salt, + await hre.getChainId(), + mockHatsAddress, + hatId + ); + + const hatAccount = MockHatsAccount__factory.connect(hatAccountAddress, hre.ethers.provider); + + return hatAccount; +}; From 01bdfd15f0fa0244feba22961f9f784cbf70c9cc Mon Sep 17 00:00:00 2001 From: Kellar Date: Tue, 8 Oct 2024 15:18:34 +0100 Subject: [PATCH 08/21] Add cancel stream tests (with 1 bug) --- contracts/DecentSablierStreamManagement.sol | 5 +- .../interfaces/sablier/ISablierV2Lockup.sol | 5 + contracts/interfaces/sablier/LockupLinear.sol | 12 ++ contracts/mocks/MockSablierV2LockupLinear.sol | 27 ++-- test/DecentSablierStreamManagement.test.ts | 119 +++++++++++++++++- test/helpers.ts | 3 - 6 files changed, 147 insertions(+), 24 deletions(-) diff --git a/contracts/DecentSablierStreamManagement.sol b/contracts/DecentSablierStreamManagement.sol index 7528b255..c3157ee8 100644 --- a/contracts/DecentSablierStreamManagement.sol +++ b/contracts/DecentSablierStreamManagement.sol @@ -37,7 +37,10 @@ contract DecentSablierStreamManagement { uint256 streamId ) public { // Check if the stream is still active - if (!sablier.isCancelable(streamId)) { + if ( + !sablier.isCancelable(streamId) || + sablier.getStream(streamId).endTime < block.timestamp + ) { return; } diff --git a/contracts/interfaces/sablier/ISablierV2Lockup.sol b/contracts/interfaces/sablier/ISablierV2Lockup.sol index 124e767f..5a55c512 100644 --- a/contracts/interfaces/sablier/ISablierV2Lockup.sol +++ b/contracts/interfaces/sablier/ISablierV2Lockup.sol @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; +import {LockupLinear} from "../sablier/LockupLinear.sol"; interface ISablierV2Lockup { function withdrawableAmountOf( @@ -13,5 +14,9 @@ interface ISablierV2Lockup { address to ) external returns (uint128 withdrawnAmount); + function getStream( + uint256 streamId + ) external view returns (LockupLinear.Stream memory); + function cancel(uint256 streamId) external; } diff --git a/contracts/interfaces/sablier/LockupLinear.sol b/contracts/interfaces/sablier/LockupLinear.sol index 0225d060..3b7eb887 100644 --- a/contracts/interfaces/sablier/LockupLinear.sol +++ b/contracts/interfaces/sablier/LockupLinear.sol @@ -25,4 +25,16 @@ library LockupLinear { address account; uint256 fee; } + + struct Stream { + address sender; + address recipient; + uint128 totalAmount; + address asset; + bool cancelable; + bool transferable; + uint40 startTime; + uint40 cliffTime; + uint40 endTime; + } } diff --git a/contracts/mocks/MockSablierV2LockupLinear.sol b/contracts/mocks/MockSablierV2LockupLinear.sol index c24b136f..ca8e6a52 100644 --- a/contracts/mocks/MockSablierV2LockupLinear.sol +++ b/contracts/mocks/MockSablierV2LockupLinear.sol @@ -6,20 +6,7 @@ import "../interfaces/sablier/ISablierV2LockupLinear.sol"; import {LockupLinear} from "../interfaces/sablier/LockupLinear.sol"; contract MockSablierV2LockupLinear is ISablierV2LockupLinear { - // Define the Stream struct here - struct Stream { - address sender; - address recipient; - uint128 totalAmount; - address asset; - bool cancelable; - bool transferable; - uint40 startTime; - uint40 cliffTime; - uint40 endTime; - } - - mapping(uint256 => Stream) public streams; + mapping(uint256 => LockupLinear.Stream) public streams; uint256 public nextStreamId = 1; // Add this event declaration at the contract level @@ -49,7 +36,7 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { ); streamId = nextStreamId++; - streams[streamId] = Stream({ + streams[streamId] = LockupLinear.Stream({ sender: params.sender, recipient: params.recipient, totalAmount: params.totalAmount, @@ -78,14 +65,16 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { return streamId; } - function getStream(uint256 streamId) external view returns (Stream memory) { + function getStream( + uint256 streamId + ) external view returns (LockupLinear.Stream memory) { return streams[streamId]; } function withdrawableAmountOf( uint256 streamId ) public view returns (uint128) { - Stream memory stream = streams[streamId]; + LockupLinear.Stream memory stream = streams[streamId]; if (block.timestamp <= stream.startTime) { return 0; } @@ -104,7 +93,7 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { address to ) external returns (uint128 withdrawnAmount) { withdrawnAmount = withdrawableAmountOf(streamId); - Stream storage stream = streams[streamId]; + LockupLinear.Stream storage stream = streams[streamId]; require(to == stream.recipient, "Only recipient can withdraw"); require( @@ -117,7 +106,7 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { } function cancel(uint256 streamId) external { - Stream memory stream = streams[streamId]; + LockupLinear.Stream memory stream = streams[streamId]; require(stream.cancelable, "Stream is not cancelable"); require(msg.sender == stream.sender, "Only sender can cancel"); diff --git a/test/DecentSablierStreamManagement.test.ts b/test/DecentSablierStreamManagement.test.ts index 6d15648d..b21997ee 100644 --- a/test/DecentSablierStreamManagement.test.ts +++ b/test/DecentSablierStreamManagement.test.ts @@ -290,7 +290,7 @@ describe.only("DecentSablierStreamManagement", () => { await expect(withdrawTx).to.emit(gnosisSafe, "ExecutionSuccess"); }); - it("Emits an ExecutionFromModuleSuccess event", async () => { + it("Does not emit an ExecutionFromModuleSuccess event", async () => { await expect(withdrawTx).to.not.emit(gnosisSafe, "ExecutionFromModuleSuccess"); }); @@ -299,4 +299,121 @@ describe.only("DecentSablierStreamManagement", () => { }); }); }); + + describe("Cancelling From Stream", () => { + let cancelTx: ethers.ContractTransactionResponse; + + describe("When the stream is active", () => { + beforeEach(async () => { + // Advance time to before the end of the stream + await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 60000]); // 1 minute from now + await hre.ethers.provider.send("evm_mine", []); + + cancelTx = await executeSafeTransaction({ + safe: gnosisSafe, + to: decentSablierManagementAddress, + transactionData: DecentSablierStreamManagement__factory.createInterface().encodeFunctionData("cancelStream", [ + mockSablierAddress, + streamId, + ]), + signers: [dao], + }); + }); + + it("Emits an ExecutionSuccess event", async () => { + await expect(cancelTx).to.emit(gnosisSafe, "ExecutionSuccess"); + }); + + it("Emits an ExecutionFromModuleSuccess event", async () => { + await expect(cancelTx) + .to.emit(gnosisSafe, "ExecutionFromModuleSuccess") + .withArgs(decentSablierManagementAddress); + }); + + it("Cancels the stream", async () => { + expect((await mockSablier.getStream(streamId)).cancelable).to.equal(false); + }); + }); + + describe("When the stream has expired", () => { + beforeEach(async () => { + // Advance time to the end of the stream + await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 2592000 + 60000]); // 30 days from now + 1 minute + await hre.ethers.provider.send("evm_mine", []); + + cancelTx = await executeSafeTransaction({ + safe: gnosisSafe, + to: decentSablierManagementAddress, + transactionData: DecentSablierStreamManagement__factory.createInterface().encodeFunctionData("cancelStream", [ + mockSablierAddress, + streamId, + ]), + signers: [dao], + }); + }); + + it("Emits an ExecutionSuccess event", async () => { + await expect(cancelTx).to.emit(gnosisSafe, "ExecutionSuccess"); + }); + + it("Does not emit an ExecutionFromModuleSuccess event", async () => { + await expect(cancelTx).to.not.emit(gnosisSafe, "ExecutionFromModuleSuccess"); + }); + + it("Does not revert", async () => { + expect(cancelTx).to.not.reverted; + }); + }); + + describe("When the stream has been previously cancelled", () => { + beforeEach(async () => { + // Advance time to before the end of the stream + await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 120000]); // 2 minutes from now + await hre.ethers.provider.send("evm_mine", []); + + await MockSablierV2LockupLinear__factory.connect(mockSablierAddress, dao).cancel(streamId); + + const stream = await mockSablier.getStream(streamId); + + expect(stream.startTime).to.equal(currentBlockTimestamp); + expect(stream.endTime).to.equal(currentBlockTimestamp + 2592000); + + // The safe cancels the stream + await executeSafeTransaction({ + safe: gnosisSafe, + to: mockSablierAddress, + transactionData: MockSablierV2LockupLinear__factory.createInterface().encodeFunctionData("cancel", [ + streamId, + ]), + signers: [dao], + }); + + // advance 1 minute + await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 60000]); + await hre.ethers.provider.send("evm_mine", []); + + cancelTx = await executeSafeTransaction({ + safe: gnosisSafe, + to: decentSablierManagementAddress, + transactionData: DecentSablierStreamManagement__factory.createInterface().encodeFunctionData("cancelStream", [ + mockSablierAddress, + streamId, + ]), + signers: [dao], + }); + }); + + it("Emits an ExecutionSuccess event", async () => { + await expect(cancelTx).to.emit(gnosisSafe, "ExecutionSuccess"); + }); + + it("Does not emit an ExecutionFromModuleSuccess event", async () => { + await expect(cancelTx).to.not.emit(gnosisSafe, "ExecutionFromModuleSuccess"); + }); + + it("Does not revert", async () => { + expect(cancelTx).to.not.reverted; + }); + }); + }); }); diff --git a/test/helpers.ts b/test/helpers.ts index 0e0626ff..5ebf999b 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -208,7 +208,6 @@ export const executeSafeTransaction = async ({ data: transactionData, nonce: await safe.nonce(), }); - console.log("safeIx"); const sigs = await Promise.all(signers.map(async (signer) => await safeSignTypedData(signer, safe, safeTx))); @@ -225,8 +224,6 @@ export const executeSafeTransaction = async ({ buildSignatureBytes(sigs) ); - console.log("done?"); - return tx; }; From 868d785c6d3b4e71e08eb748b3ee5ffef1bb0b7d Mon Sep 17 00:00:00 2001 From: Kellar Date: Tue, 8 Oct 2024 15:42:18 +0100 Subject: [PATCH 09/21] bug fixes --- contracts/DecentSablierStreamManagement.sol | 2 +- test/DecentSablierStreamManagement.test.ts | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/contracts/DecentSablierStreamManagement.sol b/contracts/DecentSablierStreamManagement.sol index c3157ee8..dfcb9118 100644 --- a/contracts/DecentSablierStreamManagement.sol +++ b/contracts/DecentSablierStreamManagement.sol @@ -39,7 +39,7 @@ contract DecentSablierStreamManagement { // Check if the stream is still active if ( !sablier.isCancelable(streamId) || - sablier.getStream(streamId).endTime < block.timestamp + sablier.getStream(streamId).endTime <= block.timestamp ) { return; } diff --git a/test/DecentSablierStreamManagement.test.ts b/test/DecentSablierStreamManagement.test.ts index b21997ee..28476655 100644 --- a/test/DecentSablierStreamManagement.test.ts +++ b/test/DecentSablierStreamManagement.test.ts @@ -371,12 +371,8 @@ describe.only("DecentSablierStreamManagement", () => { await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 120000]); // 2 minutes from now await hre.ethers.provider.send("evm_mine", []); - await MockSablierV2LockupLinear__factory.connect(mockSablierAddress, dao).cancel(streamId); - const stream = await mockSablier.getStream(streamId); - - expect(stream.startTime).to.equal(currentBlockTimestamp); - expect(stream.endTime).to.equal(currentBlockTimestamp + 2592000); + expect(stream.endTime).to.be.greaterThan(currentBlockTimestamp); // The safe cancels the stream await executeSafeTransaction({ @@ -388,8 +384,7 @@ describe.only("DecentSablierStreamManagement", () => { signers: [dao], }); - // advance 1 minute - await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 60000]); + await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 240000]); // 4 minutes from now await hre.ethers.provider.send("evm_mine", []); cancelTx = await executeSafeTransaction({ From a9b8ba904ca8a3910ad4d5ba5d274c2f84d940b9 Mon Sep 17 00:00:00 2001 From: Kellar Date: Tue, 8 Oct 2024 18:31:26 +0100 Subject: [PATCH 10/21] Fix tests --- contracts/DecentSablierStreamManagement.sol | 15 ++++++-- contracts/mocks/MockHatsAccount.sol | 11 ++++++ contracts/mocks/MockSablierV2LockupLinear.sol | 8 +++- test/DecentSablierStreamManagement.test.ts | 37 ++++++++++++------- test/helpers.ts | 5 ++- 5 files changed, 54 insertions(+), 22 deletions(-) diff --git a/contracts/DecentSablierStreamManagement.sol b/contracts/DecentSablierStreamManagement.sol index dfcb9118..e25b302a 100644 --- a/contracts/DecentSablierStreamManagement.sol +++ b/contracts/DecentSablierStreamManagement.sol @@ -10,6 +10,7 @@ contract DecentSablierStreamManagement { function withdrawMaxFromStream( ISablierV2LockupLinear sablier, + address recipientHatAccount, uint256 streamId, address to ) public { @@ -21,12 +22,18 @@ contract DecentSablierStreamManagement { // Proxy the Sablier withdrawMax call through IAvatar (Safe) IAvatar(msg.sender).execTransactionFromModule( - address(sablier), + recipientHatAccount, 0, abi.encodeWithSignature( - "withdrawMax(uint256,address)", - streamId, - to + "execute(address,uint256,bytes,uint8)", + address(sablier), + 0, + abi.encodeWithSignature( + "withdrawMax(uint256,address)", + streamId, + to + ), + 0 ), Enum.Operation.Call ); diff --git a/contracts/mocks/MockHatsAccount.sol b/contracts/mocks/MockHatsAccount.sol index 22fd1936..f60fdb0e 100644 --- a/contracts/mocks/MockHatsAccount.sol +++ b/contracts/mocks/MockHatsAccount.sol @@ -22,4 +22,15 @@ contract MockHatsAccount { } return abi.decode(footer, (address)); } + + function execute( + address to, + uint256 value, + bytes calldata data, + uint8 + ) external returns (bytes memory) { + (bool success, bytes memory result) = to.call{value: value}(data); + require(success, "HatsAccount: execution failed"); + return result; + } } diff --git a/contracts/mocks/MockSablierV2LockupLinear.sol b/contracts/mocks/MockSablierV2LockupLinear.sol index ca8e6a52..e67fadd6 100644 --- a/contracts/mocks/MockSablierV2LockupLinear.sol +++ b/contracts/mocks/MockSablierV2LockupLinear.sol @@ -95,14 +95,17 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { withdrawnAmount = withdrawableAmountOf(streamId); LockupLinear.Stream storage stream = streams[streamId]; - require(to == stream.recipient, "Only recipient can withdraw"); + require( + msg.sender == stream.recipient, + "Only recipient can call withdraw" + ); require( withdrawnAmount <= withdrawableAmountOf(streamId), "Insufficient withdrawable amount" ); stream.totalAmount -= withdrawnAmount; - IERC20(stream.asset).transfer(stream.recipient, withdrawnAmount); + IERC20(stream.asset).transfer(to, withdrawnAmount); } function cancel(uint256 streamId) external { @@ -113,6 +116,7 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { uint128 withdrawableAmount = withdrawableAmountOf(streamId); uint128 refundAmount = stream.totalAmount - withdrawableAmount; + // TODO: instead of deleting, update state similar to how the real Sablier contract does delete streams[streamId]; if (withdrawableAmount > 0) { diff --git a/test/DecentSablierStreamManagement.test.ts b/test/DecentSablierStreamManagement.test.ts index 28476655..97757089 100644 --- a/test/DecentSablierStreamManagement.test.ts +++ b/test/DecentSablierStreamManagement.test.ts @@ -27,7 +27,7 @@ import { executeSafeTransaction, getHatAccount, predictGnosisSafeAddress } from import { getGnosisSafeProxyFactory, getGnosisSafeL2Singleton } from "./GlobalSafeDeployments.test"; -describe.only("DecentSablierStreamManagement", () => { +describe("DecentSablierStreamManagement", () => { let dao: SignerWithAddress; let gnosisSafe: GnosisSafeL2; @@ -59,6 +59,7 @@ describe.only("DecentSablierStreamManagement", () => { let enableModuleTx: ethers.ContractTransactionResponse; let createAndDeclareTreeWithRolesAndStreamsTx: ethers.ContractTransactionResponse; + const streamFundsMax = ethers.parseEther("100"); beforeEach(async () => { const signers = await hre.ethers.getSigners(); @@ -157,7 +158,7 @@ describe.only("DecentSablierStreamManagement", () => { { sablier: mockSablierAddress, sender: gnosisSafeAddress, - totalAmount: ethers.parseEther("100"), + totalAmount: streamFundsMax, asset: mockERC20Address, cancelable: true, transferable: false, @@ -210,19 +211,20 @@ describe.only("DecentSablierStreamManagement", () => { describe("When the stream has funds", () => { beforeEach(async () => { - // No action has been taken yet on the stream. Balance should be untouched. - expect(await mockSablier.withdrawableAmountOf(streamId)).to.not.eq(0); - // Advance time to the end of the stream await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 2592000]); await hre.ethers.provider.send("evm_mine", []); + // No action has been taken yet on the stream. Balance should be untouched. + expect(await mockSablier.withdrawableAmountOf(streamId)).to.eq(streamFundsMax); + const recipientHatAccount = await getHatAccount( 2n, erc6551Registry, mockHatsAccountImplementationAddress, mockHatsAddress, - decentHatsAddress + decentHatsAddress, + dao ); withdrawTx = await executeSafeTransaction({ @@ -230,13 +232,12 @@ describe.only("DecentSablierStreamManagement", () => { to: decentSablierManagementAddress, transactionData: DecentSablierStreamManagement__factory.createInterface().encodeFunctionData( "withdrawMaxFromStream", - [mockSablierAddress, streamId, await recipientHatAccount.getAddress()] + [mockSablierAddress, await recipientHatAccount.getAddress(), streamId, dao.address] ), signers: [dao], }); - await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 2692000]); - await hre.ethers.provider.send("evm_mine", []); + expect(withdrawTx).to.not.reverted; }); it("Emits an ExecutionSuccess event", async () => { @@ -265,14 +266,21 @@ describe.only("DecentSablierStreamManagement", () => { erc6551Registry, mockHatsAccountImplementationAddress, mockHatsAddress, - decentHatsAddress + decentHatsAddress, + dao ); // The recipient withdraws the full amount - await MockSablierV2LockupLinear__factory.connect(mockSablierAddress, dao).withdrawMax( - streamId, - await recipientHatAccount.getAddress() + await recipientHatAccount.execute( + mockSablierAddress, + 0n, + MockSablierV2LockupLinear__factory.createInterface().encodeFunctionData("withdrawMax", [ + streamId, + dao.address, + ]), + 0 ); + expect(await mockSablier.withdrawableAmountOf(streamId)).to.equal(0); withdrawTx = await executeSafeTransaction({ @@ -280,7 +288,7 @@ describe.only("DecentSablierStreamManagement", () => { to: decentSablierManagementAddress, transactionData: DecentSablierStreamManagement__factory.createInterface().encodeFunctionData( "withdrawMaxFromStream", - [mockSablierAddress, streamId, await recipientHatAccount.getAddress()] + [mockSablierAddress, await recipientHatAccount.getAddress(), streamId, dao.address] ), signers: [dao], }); @@ -331,6 +339,7 @@ describe.only("DecentSablierStreamManagement", () => { }); it("Cancels the stream", async () => { + // TODO: use stream.statusOf instead expect((await mockSablier.getStream(streamId)).cancelable).to.equal(false); }); }); diff --git a/test/helpers.ts b/test/helpers.ts index 5ebf999b..6c8b1a9a 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -232,7 +232,8 @@ export const getHatAccount = async ( erc6551RegistryImplementation: ERC6551Registry, mockHatsAccountImplementationAddress: string, mockHatsAddress: string, - decentHatsAddress: string + decentHatsAddress: string, + signer: ethers.Signer ) => { const salt = solidityPackedKeccak256( ["string", "uint256", "address"], @@ -247,7 +248,7 @@ export const getHatAccount = async ( hatId ); - const hatAccount = MockHatsAccount__factory.connect(hatAccountAddress, hre.ethers.provider); + const hatAccount = MockHatsAccount__factory.connect(hatAccountAddress, signer); return hatAccount; }; From fac23781829cad66762c6cb168bd554c0d2124e2 Mon Sep 17 00:00:00 2001 From: Kellar Date: Tue, 8 Oct 2024 19:18:42 +0100 Subject: [PATCH 11/21] Cleanup, use more accurate code in sablier mock --- contracts/DecentSablierStreamManagement.sol | 8 +++--- .../interfaces/sablier/ISablierV2Lockup.sol | 4 +++ contracts/interfaces/sablier/LockupLinear.sol | 27 ++++++++++++++----- contracts/mocks/MockSablierV2LockupLinear.sol | 26 ++++++++++++++++-- test/DecentSablierStreamManagement.test.ts | 3 +-- 5 files changed, 55 insertions(+), 13 deletions(-) diff --git a/contracts/DecentSablierStreamManagement.sol b/contracts/DecentSablierStreamManagement.sol index e25b302a..1444dad8 100644 --- a/contracts/DecentSablierStreamManagement.sol +++ b/contracts/DecentSablierStreamManagement.sol @@ -4,6 +4,7 @@ pragma solidity =0.8.19; import {Enum} from "@gnosis.pm/safe-contracts/contracts/common/Enum.sol"; import {IAvatar} from "@gnosis.pm/zodiac/contracts/interfaces/IAvatar.sol"; import {ISablierV2LockupLinear} from "./interfaces/sablier/ISablierV2LockupLinear.sol"; +import {LockupLinear} from "./interfaces/sablier/LockupLinear.sol"; contract DecentSablierStreamManagement { string public constant NAME = "DecentSablierStreamManagement"; @@ -43,10 +44,11 @@ contract DecentSablierStreamManagement { ISablierV2LockupLinear sablier, uint256 streamId ) public { - // Check if the stream is still active + // Check if the stream can be cancelled + LockupLinear.Status streamStatus = sablier.statusOf(streamId); if ( - !sablier.isCancelable(streamId) || - sablier.getStream(streamId).endTime <= block.timestamp + streamStatus != LockupLinear.Status.PENDING && + streamStatus != LockupLinear.Status.STREAMING ) { return; } diff --git a/contracts/interfaces/sablier/ISablierV2Lockup.sol b/contracts/interfaces/sablier/ISablierV2Lockup.sol index 5a55c512..98c3e706 100644 --- a/contracts/interfaces/sablier/ISablierV2Lockup.sol +++ b/contracts/interfaces/sablier/ISablierV2Lockup.sol @@ -19,4 +19,8 @@ interface ISablierV2Lockup { ) external view returns (LockupLinear.Stream memory); function cancel(uint256 streamId) external; + + function statusOf( + uint256 streamId + ) external view returns (LockupLinear.Status status); } diff --git a/contracts/interfaces/sablier/LockupLinear.sol b/contracts/interfaces/sablier/LockupLinear.sol index 3b7eb887..97df9877 100644 --- a/contracts/interfaces/sablier/LockupLinear.sol +++ b/contracts/interfaces/sablier/LockupLinear.sol @@ -28,13 +28,28 @@ library LockupLinear { struct Stream { address sender; - address recipient; - uint128 totalAmount; - address asset; - bool cancelable; - bool transferable; uint40 startTime; - uint40 cliffTime; uint40 endTime; + uint40 cliffTime; + bool cancelable; + bool wasCanceled; + address asset; + bool transferable; + uint128 totalAmount; + address recipient; + } + + /// @notice Enum representing the different statuses of a stream. + /// @custom:value0 PENDING Stream created but not started; assets are in a pending state. + /// @custom:value1 STREAMING Active stream where assets are currently being streamed. + /// @custom:value2 SETTLED All assets have been streamed; recipient is due to withdraw them. + /// @custom:value3 CANCELED Canceled stream; remaining assets await recipient's withdrawal. + /// @custom:value4 DEPLETED Depleted stream; all assets have been withdrawn and/or refunded. + enum Status { + PENDING, + STREAMING, + SETTLED, + CANCELED, + DEPLETED } } diff --git a/contracts/mocks/MockSablierV2LockupLinear.sol b/contracts/mocks/MockSablierV2LockupLinear.sol index e67fadd6..174c4beb 100644 --- a/contracts/mocks/MockSablierV2LockupLinear.sol +++ b/contracts/mocks/MockSablierV2LockupLinear.sol @@ -42,6 +42,7 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { totalAmount: params.totalAmount, asset: address(params.asset), cancelable: params.cancelable, + wasCanceled: false, transferable: params.transferable, startTime: params.timestamps.start, cliffTime: params.timestamps.cliff, @@ -116,8 +117,7 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { uint128 withdrawableAmount = withdrawableAmountOf(streamId); uint128 refundAmount = stream.totalAmount - withdrawableAmount; - // TODO: instead of deleting, update state similar to how the real Sablier contract does - delete streams[streamId]; + streams[streamId].wasCanceled = true; if (withdrawableAmount > 0) { IERC20(stream.asset).transfer(stream.recipient, withdrawableAmount); @@ -130,4 +130,26 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { function isCancelable(uint256 streamId) external view returns (bool) { return streams[streamId].cancelable; } + + /// @dev Retrieves the stream's status without performing a null check. + function statusOf( + uint256 streamId + ) public view returns (LockupLinear.Status) { + uint256 withdrawableAmount = withdrawableAmountOf(streamId); + if (withdrawableAmount == 0) { + return LockupLinear.Status.DEPLETED; + } else if (streams[streamId].wasCanceled) { + return LockupLinear.Status.CANCELED; + } + + if (block.timestamp < streams[streamId].startTime) { + return LockupLinear.Status.PENDING; + } + + if (block.timestamp < streams[streamId].endTime) { + return LockupLinear.Status.STREAMING; + } else { + return LockupLinear.Status.SETTLED; + } + } } diff --git a/test/DecentSablierStreamManagement.test.ts b/test/DecentSablierStreamManagement.test.ts index 97757089..60ad02d0 100644 --- a/test/DecentSablierStreamManagement.test.ts +++ b/test/DecentSablierStreamManagement.test.ts @@ -339,8 +339,7 @@ describe("DecentSablierStreamManagement", () => { }); it("Cancels the stream", async () => { - // TODO: use stream.statusOf instead - expect((await mockSablier.getStream(streamId)).cancelable).to.equal(false); + expect(await mockSablier.statusOf(streamId)).to.equal(3); // 3 === LockupLinear.Status.CANCELED }); }); From 2f5bd7a51857a91ca50c8653f17129463fad8cec Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Tue, 8 Oct 2024 14:33:22 -0400 Subject: [PATCH 12/21] Autoformat typescript files --- test/DecentHats_0_1_0.test.ts | 468 ++++++++++++--------- test/DecentSablierStreamManagement.test.ts | 303 ++++++++----- test/helpers.ts | 67 ++- 3 files changed, 516 insertions(+), 322 deletions(-) diff --git a/test/DecentHats_0_1_0.test.ts b/test/DecentHats_0_1_0.test.ts index f0da3930..0d42dc8e 100644 --- a/test/DecentHats_0_1_0.test.ts +++ b/test/DecentHats_0_1_0.test.ts @@ -22,7 +22,10 @@ import { expect } from "chai"; import { ethers, solidityPackedKeccak256 } from "ethers"; import hre from "hardhat"; -import { getGnosisSafeL2Singleton, getGnosisSafeProxyFactory } from "./GlobalSafeDeployments.test"; +import { + getGnosisSafeL2Singleton, + getGnosisSafeProxyFactory, +} from "./GlobalSafeDeployments.test"; import { buildSafeTransaction, buildSignatureBytes, @@ -64,27 +67,34 @@ describe("DecentHats_0_1_0", () => { mockHatsAddress = await mockHats.getAddress(); keyValuePairs = await new KeyValuePairs__factory(deployer).deploy(); erc6551Registry = await new ERC6551Registry__factory(deployer).deploy(); - mockHatsAccountImplementation = await new MockHatsAccount__factory(deployer).deploy(); - mockHatsAccountImplementationAddress = await mockHatsAccountImplementation.getAddress(); + mockHatsAccountImplementation = await new MockHatsAccount__factory( + deployer + ).deploy(); + mockHatsAccountImplementationAddress = + await mockHatsAccountImplementation.getAddress(); decentHats = await new DecentHats_0_1_0__factory(deployer).deploy(); decentHatsAddress = await decentHats.getAddress(); const gnosisSafeProxyFactory = getGnosisSafeProxyFactory(); const gnosisSafeL2Singleton = getGnosisSafeL2Singleton(); - const gnosisSafeL2SingletonAddress = await gnosisSafeL2Singleton.getAddress(); - - const createGnosisSetupCalldata = GnosisSafeL2__factory.createInterface().encodeFunctionData("setup", [ - [dao.address], - 1, - hre.ethers.ZeroAddress, - hre.ethers.ZeroHash, - hre.ethers.ZeroAddress, - hre.ethers.ZeroAddress, - 0, - hre.ethers.ZeroAddress, - ]); - - const saltNum = BigInt(`0x${Buffer.from(hre.ethers.randomBytes(32)).toString("hex")}`); + const gnosisSafeL2SingletonAddress = + await gnosisSafeL2Singleton.getAddress(); + + const createGnosisSetupCalldata = + GnosisSafeL2__factory.createInterface().encodeFunctionData("setup", [ + [dao.address], + 1, + hre.ethers.ZeroAddress, + hre.ethers.ZeroHash, + hre.ethers.ZeroAddress, + hre.ethers.ZeroAddress, + 0, + hre.ethers.ZeroAddress, + ]); + + const saltNum = BigInt( + `0x${Buffer.from(hre.ethers.randomBytes(32)).toString("hex")}` + ); const predictedGnosisSafeAddress = await predictGnosisSafeAddress( createGnosisSetupCalldata, @@ -94,15 +104,27 @@ describe("DecentHats_0_1_0", () => { ); gnosisSafeAddress = predictedGnosisSafeAddress; - await gnosisSafeProxyFactory.createProxyWithNonce(gnosisSafeL2SingletonAddress, createGnosisSetupCalldata, saltNum); + await gnosisSafeProxyFactory.createProxyWithNonce( + gnosisSafeL2SingletonAddress, + createGnosisSetupCalldata, + saltNum + ); - gnosisSafe = GnosisSafeL2__factory.connect(predictedGnosisSafeAddress, deployer); + gnosisSafe = GnosisSafeL2__factory.connect( + predictedGnosisSafeAddress, + deployer + ); // Deploy MockSablierV2LockupLinear - mockSablier = await new MockSablierV2LockupLinear__factory(deployer).deploy(); + mockSablier = await new MockSablierV2LockupLinear__factory( + deployer + ).deploy(); mockSablierAddress = await mockSablier.getAddress(); - mockERC20 = await new MockERC20__factory(deployer).deploy("MockERC20", "MCK"); + mockERC20 = await new MockERC20__factory(deployer).deploy( + "MockERC20", + "MCK" + ); mockERC20Address = await mockERC20.getAddress(); await mockERC20.mint(gnosisSafeAddress, ethers.parseEther("1000000")); @@ -115,9 +137,11 @@ describe("DecentHats_0_1_0", () => { enableModuleTx = await executeSafeTransaction({ safe: gnosisSafe, to: gnosisSafeAddress, - transactionData: GnosisSafeL2__factory.createInterface().encodeFunctionData("enableModule", [ - decentHatsAddress, - ]), + transactionData: + GnosisSafeL2__factory.createInterface().encodeFunctionData( + "enableModule", + [decentHatsAddress] + ), signers: [dao], }); }); @@ -128,7 +152,9 @@ describe("DecentHats_0_1_0", () => { }); it("Emits an EnabledModule event", async () => { - await expect(enableModuleTx).to.emit(gnosisSafe, "EnabledModule").withArgs(decentHatsAddress); + await expect(enableModuleTx) + .to.emit(gnosisSafe, "EnabledModule") + .withArgs(decentHatsAddress); }); }); @@ -139,48 +165,56 @@ describe("DecentHats_0_1_0", () => { createAndDeclareTreeTx = await executeSafeTransaction({ safe: gnosisSafe, to: decentHatsAddress, - transactionData: DecentHats_0_1_0__factory.createInterface().encodeFunctionData("createAndDeclareTree", [ - { - hatsProtocol: mockHatsAddress, - hatsAccountImplementation: mockHatsAccountImplementationAddress, - registry: await erc6551Registry.getAddress(), - keyValuePairs: await keyValuePairs.getAddress(), - topHatDetails: "", - topHatImageURI: "", - adminHat: { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [], - }, - hats: [ + transactionData: + DecentHats_0_1_0__factory.createInterface().encodeFunctionData( + "createAndDeclareTree", + [ { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [], - }, - { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [], + hatsProtocol: mockHatsAddress, + hatsAccountImplementation: + mockHatsAccountImplementationAddress, + registry: await erc6551Registry.getAddress(), + keyValuePairs: await keyValuePairs.getAddress(), + topHatDetails: "", + topHatImageURI: "", + adminHat: { + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [], + }, + hats: [ + { + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [], + }, + { + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [], + }, + ], }, - ], - }, - ]), + ] + ), signers: [dao], }); }); it("Emits an ExecutionSuccess event", async () => { - await expect(createAndDeclareTreeTx).to.emit(gnosisSafe, "ExecutionSuccess"); + await expect(createAndDeclareTreeTx).to.emit( + gnosisSafe, + "ExecutionSuccess" + ); }); it("Emits an ExecutionFromModuleSuccess event", async () => { @@ -202,31 +236,39 @@ describe("DecentHats_0_1_0", () => { createAndDeclareTreeTx2 = await executeSafeTransaction({ safe: gnosisSafe, to: decentHatsAddress, - transactionData: DecentHats_0_1_0__factory.createInterface().encodeFunctionData("createAndDeclareTree", [ - { - hatsProtocol: mockHatsAddress, - hatsAccountImplementation: mockHatsAccountImplementationAddress, - registry: await erc6551Registry.getAddress(), - keyValuePairs: await keyValuePairs.getAddress(), - topHatDetails: "", - topHatImageURI: "", - adminHat: { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [], - }, - hats: [], - }, - ]), + transactionData: + DecentHats_0_1_0__factory.createInterface().encodeFunctionData( + "createAndDeclareTree", + [ + { + hatsProtocol: mockHatsAddress, + hatsAccountImplementation: + mockHatsAccountImplementationAddress, + registry: await erc6551Registry.getAddress(), + keyValuePairs: await keyValuePairs.getAddress(), + topHatDetails: "", + topHatImageURI: "", + adminHat: { + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [], + }, + hats: [], + }, + ] + ), signers: [dao], }); }); it("Emits an ExecutionSuccess event", async () => { - await expect(createAndDeclareTreeTx2).to.emit(gnosisSafe, "ExecutionSuccess"); + await expect(createAndDeclareTreeTx2).to.emit( + gnosisSafe, + "ExecutionSuccess" + ); }); it("Emits an ExecutionFromModuleSuccess event", async () => { @@ -261,7 +303,10 @@ describe("DecentHats_0_1_0", () => { hatId ); - const hatAccount = MockHatsAccount__factory.connect(hatAccountAddress, hre.ethers.provider); + const hatAccount = MockHatsAccount__factory.connect( + hatAccountAddress, + hre.ethers.provider + ); return hatAccount; }; @@ -272,7 +317,9 @@ describe("DecentHats_0_1_0", () => { for (let i = 0n; i < currentCount; i++) { const topHatAccount = await getHatAccount(i); expect(await topHatAccount.tokenId()).eq(i); - expect(await topHatAccount.tokenImplementation()).eq(mockHatsAddress); + expect(await topHatAccount.tokenImplementation()).eq( + mockHatsAddress + ); } }); }); @@ -283,68 +330,77 @@ describe("DecentHats_0_1_0", () => { let currentBlockTimestamp: number; beforeEach(async () => { - currentBlockTimestamp = (await hre.ethers.provider.getBlock("latest"))!.timestamp; + currentBlockTimestamp = (await hre.ethers.provider.getBlock("latest"))! + .timestamp; createAndDeclareTreeTx = await executeSafeTransaction({ safe: gnosisSafe, to: decentHatsAddress, - transactionData: DecentHats_0_1_0__factory.createInterface().encodeFunctionData("createAndDeclareTree", [ - { - hatsProtocol: mockHatsAddress, - hatsAccountImplementation: mockHatsAccountImplementationAddress, - registry: await erc6551Registry.getAddress(), - keyValuePairs: await keyValuePairs.getAddress(), - topHatDetails: "", - topHatImageURI: "", - adminHat: { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [], - }, - hats: [ + transactionData: + DecentHats_0_1_0__factory.createInterface().encodeFunctionData( + "createAndDeclareTree", + [ { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [ + hatsProtocol: mockHatsAddress, + hatsAccountImplementation: + mockHatsAccountImplementationAddress, + registry: await erc6551Registry.getAddress(), + keyValuePairs: await keyValuePairs.getAddress(), + topHatDetails: "", + topHatImageURI: "", + adminHat: { + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [], + }, + hats: [ { - sablier: mockSablierAddress, - sender: gnosisSafeAddress, - totalAmount: ethers.parseEther("100"), - asset: mockERC20Address, - cancelable: true, - transferable: false, - timestamps: { - start: currentBlockTimestamp, - cliff: 0, - end: currentBlockTimestamp + 2592000, // 30 days from now - }, - broker: { account: ethers.ZeroAddress, fee: 0 }, + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [ + { + sablier: mockSablierAddress, + sender: gnosisSafeAddress, + totalAmount: ethers.parseEther("100"), + asset: mockERC20Address, + cancelable: true, + transferable: false, + timestamps: { + start: currentBlockTimestamp, + cliff: 0, + end: currentBlockTimestamp + 2592000, // 30 days from now + }, + broker: { account: ethers.ZeroAddress, fee: 0 }, + }, + ], + }, + { + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [], }, ], }, - { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [], - }, - ], - }, - ]), + ] + ), signers: [dao], }); }); it("Emits an ExecutionSuccess event", async () => { - await expect(createAndDeclareTreeTx).to.emit(gnosisSafe, "ExecutionSuccess"); + await expect(createAndDeclareTreeTx).to.emit( + gnosisSafe, + "ExecutionSuccess" + ); }); it("Emits an ExecutionFromModuleSuccess event", async () => { @@ -360,7 +416,9 @@ describe("DecentHats_0_1_0", () => { }); it("Creates a Sablier stream for the hat with stream parameters", async () => { - const streamCreatedEvents = await mockSablier.queryFilter(mockSablier.filters.StreamCreated()); + const streamCreatedEvents = await mockSablier.queryFilter( + mockSablier.filters.StreamCreated() + ); expect(streamCreatedEvents.length).to.equal(1); const event = streamCreatedEvents[0]; @@ -370,12 +428,16 @@ describe("DecentHats_0_1_0", () => { }); it("Does not create a Sablier stream for hats without stream parameters", async () => { - const streamCreatedEvents = await mockSablier.queryFilter(mockSablier.filters.StreamCreated()); + const streamCreatedEvents = await mockSablier.queryFilter( + mockSablier.filters.StreamCreated() + ); expect(streamCreatedEvents.length).to.equal(1); // Only one stream should be created }); it("Creates a Sablier stream with correct timestamps", async () => { - const streamCreatedEvents = await mockSablier.queryFilter(mockSablier.filters.StreamCreated()); + const streamCreatedEvents = await mockSablier.queryFilter( + mockSablier.filters.StreamCreated() + ); expect(streamCreatedEvents.length).to.equal(1); const streamId = streamCreatedEvents[0].args.streamId; @@ -390,74 +452,82 @@ describe("DecentHats_0_1_0", () => { let currentBlockTimestamp: number; beforeEach(async () => { - currentBlockTimestamp = (await hre.ethers.provider.getBlock("latest"))!.timestamp; + currentBlockTimestamp = (await hre.ethers.provider.getBlock("latest"))! + .timestamp; await executeSafeTransaction({ safe: gnosisSafe, to: decentHatsAddress, - transactionData: DecentHats_0_1_0__factory.createInterface().encodeFunctionData("createAndDeclareTree", [ - { - hatsProtocol: mockHatsAddress, - hatsAccountImplementation: mockHatsAccountImplementationAddress, - registry: await erc6551Registry.getAddress(), - keyValuePairs: await keyValuePairs.getAddress(), - topHatDetails: "", - topHatImageURI: "", - adminHat: { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [], - }, - hats: [ + transactionData: + DecentHats_0_1_0__factory.createInterface().encodeFunctionData( + "createAndDeclareTree", + [ { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [ - { - sablier: mockSablierAddress, - sender: gnosisSafeAddress, - totalAmount: ethers.parseEther("100"), - asset: mockERC20Address, - cancelable: true, - transferable: false, - timestamps: { - start: currentBlockTimestamp, - cliff: currentBlockTimestamp + 86400, // 1 day cliff - end: currentBlockTimestamp + 2592000, // 30 days from now - }, - broker: { account: ethers.ZeroAddress, fee: 0 }, - }, + hatsProtocol: mockHatsAddress, + hatsAccountImplementation: + mockHatsAccountImplementationAddress, + registry: await erc6551Registry.getAddress(), + keyValuePairs: await keyValuePairs.getAddress(), + topHatDetails: "", + topHatImageURI: "", + adminHat: { + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [], + }, + hats: [ { - sablier: mockSablierAddress, - sender: gnosisSafeAddress, - totalAmount: ethers.parseEther("50"), - asset: mockERC20Address, - cancelable: false, - transferable: true, - timestamps: { - start: currentBlockTimestamp, - cliff: 0, // No cliff - end: currentBlockTimestamp + 1296000, // 15 days from now - }, - broker: { account: ethers.ZeroAddress, fee: 0 }, + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [ + { + sablier: mockSablierAddress, + sender: gnosisSafeAddress, + totalAmount: ethers.parseEther("100"), + asset: mockERC20Address, + cancelable: true, + transferable: false, + timestamps: { + start: currentBlockTimestamp, + cliff: currentBlockTimestamp + 86400, // 1 day cliff + end: currentBlockTimestamp + 2592000, // 30 days from now + }, + broker: { account: ethers.ZeroAddress, fee: 0 }, + }, + { + sablier: mockSablierAddress, + sender: gnosisSafeAddress, + totalAmount: ethers.parseEther("50"), + asset: mockERC20Address, + cancelable: false, + transferable: true, + timestamps: { + start: currentBlockTimestamp, + cliff: 0, // No cliff + end: currentBlockTimestamp + 1296000, // 15 days from now + }, + broker: { account: ethers.ZeroAddress, fee: 0 }, + }, + ], }, ], }, - ], - }, - ]), + ] + ), signers: [dao], }); }); it("Creates multiple Sablier streams for a single hat", async () => { - const streamCreatedEvents = await mockSablier.queryFilter(mockSablier.filters.StreamCreated()); + const streamCreatedEvents = await mockSablier.queryFilter( + mockSablier.filters.StreamCreated() + ); expect(streamCreatedEvents.length).to.equal(2); const event1 = streamCreatedEvents[0]; @@ -472,27 +542,39 @@ describe("DecentHats_0_1_0", () => { }); it("Creates streams with correct parameters", async () => { - const streamCreatedEvents = await mockSablier.queryFilter(mockSablier.filters.StreamCreated()); + const streamCreatedEvents = await mockSablier.queryFilter( + mockSablier.filters.StreamCreated() + ); - const stream1 = await mockSablier.getStream(streamCreatedEvents[0].args.streamId); + const stream1 = await mockSablier.getStream( + streamCreatedEvents[0].args.streamId + ); expect(stream1.cancelable).to.be.true; expect(stream1.transferable).to.be.false; expect(stream1.endTime - stream1.startTime).to.equal(2592000); - const stream2 = await mockSablier.getStream(streamCreatedEvents[1].args.streamId); + const stream2 = await mockSablier.getStream( + streamCreatedEvents[1].args.streamId + ); expect(stream2.cancelable).to.be.false; expect(stream2.transferable).to.be.true; expect(stream2.endTime - stream2.startTime).to.equal(1296000); }); it("Creates streams with correct timestamps", async () => { - const streamCreatedEvents = await mockSablier.queryFilter(mockSablier.filters.StreamCreated()); + const streamCreatedEvents = await mockSablier.queryFilter( + mockSablier.filters.StreamCreated() + ); - const stream1 = await mockSablier.getStream(streamCreatedEvents[0].args.streamId); + const stream1 = await mockSablier.getStream( + streamCreatedEvents[0].args.streamId + ); expect(stream1.startTime).to.equal(currentBlockTimestamp); expect(stream1.endTime).to.equal(currentBlockTimestamp + 2592000); - const stream2 = await mockSablier.getStream(streamCreatedEvents[1].args.streamId); + const stream2 = await mockSablier.getStream( + streamCreatedEvents[1].args.streamId + ); expect(stream2.startTime).to.equal(currentBlockTimestamp); expect(stream2.endTime).to.equal(currentBlockTimestamp + 1296000); }); diff --git a/test/DecentSablierStreamManagement.test.ts b/test/DecentSablierStreamManagement.test.ts index 60ad02d0..8e1b0ea5 100644 --- a/test/DecentSablierStreamManagement.test.ts +++ b/test/DecentSablierStreamManagement.test.ts @@ -23,9 +23,16 @@ import { expect } from "chai"; import { ethers } from "ethers"; import hre from "hardhat"; -import { executeSafeTransaction, getHatAccount, predictGnosisSafeAddress } from "./helpers"; +import { + executeSafeTransaction, + getHatAccount, + predictGnosisSafeAddress, +} from "./helpers"; -import { getGnosisSafeProxyFactory, getGnosisSafeL2Singleton } from "./GlobalSafeDeployments.test"; +import { + getGnosisSafeProxyFactory, + getGnosisSafeL2Singleton, +} from "./GlobalSafeDeployments.test"; describe("DecentSablierStreamManagement", () => { let dao: SignerWithAddress; @@ -66,31 +73,40 @@ describe("DecentSablierStreamManagement", () => { const [deployer] = signers; [, dao] = signers; - decentSablierManagement = await new DecentSablierStreamManagement__factory(deployer).deploy(); + decentSablierManagement = await new DecentSablierStreamManagement__factory( + deployer + ).deploy(); decentSablierManagementAddress = await decentSablierManagement.getAddress(); - mockHatsAccountImplementation = await new MockHatsAccount__factory(deployer).deploy(); - mockHatsAccountImplementationAddress = await mockHatsAccountImplementation.getAddress(); + mockHatsAccountImplementation = await new MockHatsAccount__factory( + deployer + ).deploy(); + mockHatsAccountImplementationAddress = + await mockHatsAccountImplementation.getAddress(); decentHats = await new DecentHats_0_1_0__factory(deployer).deploy(); decentHatsAddress = await decentHats.getAddress(); const gnosisSafeProxyFactory = getGnosisSafeProxyFactory(); const gnosisSafeL2Singleton = getGnosisSafeL2Singleton(); - const gnosisSafeL2SingletonAddress = await gnosisSafeL2Singleton.getAddress(); - - const createGnosisSetupCalldata = GnosisSafeL2__factory.createInterface().encodeFunctionData("setup", [ - [dao.address], - 1, - hre.ethers.ZeroAddress, - hre.ethers.ZeroHash, - hre.ethers.ZeroAddress, - hre.ethers.ZeroAddress, - 0, - hre.ethers.ZeroAddress, - ]); - - const saltNum = BigInt(`0x${Buffer.from(hre.ethers.randomBytes(32)).toString("hex")}`); + const gnosisSafeL2SingletonAddress = + await gnosisSafeL2Singleton.getAddress(); + + const createGnosisSetupCalldata = + GnosisSafeL2__factory.createInterface().encodeFunctionData("setup", [ + [dao.address], + 1, + hre.ethers.ZeroAddress, + hre.ethers.ZeroHash, + hre.ethers.ZeroAddress, + hre.ethers.ZeroAddress, + 0, + hre.ethers.ZeroAddress, + ]); + + const saltNum = BigInt( + `0x${Buffer.from(hre.ethers.randomBytes(32)).toString("hex")}` + ); const predictedGnosisSafeAddress = await predictGnosisSafeAddress( createGnosisSetupCalldata, @@ -100,15 +116,27 @@ describe("DecentSablierStreamManagement", () => { ); gnosisSafeAddress = predictedGnosisSafeAddress; - await gnosisSafeProxyFactory.createProxyWithNonce(gnosisSafeL2SingletonAddress, createGnosisSetupCalldata, saltNum); + await gnosisSafeProxyFactory.createProxyWithNonce( + gnosisSafeL2SingletonAddress, + createGnosisSetupCalldata, + saltNum + ); - gnosisSafe = GnosisSafeL2__factory.connect(predictedGnosisSafeAddress, deployer); + gnosisSafe = GnosisSafeL2__factory.connect( + predictedGnosisSafeAddress, + deployer + ); // Deploy MockSablierV2LockupLinear - mockSablier = await new MockSablierV2LockupLinear__factory(deployer).deploy(); + mockSablier = await new MockSablierV2LockupLinear__factory( + deployer + ).deploy(); mockSablierAddress = await mockSablier.getAddress(); - mockERC20 = await new MockERC20__factory(deployer).deploy("MockERC20", "MCK"); + mockERC20 = await new MockERC20__factory(deployer).deploy( + "MockERC20", + "MCK" + ); mockERC20Address = await mockERC20.getAddress(); await mockERC20.mint(gnosisSafeAddress, ethers.parseEther("1000000")); @@ -117,11 +145,16 @@ describe("DecentSablierStreamManagement", () => { await executeSafeTransaction({ safe: gnosisSafe, to: gnosisSafeAddress, - transactionData: GnosisSafeL2__factory.createInterface().encodeFunctionData("enableModule", [decentHatsAddress]), + transactionData: + GnosisSafeL2__factory.createInterface().encodeFunctionData( + "enableModule", + [decentHatsAddress] + ), signers: [dao], }); - currentBlockTimestamp = (await hre.ethers.provider.getBlock("latest"))!.timestamp; + currentBlockTimestamp = (await hre.ethers.provider.getBlock("latest"))! + .timestamp; mockHats = await new MockHats__factory(deployer).deploy(); mockHatsAddress = await mockHats.getAddress(); @@ -131,56 +164,68 @@ describe("DecentSablierStreamManagement", () => { createAndDeclareTreeWithRolesAndStreamsTx = await executeSafeTransaction({ safe: gnosisSafe, to: decentHatsAddress, - transactionData: DecentHats_0_1_0__factory.createInterface().encodeFunctionData("createAndDeclareTree", [ - { - hatsProtocol: mockHatsAddress, - hatsAccountImplementation: mockHatsAccountImplementationAddress, - registry: await erc6551Registry.getAddress(), - keyValuePairs: await keyValuePairs.getAddress(), - topHatDetails: "", - topHatImageURI: "", - adminHat: { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: ethers.ZeroAddress, - sablierParams: [], - }, - hats: [ + transactionData: + DecentHats_0_1_0__factory.createInterface().encodeFunctionData( + "createAndDeclareTree", + [ { - maxSupply: 1, - details: "", - imageURI: "", - isMutable: false, - wearer: dao.address, - sablierParams: [ + hatsProtocol: mockHatsAddress, + hatsAccountImplementation: mockHatsAccountImplementationAddress, + registry: await erc6551Registry.getAddress(), + keyValuePairs: await keyValuePairs.getAddress(), + topHatDetails: "", + topHatImageURI: "", + adminHat: { + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + sablierParams: [], + }, + hats: [ { - sablier: mockSablierAddress, - sender: gnosisSafeAddress, - totalAmount: streamFundsMax, - asset: mockERC20Address, - cancelable: true, - transferable: false, - timestamps: { - start: currentBlockTimestamp, - cliff: 0, - end: currentBlockTimestamp + 2592000, // 30 days from now - }, - broker: { account: ethers.ZeroAddress, fee: 0 }, + maxSupply: 1, + details: "", + imageURI: "", + isMutable: false, + wearer: dao.address, + sablierParams: [ + { + sablier: mockSablierAddress, + sender: gnosisSafeAddress, + totalAmount: streamFundsMax, + asset: mockERC20Address, + cancelable: true, + transferable: false, + timestamps: { + start: currentBlockTimestamp, + cliff: 0, + end: currentBlockTimestamp + 2592000, // 30 days from now + }, + broker: { account: ethers.ZeroAddress, fee: 0 }, + }, + ], }, ], }, - ], - }, - ]), + ] + ), signers: [dao], }); - await expect(createAndDeclareTreeWithRolesAndStreamsTx).to.emit(gnosisSafe, "ExecutionSuccess"); - await expect(createAndDeclareTreeWithRolesAndStreamsTx).to.emit(gnosisSafe, "ExecutionFromModuleSuccess"); + await expect(createAndDeclareTreeWithRolesAndStreamsTx).to.emit( + gnosisSafe, + "ExecutionSuccess" + ); + await expect(createAndDeclareTreeWithRolesAndStreamsTx).to.emit( + gnosisSafe, + "ExecutionFromModuleSuccess" + ); - const streamCreatedEvents = await mockSablier.queryFilter(mockSablier.filters.StreamCreated()); + const streamCreatedEvents = await mockSablier.queryFilter( + mockSablier.filters.StreamCreated() + ); expect(streamCreatedEvents.length).to.equal(1); streamId = streamCreatedEvents[0].args.streamId; @@ -189,9 +234,11 @@ describe("DecentSablierStreamManagement", () => { enableModuleTx = await executeSafeTransaction({ safe: gnosisSafe, to: gnosisSafeAddress, - transactionData: GnosisSafeL2__factory.createInterface().encodeFunctionData("enableModule", [ - decentSablierManagementAddress, - ]), + transactionData: + GnosisSafeL2__factory.createInterface().encodeFunctionData( + "enableModule", + [decentSablierManagementAddress] + ), signers: [dao], }); }); @@ -202,7 +249,9 @@ describe("DecentSablierStreamManagement", () => { }); it("Emits an EnabledModule event", async () => { - await expect(enableModuleTx).to.emit(gnosisSafe, "EnabledModule").withArgs(decentSablierManagementAddress); + await expect(enableModuleTx) + .to.emit(gnosisSafe, "EnabledModule") + .withArgs(decentSablierManagementAddress); }); }); @@ -212,11 +261,15 @@ describe("DecentSablierStreamManagement", () => { describe("When the stream has funds", () => { beforeEach(async () => { // Advance time to the end of the stream - await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 2592000]); + await hre.ethers.provider.send("evm_setNextBlockTimestamp", [ + currentBlockTimestamp + 2592000, + ]); await hre.ethers.provider.send("evm_mine", []); // No action has been taken yet on the stream. Balance should be untouched. - expect(await mockSablier.withdrawableAmountOf(streamId)).to.eq(streamFundsMax); + expect(await mockSablier.withdrawableAmountOf(streamId)).to.eq( + streamFundsMax + ); const recipientHatAccount = await getHatAccount( 2n, @@ -230,10 +283,16 @@ describe("DecentSablierStreamManagement", () => { withdrawTx = await executeSafeTransaction({ safe: gnosisSafe, to: decentSablierManagementAddress, - transactionData: DecentSablierStreamManagement__factory.createInterface().encodeFunctionData( - "withdrawMaxFromStream", - [mockSablierAddress, await recipientHatAccount.getAddress(), streamId, dao.address] - ), + transactionData: + DecentSablierStreamManagement__factory.createInterface().encodeFunctionData( + "withdrawMaxFromStream", + [ + mockSablierAddress, + await recipientHatAccount.getAddress(), + streamId, + dao.address, + ] + ), signers: [dao], }); @@ -258,7 +317,9 @@ describe("DecentSablierStreamManagement", () => { describe("When the stream has no funds", () => { beforeEach(async () => { // Advance time to the end of the stream - await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 2592000]); + await hre.ethers.provider.send("evm_setNextBlockTimestamp", [ + currentBlockTimestamp + 2592000, + ]); await hre.ethers.provider.send("evm_mine", []); const recipientHatAccount = await getHatAccount( @@ -274,10 +335,10 @@ describe("DecentSablierStreamManagement", () => { await recipientHatAccount.execute( mockSablierAddress, 0n, - MockSablierV2LockupLinear__factory.createInterface().encodeFunctionData("withdrawMax", [ - streamId, - dao.address, - ]), + MockSablierV2LockupLinear__factory.createInterface().encodeFunctionData( + "withdrawMax", + [streamId, dao.address] + ), 0 ); @@ -286,10 +347,16 @@ describe("DecentSablierStreamManagement", () => { withdrawTx = await executeSafeTransaction({ safe: gnosisSafe, to: decentSablierManagementAddress, - transactionData: DecentSablierStreamManagement__factory.createInterface().encodeFunctionData( - "withdrawMaxFromStream", - [mockSablierAddress, await recipientHatAccount.getAddress(), streamId, dao.address] - ), + transactionData: + DecentSablierStreamManagement__factory.createInterface().encodeFunctionData( + "withdrawMaxFromStream", + [ + mockSablierAddress, + await recipientHatAccount.getAddress(), + streamId, + dao.address, + ] + ), signers: [dao], }); }); @@ -299,7 +366,10 @@ describe("DecentSablierStreamManagement", () => { }); it("Does not emit an ExecutionFromModuleSuccess event", async () => { - await expect(withdrawTx).to.not.emit(gnosisSafe, "ExecutionFromModuleSuccess"); + await expect(withdrawTx).to.not.emit( + gnosisSafe, + "ExecutionFromModuleSuccess" + ); }); it("Does not revert", async () => { @@ -314,16 +384,19 @@ describe("DecentSablierStreamManagement", () => { describe("When the stream is active", () => { beforeEach(async () => { // Advance time to before the end of the stream - await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 60000]); // 1 minute from now + await hre.ethers.provider.send("evm_setNextBlockTimestamp", [ + currentBlockTimestamp + 60000, + ]); // 1 minute from now await hre.ethers.provider.send("evm_mine", []); cancelTx = await executeSafeTransaction({ safe: gnosisSafe, to: decentSablierManagementAddress, - transactionData: DecentSablierStreamManagement__factory.createInterface().encodeFunctionData("cancelStream", [ - mockSablierAddress, - streamId, - ]), + transactionData: + DecentSablierStreamManagement__factory.createInterface().encodeFunctionData( + "cancelStream", + [mockSablierAddress, streamId] + ), signers: [dao], }); }); @@ -346,16 +419,19 @@ describe("DecentSablierStreamManagement", () => { describe("When the stream has expired", () => { beforeEach(async () => { // Advance time to the end of the stream - await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 2592000 + 60000]); // 30 days from now + 1 minute + await hre.ethers.provider.send("evm_setNextBlockTimestamp", [ + currentBlockTimestamp + 2592000 + 60000, + ]); // 30 days from now + 1 minute await hre.ethers.provider.send("evm_mine", []); cancelTx = await executeSafeTransaction({ safe: gnosisSafe, to: decentSablierManagementAddress, - transactionData: DecentSablierStreamManagement__factory.createInterface().encodeFunctionData("cancelStream", [ - mockSablierAddress, - streamId, - ]), + transactionData: + DecentSablierStreamManagement__factory.createInterface().encodeFunctionData( + "cancelStream", + [mockSablierAddress, streamId] + ), signers: [dao], }); }); @@ -365,7 +441,10 @@ describe("DecentSablierStreamManagement", () => { }); it("Does not emit an ExecutionFromModuleSuccess event", async () => { - await expect(cancelTx).to.not.emit(gnosisSafe, "ExecutionFromModuleSuccess"); + await expect(cancelTx).to.not.emit( + gnosisSafe, + "ExecutionFromModuleSuccess" + ); }); it("Does not revert", async () => { @@ -376,7 +455,9 @@ describe("DecentSablierStreamManagement", () => { describe("When the stream has been previously cancelled", () => { beforeEach(async () => { // Advance time to before the end of the stream - await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 120000]); // 2 minutes from now + await hre.ethers.provider.send("evm_setNextBlockTimestamp", [ + currentBlockTimestamp + 120000, + ]); // 2 minutes from now await hre.ethers.provider.send("evm_mine", []); const stream = await mockSablier.getStream(streamId); @@ -386,22 +467,27 @@ describe("DecentSablierStreamManagement", () => { await executeSafeTransaction({ safe: gnosisSafe, to: mockSablierAddress, - transactionData: MockSablierV2LockupLinear__factory.createInterface().encodeFunctionData("cancel", [ - streamId, - ]), + transactionData: + MockSablierV2LockupLinear__factory.createInterface().encodeFunctionData( + "cancel", + [streamId] + ), signers: [dao], }); - await hre.ethers.provider.send("evm_setNextBlockTimestamp", [currentBlockTimestamp + 240000]); // 4 minutes from now + await hre.ethers.provider.send("evm_setNextBlockTimestamp", [ + currentBlockTimestamp + 240000, + ]); // 4 minutes from now await hre.ethers.provider.send("evm_mine", []); cancelTx = await executeSafeTransaction({ safe: gnosisSafe, to: decentSablierManagementAddress, - transactionData: DecentSablierStreamManagement__factory.createInterface().encodeFunctionData("cancelStream", [ - mockSablierAddress, - streamId, - ]), + transactionData: + DecentSablierStreamManagement__factory.createInterface().encodeFunctionData( + "cancelStream", + [mockSablierAddress, streamId] + ), signers: [dao], }); }); @@ -411,7 +497,10 @@ describe("DecentSablierStreamManagement", () => { }); it("Does not emit an ExecutionFromModuleSuccess event", async () => { - await expect(cancelTx).to.not.emit(gnosisSafe, "ExecutionFromModuleSuccess"); + await expect(cancelTx).to.not.emit( + gnosisSafe, + "ExecutionFromModuleSuccess" + ); }); it("Does not revert", async () => { diff --git a/test/helpers.ts b/test/helpers.ts index 6c8b1a9a..c4a538f7 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -44,7 +44,10 @@ export const predictGnosisSafeAddress = async ( ["bytes", "uint256"], [ethers.solidityPackedKeccak256(["bytes"], [calldata]), saltNum] ), - ethers.solidityPackedKeccak256(["bytes", "uint256"], [await gnosisFactory.proxyCreationCode(), singleton]) + ethers.solidityPackedKeccak256( + ["bytes", "uint256"], + [await gnosisFactory.proxyCreationCode(), singleton] + ) ); }; @@ -55,14 +58,21 @@ export const calculateProxyAddress = async ( saltNonce: string ): Promise => { const masterCopyAddress = masterCopy.toLowerCase().replace(/^0x/, ""); - const byteCode = "0x602d8060093d393df3363d3d373d3d3d363d73" + masterCopyAddress + "5af43d82803e903d91602b57fd5bf3"; + const byteCode = + "0x602d8060093d393df3363d3d373d3d3d363d73" + + masterCopyAddress + + "5af43d82803e903d91602b57fd5bf3"; const salt = ethers.solidityPackedKeccak256( ["bytes32", "uint256"], [ethers.solidityPackedKeccak256(["bytes"], [initData]), saltNonce] ); - return ethers.getCreate2Address(await factory.getAddress(), salt, ethers.keccak256(byteCode)); + return ethers.getCreate2Address( + await factory.getAddress(), + salt, + ethers.keccak256(byteCode) + ); }; export const safeSignTypedData = async ( @@ -100,7 +110,9 @@ export const safeSignTypedData = async ( }; export const buildSignatureBytes = (signatures: SafeSignature[]): string => { - signatures.sort((left, right) => left.signer.toLowerCase().localeCompare(right.signer.toLowerCase())); + signatures.sort((left, right) => + left.signer.toLowerCase().localeCompare(right.signer.toLowerCase()) + ); let signatureBytes = "0x"; for (const sig of signatures) { signatureBytes += sig.data.slice(2); @@ -172,25 +184,31 @@ export const encodeMultiSend = (txs: MetaTransaction[]): string => { ); }; -export const mockTransaction = async (): Promise => { - return { - to: await getMockContract().getAddress(), - value: 0n, - // eslint-disable-next-line camelcase - data: MockContract__factory.createInterface().encodeFunctionData("doSomething"), - operation: 0, +export const mockTransaction = + async (): Promise => { + return { + to: await getMockContract().getAddress(), + value: 0n, + // eslint-disable-next-line camelcase + data: MockContract__factory.createInterface().encodeFunctionData( + "doSomething" + ), + operation: 0, + }; }; -}; -export const mockRevertTransaction = async (): Promise => { - return { - to: await getMockContract().getAddress(), - value: 0n, - // eslint-disable-next-line camelcase - data: MockContract__factory.createInterface().encodeFunctionData("revertSomething"), - operation: 0, +export const mockRevertTransaction = + async (): Promise => { + return { + to: await getMockContract().getAddress(), + value: 0n, + // eslint-disable-next-line camelcase + data: MockContract__factory.createInterface().encodeFunctionData( + "revertSomething" + ), + operation: 0, + }; }; -}; export const executeSafeTransaction = async ({ safe, @@ -209,7 +227,9 @@ export const executeSafeTransaction = async ({ nonce: await safe.nonce(), }); - const sigs = await Promise.all(signers.map(async (signer) => await safeSignTypedData(signer, safe, safeTx))); + const sigs = await Promise.all( + signers.map(async (signer) => await safeSignTypedData(signer, safe, safeTx)) + ); const tx = await safe.execTransaction( safeTx.to, @@ -248,7 +268,10 @@ export const getHatAccount = async ( hatId ); - const hatAccount = MockHatsAccount__factory.connect(hatAccountAddress, signer); + const hatAccount = MockHatsAccount__factory.connect( + hatAccountAddress, + signer + ); return hatAccount; }; From 1524e94ca47c9588974f916346c4704fc4962b6e Mon Sep 17 00:00:00 2001 From: Kellar Date: Wed, 9 Oct 2024 16:08:42 +0100 Subject: [PATCH 13/21] Separate new and/or mock structs from existing and/or production code --- contracts/DecentSablierStreamManagement.sol | 17 +++--- .../interfaces/sablier/ISablierV2Lockup.sol | 6 +- .../sablier/ISablierV2LockupLinear.sol | 3 +- contracts/interfaces/sablier/LockupLinear.sol | 27 --------- .../interfaces/sablier/LockupLinear2.sol | 31 +++++++++++ contracts/mocks/MockLockupLinear.sol | 55 +++++++++++++++++++ contracts/mocks/MockSablierV2LockupLinear.sol | 35 ++++++------ 7 files changed, 115 insertions(+), 59 deletions(-) create mode 100644 contracts/interfaces/sablier/LockupLinear2.sol create mode 100644 contracts/mocks/MockLockupLinear.sol diff --git a/contracts/DecentSablierStreamManagement.sol b/contracts/DecentSablierStreamManagement.sol index 1444dad8..24f44142 100644 --- a/contracts/DecentSablierStreamManagement.sol +++ b/contracts/DecentSablierStreamManagement.sol @@ -3,14 +3,14 @@ pragma solidity =0.8.19; import {Enum} from "@gnosis.pm/safe-contracts/contracts/common/Enum.sol"; import {IAvatar} from "@gnosis.pm/zodiac/contracts/interfaces/IAvatar.sol"; -import {ISablierV2LockupLinear} from "./interfaces/sablier/ISablierV2LockupLinear.sol"; -import {LockupLinear} from "./interfaces/sablier/LockupLinear.sol"; +import {ISablierV2Lockup} from "./interfaces/sablier/ISablierV2Lockup.sol"; +import {LockupLinear2} from "./interfaces/sablier/LockupLinear2.sol"; contract DecentSablierStreamManagement { string public constant NAME = "DecentSablierStreamManagement"; function withdrawMaxFromStream( - ISablierV2LockupLinear sablier, + ISablierV2Lockup sablier, address recipientHatAccount, uint256 streamId, address to @@ -40,15 +40,12 @@ contract DecentSablierStreamManagement { ); } - function cancelStream( - ISablierV2LockupLinear sablier, - uint256 streamId - ) public { + function cancelStream(ISablierV2Lockup sablier, uint256 streamId) public { // Check if the stream can be cancelled - LockupLinear.Status streamStatus = sablier.statusOf(streamId); + LockupLinear2.Status streamStatus = sablier.statusOf(streamId); if ( - streamStatus != LockupLinear.Status.PENDING && - streamStatus != LockupLinear.Status.STREAMING + streamStatus != LockupLinear2.Status.PENDING && + streamStatus != LockupLinear2.Status.STREAMING ) { return; } diff --git a/contracts/interfaces/sablier/ISablierV2Lockup.sol b/contracts/interfaces/sablier/ISablierV2Lockup.sol index 98c3e706..cce723e0 100644 --- a/contracts/interfaces/sablier/ISablierV2Lockup.sol +++ b/contracts/interfaces/sablier/ISablierV2Lockup.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import {LockupLinear} from "../sablier/LockupLinear.sol"; +import {LockupLinear2} from "../sablier/LockupLinear2.sol"; interface ISablierV2Lockup { function withdrawableAmountOf( @@ -16,11 +16,11 @@ interface ISablierV2Lockup { function getStream( uint256 streamId - ) external view returns (LockupLinear.Stream memory); + ) external view returns (LockupLinear2.Stream memory); function cancel(uint256 streamId) external; function statusOf( uint256 streamId - ) external view returns (LockupLinear.Status status); + ) external view returns (LockupLinear2.Status status); } diff --git a/contracts/interfaces/sablier/ISablierV2LockupLinear.sol b/contracts/interfaces/sablier/ISablierV2LockupLinear.sol index ebcc6d50..0aa6cac9 100644 --- a/contracts/interfaces/sablier/ISablierV2LockupLinear.sol +++ b/contracts/interfaces/sablier/ISablierV2LockupLinear.sol @@ -2,10 +2,9 @@ pragma solidity ^0.8.0; import {LockupLinear} from "./LockupLinear.sol"; -import {ISablierV2Lockup} from "./ISablierV2Lockup.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -interface ISablierV2LockupLinear is ISablierV2Lockup { +interface ISablierV2LockupLinear { function createWithTimestamps( LockupLinear.CreateWithTimestamps calldata params ) external returns (uint256 streamId); diff --git a/contracts/interfaces/sablier/LockupLinear.sol b/contracts/interfaces/sablier/LockupLinear.sol index 97df9877..0225d060 100644 --- a/contracts/interfaces/sablier/LockupLinear.sol +++ b/contracts/interfaces/sablier/LockupLinear.sol @@ -25,31 +25,4 @@ library LockupLinear { address account; uint256 fee; } - - struct Stream { - address sender; - uint40 startTime; - uint40 endTime; - uint40 cliffTime; - bool cancelable; - bool wasCanceled; - address asset; - bool transferable; - uint128 totalAmount; - address recipient; - } - - /// @notice Enum representing the different statuses of a stream. - /// @custom:value0 PENDING Stream created but not started; assets are in a pending state. - /// @custom:value1 STREAMING Active stream where assets are currently being streamed. - /// @custom:value2 SETTLED All assets have been streamed; recipient is due to withdraw them. - /// @custom:value3 CANCELED Canceled stream; remaining assets await recipient's withdrawal. - /// @custom:value4 DEPLETED Depleted stream; all assets have been withdrawn and/or refunded. - enum Status { - PENDING, - STREAMING, - SETTLED, - CANCELED, - DEPLETED - } } diff --git a/contracts/interfaces/sablier/LockupLinear2.sol b/contracts/interfaces/sablier/LockupLinear2.sol new file mode 100644 index 00000000..2507cfb2 --- /dev/null +++ b/contracts/interfaces/sablier/LockupLinear2.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library LockupLinear2 { + struct Stream { + address sender; + uint40 startTime; + uint40 endTime; + uint40 cliffTime; + bool cancelable; + bool wasCanceled; + address asset; + bool transferable; + uint128 totalAmount; + address recipient; + } + + /// @notice Enum representing the different statuses of a stream. + /// @custom:value0 PENDING Stream created but not started; assets are in a pending state. + /// @custom:value1 STREAMING Active stream where assets are currently being streamed. + /// @custom:value2 SETTLED All assets have been streamed; recipient is due to withdraw them. + /// @custom:value3 CANCELED Canceled stream; remaining assets await recipient's withdrawal. + /// @custom:value4 DEPLETED Depleted stream; all assets have been withdrawn and/or refunded. + enum Status { + PENDING, + STREAMING, + SETTLED, + CANCELED, + DEPLETED + } +} diff --git a/contracts/mocks/MockLockupLinear.sol b/contracts/mocks/MockLockupLinear.sol new file mode 100644 index 00000000..911d220b --- /dev/null +++ b/contracts/mocks/MockLockupLinear.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +library MockLockupLinear { + struct CreateWithTimestamps { + address sender; + address recipient; + uint128 totalAmount; + IERC20 asset; + bool cancelable; + bool transferable; + Timestamps timestamps; + Broker broker; + } + + struct Timestamps { + uint40 start; + uint40 cliff; + uint40 end; + } + + struct Broker { + address account; + uint256 fee; + } + + struct Stream { + address sender; + uint40 startTime; + uint40 endTime; + uint40 cliffTime; + bool cancelable; + bool wasCanceled; + address asset; + bool transferable; + uint128 totalAmount; + address recipient; + } + + /// @notice Enum representing the different statuses of a stream. + /// @custom:value0 PENDING Stream created but not started; assets are in a pending state. + /// @custom:value1 STREAMING Active stream where assets are currently being streamed. + /// @custom:value2 SETTLED All assets have been streamed; recipient is due to withdraw them. + /// @custom:value3 CANCELED Canceled stream; remaining assets await recipient's withdrawal. + /// @custom:value4 DEPLETED Depleted stream; all assets have been withdrawn and/or refunded. + enum Status { + PENDING, + STREAMING, + SETTLED, + CANCELED, + DEPLETED + } +} diff --git a/contracts/mocks/MockSablierV2LockupLinear.sol b/contracts/mocks/MockSablierV2LockupLinear.sol index 174c4beb..9926a994 100644 --- a/contracts/mocks/MockSablierV2LockupLinear.sol +++ b/contracts/mocks/MockSablierV2LockupLinear.sol @@ -2,11 +2,12 @@ pragma solidity =0.8.19; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import "../interfaces/sablier/ISablierV2LockupLinear.sol"; -import {LockupLinear} from "../interfaces/sablier/LockupLinear.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ISablierV2LockupLinear} from "../interfaces/sablier/ISablierV2LockupLinear.sol"; +import {MockLockupLinear} from "./MockLockupLinear.sol"; -contract MockSablierV2LockupLinear is ISablierV2LockupLinear { - mapping(uint256 => LockupLinear.Stream) public streams; +contract MockSablierV2LockupLinear { + mapping(uint256 => MockLockupLinear.Stream) public streams; uint256 public nextStreamId = 1; // Add this event declaration at the contract level @@ -24,8 +25,8 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { ); function createWithTimestamps( - LockupLinear.CreateWithTimestamps calldata params - ) external override returns (uint256 streamId) { + MockLockupLinear.CreateWithTimestamps calldata params + ) external returns (uint256 streamId) { require( params.asset.transferFrom( msg.sender, @@ -36,7 +37,7 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { ); streamId = nextStreamId++; - streams[streamId] = LockupLinear.Stream({ + streams[streamId] = MockLockupLinear.Stream({ sender: params.sender, recipient: params.recipient, totalAmount: params.totalAmount, @@ -68,14 +69,14 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { function getStream( uint256 streamId - ) external view returns (LockupLinear.Stream memory) { + ) external view returns (MockLockupLinear.Stream memory) { return streams[streamId]; } function withdrawableAmountOf( uint256 streamId ) public view returns (uint128) { - LockupLinear.Stream memory stream = streams[streamId]; + MockLockupLinear.Stream memory stream = streams[streamId]; if (block.timestamp <= stream.startTime) { return 0; } @@ -94,7 +95,7 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { address to ) external returns (uint128 withdrawnAmount) { withdrawnAmount = withdrawableAmountOf(streamId); - LockupLinear.Stream storage stream = streams[streamId]; + MockLockupLinear.Stream storage stream = streams[streamId]; require( msg.sender == stream.recipient, @@ -110,7 +111,7 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { } function cancel(uint256 streamId) external { - LockupLinear.Stream memory stream = streams[streamId]; + MockLockupLinear.Stream memory stream = streams[streamId]; require(stream.cancelable, "Stream is not cancelable"); require(msg.sender == stream.sender, "Only sender can cancel"); @@ -134,22 +135,22 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear { /// @dev Retrieves the stream's status without performing a null check. function statusOf( uint256 streamId - ) public view returns (LockupLinear.Status) { + ) public view returns (MockLockupLinear.Status) { uint256 withdrawableAmount = withdrawableAmountOf(streamId); if (withdrawableAmount == 0) { - return LockupLinear.Status.DEPLETED; + return MockLockupLinear.Status.DEPLETED; } else if (streams[streamId].wasCanceled) { - return LockupLinear.Status.CANCELED; + return MockLockupLinear.Status.CANCELED; } if (block.timestamp < streams[streamId].startTime) { - return LockupLinear.Status.PENDING; + return MockLockupLinear.Status.PENDING; } if (block.timestamp < streams[streamId].endTime) { - return LockupLinear.Status.STREAMING; + return MockLockupLinear.Status.STREAMING; } else { - return LockupLinear.Status.SETTLED; + return MockLockupLinear.Status.SETTLED; } } } From a1e23c469773b30b012fa5427fa063c3cd2c42c4 Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Wed, 9 Oct 2024 12:21:03 -0400 Subject: [PATCH 14/21] Add PRBMath to package, for Sablier interfaces --- package-lock.json | 7 +++++++ package.json | 1 + 2 files changed, 8 insertions(+) diff --git a/package-lock.json b/package-lock.json index 2f51a13f..8b4f73ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@nomicfoundation/hardhat-toolbox": "^5.0.0", "@openzeppelin/contracts": "^4.5.0", "@openzeppelin/contracts-upgradeable": "^4.5.0", + "@prb/math": "^4.0.3", "dotenv": "^16.4.5", "hardhat": "^2.22.2", "hardhat-dependency-compiler": "^1.1.4", @@ -1811,6 +1812,12 @@ "integrity": "sha512-+wuegAMaLcZnLCJIvrVUDzA9z/Wp93f0Dla/4jJvIhijRrPabjQbZe6fWiECLaJyfn5ci9fqf9vTw3xpQOad2A==", "dev": true }, + "node_modules/@prb/math": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@prb/math/-/math-4.0.3.tgz", + "integrity": "sha512-/RSt3VU1k2m3ox6U6kUL1MrktnAHr8vhydXu4eDtqFAms1gm3XnGpoZIPaK1lm2zdJQmKBwJ4EXALPARsuOlaA==", + "dev": true + }, "node_modules/@scure/base": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.6.tgz", diff --git a/package.json b/package.json index d6620d11..de81f04e 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@nomicfoundation/hardhat-toolbox": "^5.0.0", "@openzeppelin/contracts": "^4.5.0", "@openzeppelin/contracts-upgradeable": "^4.5.0", + "@prb/math": "^4.0.3", "dotenv": "^16.4.5", "hardhat": "^2.22.2", "hardhat-dependency-compiler": "^1.1.4", From 1ec965046b262037f437088c9ab44690b0c19509 Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Wed, 9 Oct 2024 12:23:14 -0400 Subject: [PATCH 15/21] Added minimum amount of "full" Sablier interfaces needed for DecentSablierStreamManagement, to also be future-proof --- .../interfaces/sablier/full/IAdminable.sol | 41 ++ .../interfaces/sablier/full/IERC4096.sol | 20 + .../sablier/full/ISablierV2Lockup.sol | 395 ++++++++++++++++++ .../sablier/full/ISablierV2NFTDescriptor.sol | 19 + .../sablier/full/types/DataTypes.sol | 374 +++++++++++++++++ 5 files changed, 849 insertions(+) create mode 100644 contracts/interfaces/sablier/full/IAdminable.sol create mode 100644 contracts/interfaces/sablier/full/IERC4096.sol create mode 100644 contracts/interfaces/sablier/full/ISablierV2Lockup.sol create mode 100644 contracts/interfaces/sablier/full/ISablierV2NFTDescriptor.sol create mode 100644 contracts/interfaces/sablier/full/types/DataTypes.sol diff --git a/contracts/interfaces/sablier/full/IAdminable.sol b/contracts/interfaces/sablier/full/IAdminable.sol new file mode 100644 index 00000000..e5ee0956 --- /dev/null +++ b/contracts/interfaces/sablier/full/IAdminable.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.19; + +/// @title IAdminable +/// @notice Contract module that provides a basic access control mechanism, with an admin that can be +/// granted exclusive access to specific functions. The inheriting contract must set the initial admin +/// in the constructor. +interface IAdminable { + /*////////////////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Emitted when the admin is transferred. + /// @param oldAdmin The address of the old admin. + /// @param newAdmin The address of the new admin. + event TransferAdmin(address indexed oldAdmin, address indexed newAdmin); + + /*////////////////////////////////////////////////////////////////////////// + CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice The address of the admin account or contract. + function admin() external view returns (address); + + /*////////////////////////////////////////////////////////////////////////// + NON-CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Transfers the contract admin to a new address. + /// + /// @dev Notes: + /// - Does not revert if the admin is the same. + /// - This function can potentially leave the contract without an admin, thereby removing any + /// functionality that is only available to the admin. + /// + /// Requirements: + /// - `msg.sender` must be the contract admin. + /// + /// @param newAdmin The address of the new admin. + function transferAdmin(address newAdmin) external; +} diff --git a/contracts/interfaces/sablier/full/IERC4096.sol b/contracts/interfaces/sablier/full/IERC4096.sol new file mode 100644 index 00000000..29bcc6ea --- /dev/null +++ b/contracts/interfaces/sablier/full/IERC4096.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.0.0) (interfaces/IERC4906.sol) + +pragma solidity ^0.8.19; + +import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +/// @title ERC-721 Metadata Update Extension +interface IERC4906 is IERC165, IERC721 { + /// @dev This event emits when the metadata of a token is changed. + /// So that the third-party platforms such as NFT market could + /// timely update the images and related attributes of the NFT. + event MetadataUpdate(uint256 _tokenId); + + /// @dev This event emits when the metadata of a range of tokens is changed. + /// So that the third-party platforms such as NFT market could + /// timely update the images and related attributes of the NFTs. + event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId); +} diff --git a/contracts/interfaces/sablier/full/ISablierV2Lockup.sol b/contracts/interfaces/sablier/full/ISablierV2Lockup.sol new file mode 100644 index 00000000..8e5b21e3 --- /dev/null +++ b/contracts/interfaces/sablier/full/ISablierV2Lockup.sol @@ -0,0 +1,395 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.19; + +import {IERC4906} from "./IERC4096.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC721Metadata} from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; +import {UD60x18} from "@prb/math/src/UD60x18.sol"; + +import {Lockup} from "./types/DataTypes.sol"; +import {IAdminable} from "./IAdminable.sol"; +import {ISablierV2NFTDescriptor} from "./ISablierV2NFTDescriptor.sol"; + +/// @title ISablierV2Lockup +/// @notice Common logic between all Sablier V2 Lockup contracts. +interface ISablierV2Lockup is + IAdminable, // 0 inherited components + IERC4906, // 2 inherited components + IERC721Metadata // 2 inherited components +{ + /*////////////////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Emitted when the admin allows a new recipient contract to hook to Sablier. + /// @param admin The address of the current contract admin. + /// @param recipient The address of the recipient contract put on the allowlist. + event AllowToHook(address indexed admin, address recipient); + + /// @notice Emitted when a stream is canceled. + /// @param streamId The ID of the stream. + /// @param sender The address of the stream's sender. + /// @param recipient The address of the stream's recipient. + /// @param asset The contract address of the ERC-20 asset to be distributed. + /// @param senderAmount The amount of assets refunded to the stream's sender, denoted in units of the asset's + /// decimals. + /// @param recipientAmount The amount of assets left for the stream's recipient to withdraw, denoted in units of the + /// asset's decimals. + event CancelLockupStream( + uint256 streamId, + address indexed sender, + address indexed recipient, + IERC20 indexed asset, + uint128 senderAmount, + uint128 recipientAmount + ); + + /// @notice Emitted when a sender gives up the right to cancel a stream. + /// @param streamId The ID of the stream. + event RenounceLockupStream(uint256 indexed streamId); + + /// @notice Emitted when the admin sets a new NFT descriptor contract. + /// @param admin The address of the current contract admin. + /// @param oldNFTDescriptor The address of the old NFT descriptor contract. + /// @param newNFTDescriptor The address of the new NFT descriptor contract. + event SetNFTDescriptor( + address indexed admin, + ISablierV2NFTDescriptor oldNFTDescriptor, + ISablierV2NFTDescriptor newNFTDescriptor + ); + + /// @notice Emitted when assets are withdrawn from a stream. + /// @param streamId The ID of the stream. + /// @param to The address that has received the withdrawn assets. + /// @param asset The contract address of the ERC-20 asset to be distributed. + /// @param amount The amount of assets withdrawn, denoted in units of the asset's decimals. + event WithdrawFromLockupStream( + uint256 indexed streamId, + address indexed to, + IERC20 indexed asset, + uint128 amount + ); + + /*////////////////////////////////////////////////////////////////////////// + CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Retrieves the address of the ERC-20 asset to be distributed. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function getAsset(uint256 streamId) external view returns (IERC20 asset); + + /// @notice Retrieves the amount deposited in the stream, denoted in units of the asset's decimals. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function getDepositedAmount( + uint256 streamId + ) external view returns (uint128 depositedAmount); + + /// @notice Retrieves the stream's end time, which is a Unix timestamp. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function getEndTime( + uint256 streamId + ) external view returns (uint40 endTime); + + /// @notice Retrieves the stream's recipient. + /// @dev Reverts if the NFT has been burned. + /// @param streamId The stream ID for the query. + function getRecipient( + uint256 streamId + ) external view returns (address recipient); + + /// @notice Retrieves the amount refunded to the sender after a cancellation, denoted in units of the asset's + /// decimals. This amount is always zero unless the stream was canceled. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function getRefundedAmount( + uint256 streamId + ) external view returns (uint128 refundedAmount); + + /// @notice Retrieves the stream's sender. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function getSender(uint256 streamId) external view returns (address sender); + + /// @notice Retrieves the stream's start time, which is a Unix timestamp. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function getStartTime( + uint256 streamId + ) external view returns (uint40 startTime); + + /// @notice Retrieves the amount withdrawn from the stream, denoted in units of the asset's decimals. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function getWithdrawnAmount( + uint256 streamId + ) external view returns (uint128 withdrawnAmount); + + /// @notice Retrieves a flag indicating whether the provided address is a contract allowed to hook to Sablier + /// when a stream is canceled or when assets are withdrawn. + /// @dev See {ISablierLockupRecipient} for more information. + function isAllowedToHook( + address recipient + ) external view returns (bool result); + + /// @notice Retrieves a flag indicating whether the stream can be canceled. When the stream is cold, this + /// flag is always `false`. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function isCancelable(uint256 streamId) external view returns (bool result); + + /// @notice Retrieves a flag indicating whether the stream is cold, i.e. settled, canceled, or depleted. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function isCold(uint256 streamId) external view returns (bool result); + + /// @notice Retrieves a flag indicating whether the stream is depleted. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function isDepleted(uint256 streamId) external view returns (bool result); + + /// @notice Retrieves a flag indicating whether the stream exists. + /// @dev Does not revert if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function isStream(uint256 streamId) external view returns (bool result); + + /// @notice Retrieves a flag indicating whether the stream NFT can be transferred. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function isTransferable( + uint256 streamId + ) external view returns (bool result); + + /// @notice Retrieves a flag indicating whether the stream is warm, i.e. either pending or streaming. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function isWarm(uint256 streamId) external view returns (bool result); + + /// @notice Retrieves the maximum broker fee that can be charged by the broker, denoted as a fixed-point + /// number where 1e18 is 100%. + /// @dev This value is hard coded as a constant. + function MAX_BROKER_FEE() external view returns (UD60x18); + + /// @notice Counter for stream IDs, used in the create functions. + function nextStreamId() external view returns (uint256); + + /// @notice Contract that generates the non-fungible token URI. + function nftDescriptor() external view returns (ISablierV2NFTDescriptor); + + /// @notice Calculates the amount that the sender would be refunded if the stream were canceled, denoted in units + /// of the asset's decimals. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function refundableAmountOf( + uint256 streamId + ) external view returns (uint128 refundableAmount); + + /// @notice Retrieves the stream's status. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function statusOf( + uint256 streamId + ) external view returns (Lockup.Status status); + + /// @notice Calculates the amount streamed to the recipient, denoted in units of the asset's decimals. + /// @dev Reverts if `streamId` references a null stream. + /// + /// Notes: + /// - Upon cancellation of the stream, the amount streamed is calculated as the difference between the deposited + /// amount and the refunded amount. Ultimately, when the stream becomes depleted, the streamed amount is equivalent + /// to the total amount withdrawn. + /// + /// @param streamId The stream ID for the query. + function streamedAmountOf( + uint256 streamId + ) external view returns (uint128 streamedAmount); + + /// @notice Retrieves a flag indicating whether the stream was canceled. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function wasCanceled(uint256 streamId) external view returns (bool result); + + /// @notice Calculates the amount that the recipient can withdraw from the stream, denoted in units of the asset's + /// decimals. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function withdrawableAmountOf( + uint256 streamId + ) external view returns (uint128 withdrawableAmount); + + /*////////////////////////////////////////////////////////////////////////// + NON-CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Allows a recipient contract to hook to Sablier when a stream is canceled or when assets are withdrawn. + /// Useful for implementing contracts that hold streams on behalf of users, such as vaults or staking contracts. + /// + /// @dev Emits an {AllowToHook} event. + /// + /// Notes: + /// - Does not revert if the contract is already on the allowlist. + /// - This is an irreversible operation. The contract cannot be removed from the allowlist. + /// + /// Requirements: + /// - `msg.sender` must be the contract admin. + /// - `recipient` must have a non-zero code size. + /// - `recipient` must implement {ISablierLockupRecipient}. + /// + /// @param recipient The address of the contract to allow for hooks. + function allowToHook(address recipient) external; + + /// @notice Burns the NFT associated with the stream. + /// + /// @dev Emits a {Transfer} event. + /// + /// Requirements: + /// - Must not be delegate called. + /// - `streamId` must reference a depleted stream. + /// - The NFT must exist. + /// - `msg.sender` must be either the NFT owner or an approved third party. + /// + /// @param streamId The ID of the stream NFT to burn. + function burn(uint256 streamId) external; + + /// @notice Cancels the stream and refunds any remaining assets to the sender. + /// + /// @dev Emits a {Transfer}, {CancelLockupStream}, and {MetadataUpdate} event. + /// + /// Notes: + /// - If there any assets left for the recipient to withdraw, the stream is marked as canceled. Otherwise, the + /// stream is marked as depleted. + /// - This function attempts to invoke a hook on the recipient, if the resolved address is a contract. + /// + /// Requirements: + /// - Must not be delegate called. + /// - The stream must be warm and cancelable. + /// - `msg.sender` must be the stream's sender. + /// + /// @param streamId The ID of the stream to cancel. + function cancel(uint256 streamId) external; + + /// @notice Cancels multiple streams and refunds any remaining assets to the sender. + /// + /// @dev Emits multiple {Transfer}, {CancelLockupStream}, and {MetadataUpdate} events. + /// + /// Notes: + /// - Refer to the notes in {cancel}. + /// + /// Requirements: + /// - All requirements from {cancel} must be met for each stream. + /// + /// @param streamIds The IDs of the streams to cancel. + function cancelMultiple(uint256[] calldata streamIds) external; + + /// @notice Removes the right of the stream's sender to cancel the stream. + /// + /// @dev Emits a {RenounceLockupStream} and {MetadataUpdate} event. + /// + /// Notes: + /// - This is an irreversible operation. + /// + /// Requirements: + /// - Must not be delegate called. + /// - `streamId` must reference a warm stream. + /// - `msg.sender` must be the stream's sender. + /// - The stream must be cancelable. + /// + /// @param streamId The ID of the stream to renounce. + function renounce(uint256 streamId) external; + + /// @notice Sets a new NFT descriptor contract, which produces the URI describing the Sablier stream NFTs. + /// + /// @dev Emits a {SetNFTDescriptor} and {BatchMetadataUpdate} event. + /// + /// Notes: + /// - Does not revert if the NFT descriptor is the same. + /// + /// Requirements: + /// - `msg.sender` must be the contract admin. + /// + /// @param newNFTDescriptor The address of the new NFT descriptor contract. + function setNFTDescriptor( + ISablierV2NFTDescriptor newNFTDescriptor + ) external; + + /// @notice Withdraws the provided amount of assets from the stream to the `to` address. + /// + /// @dev Emits a {Transfer}, {WithdrawFromLockupStream}, and {MetadataUpdate} event. + /// + /// Notes: + /// - This function attempts to call a hook on the recipient of the stream, unless `msg.sender` is the recipient. + /// + /// Requirements: + /// - Must not be delegate called. + /// - `streamId` must not reference a null or depleted stream. + /// - `to` must not be the zero address. + /// - `amount` must be greater than zero and must not exceed the withdrawable amount. + /// - `to` must be the recipient if `msg.sender` is not the stream's recipient or an approved third party. + /// + /// @param streamId The ID of the stream to withdraw from. + /// @param to The address receiving the withdrawn assets. + /// @param amount The amount to withdraw, denoted in units of the asset's decimals. + function withdraw(uint256 streamId, address to, uint128 amount) external; + + /// @notice Withdraws the maximum withdrawable amount from the stream to the provided address `to`. + /// + /// @dev Emits a {Transfer}, {WithdrawFromLockupStream}, and {MetadataUpdate} event. + /// + /// Notes: + /// - Refer to the notes in {withdraw}. + /// + /// Requirements: + /// - Refer to the requirements in {withdraw}. + /// + /// @param streamId The ID of the stream to withdraw from. + /// @param to The address receiving the withdrawn assets. + /// @return withdrawnAmount The amount withdrawn, denoted in units of the asset's decimals. + function withdrawMax( + uint256 streamId, + address to + ) external returns (uint128 withdrawnAmount); + + /// @notice Withdraws the maximum withdrawable amount from the stream to the current recipient, and transfers the + /// NFT to `newRecipient`. + /// + /// @dev Emits a {WithdrawFromLockupStream} and a {Transfer} event. + /// + /// Notes: + /// - If the withdrawable amount is zero, the withdrawal is skipped. + /// - Refer to the notes in {withdraw}. + /// + /// Requirements: + /// - `msg.sender` must be the stream's recipient. + /// - Refer to the requirements in {withdraw}. + /// - Refer to the requirements in {IERC721.transferFrom}. + /// + /// @param streamId The ID of the stream NFT to transfer. + /// @param newRecipient The address of the new owner of the stream NFT. + /// @return withdrawnAmount The amount withdrawn, denoted in units of the asset's decimals. + function withdrawMaxAndTransfer( + uint256 streamId, + address newRecipient + ) external returns (uint128 withdrawnAmount); + + /// @notice Withdraws assets from streams to the recipient of each stream. + /// + /// @dev Emits multiple {Transfer}, {WithdrawFromLockupStream}, and {MetadataUpdate} events. + /// + /// Notes: + /// - This function attempts to call a hook on the recipient of each stream, unless `msg.sender` is the recipient. + /// + /// Requirements: + /// - Must not be delegate called. + /// - There must be an equal number of `streamIds` and `amounts`. + /// - Each stream ID in the array must not reference a null or depleted stream. + /// - Each amount in the array must be greater than zero and must not exceed the withdrawable amount. + /// + /// @param streamIds The IDs of the streams to withdraw from. + /// @param amounts The amounts to withdraw, denoted in units of the asset's decimals. + function withdrawMultiple( + uint256[] calldata streamIds, + uint128[] calldata amounts + ) external; +} diff --git a/contracts/interfaces/sablier/full/ISablierV2NFTDescriptor.sol b/contracts/interfaces/sablier/full/ISablierV2NFTDescriptor.sol new file mode 100644 index 00000000..49fec926 --- /dev/null +++ b/contracts/interfaces/sablier/full/ISablierV2NFTDescriptor.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.19; + +import {IERC721Metadata} from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; + +/// @title ISablierV2NFTDescriptor +/// @notice This contract generates the URI describing the Sablier V2 stream NFTs. +/// @dev Inspired by Uniswap V3 Positions NFTs. +interface ISablierV2NFTDescriptor { + /// @notice Produces the URI describing a particular stream NFT. + /// @dev This is a data URI with the JSON contents directly inlined. + /// @param sablier The address of the Sablier contract the stream was created in. + /// @param streamId The ID of the stream for which to produce a description. + /// @return uri The URI of the ERC721-compliant metadata. + function tokenURI( + IERC721Metadata sablier, + uint256 streamId + ) external view returns (string memory uri); +} diff --git a/contracts/interfaces/sablier/full/types/DataTypes.sol b/contracts/interfaces/sablier/full/types/DataTypes.sol new file mode 100644 index 00000000..b7b50c59 --- /dev/null +++ b/contracts/interfaces/sablier/full/types/DataTypes.sol @@ -0,0 +1,374 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.19; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {UD2x18} from "@prb/math/src/UD2x18.sol"; +import {UD60x18} from "@prb/math/src/UD60x18.sol"; + +// DataTypes.sol +// +// This file defines all structs used in V2 Core, most of which are organized under three namespaces: +// +// - Lockup +// - LockupDynamic +// - LockupLinear +// - LockupTranched +// +// You will notice that some structs contain "slot" annotations - they are used to indicate the +// storage layout of the struct. It is more gas efficient to group small data types together so +// that they fit in a single 32-byte slot. + +/// @notice Struct encapsulating the broker parameters passed to the create functions. Both can be set to zero. +/// @param account The address receiving the broker's fee. +/// @param fee The broker's percentage fee from the total amount, denoted as a fixed-point number where 1e18 is 100%. +struct Broker { + address account; + UD60x18 fee; +} + +/// @notice Namespace for the structs used in both {SablierV2LockupLinear} and {SablierV2LockupDynamic}. +library Lockup { + /// @notice Struct encapsulating the deposit, withdrawn, and refunded amounts, all denoted in units of the asset's + /// decimals. + /// @dev Because the deposited and the withdrawn amount are often read together, declaring them in the same slot + /// saves gas. + /// @param deposited The initial amount deposited in the stream, net of broker fee. + /// @param withdrawn The cumulative amount withdrawn from the stream. + /// @param refunded The amount refunded to the sender. Unless the stream was canceled, this is always zero. + struct Amounts { + // slot 0 + uint128 deposited; + uint128 withdrawn; + // slot 1 + uint128 refunded; + } + + /// @notice Struct encapsulating (i) the deposit amount and (ii) the broker fee amount, both denoted in units of the + /// asset's decimals. + /// @param deposit The amount to deposit in the stream. + /// @param brokerFee The broker fee amount. + struct CreateAmounts { + uint128 deposit; + uint128 brokerFee; + } + + /// @notice Enum representing the different statuses of a stream. + /// @custom:value0 PENDING Stream created but not started; assets are in a pending state. + /// @custom:value1 STREAMING Active stream where assets are currently being streamed. + /// @custom:value2 SETTLED All assets have been streamed; recipient is due to withdraw them. + /// @custom:value3 CANCELED Canceled stream; remaining assets await recipient's withdrawal. + /// @custom:value4 DEPLETED Depleted stream; all assets have been withdrawn and/or refunded. + enum Status { + PENDING, + STREAMING, + SETTLED, + CANCELED, + DEPLETED + } + + /// @notice A common data structure to be stored in all {SablierV2Lockup} models. + /// @dev The fields are arranged like this to save gas via tight variable packing. + /// @param sender The address distributing the assets, with the ability to cancel the stream. + /// @param startTime The Unix timestamp indicating the stream's start. + /// @param endTime The Unix timestamp indicating the stream's end. + /// @param isCancelable Boolean indicating if the stream is cancelable. + /// @param wasCanceled Boolean indicating if the stream was canceled. + /// @param asset The contract address of the ERC-20 asset to be distributed. + /// @param isDepleted Boolean indicating if the stream is depleted. + /// @param isStream Boolean indicating if the struct entity exists. + /// @param isTransferable Boolean indicating if the stream NFT is transferable. + /// @param amounts Struct encapsulating the deposit, withdrawn, and refunded amounts, all denoted in units of the + /// asset's decimals. + struct Stream { + // slot 0 + address sender; + uint40 startTime; + uint40 endTime; + bool isCancelable; + bool wasCanceled; + // slot 1 + IERC20 asset; + bool isDepleted; + bool isStream; + bool isTransferable; + // slot 2 and 3 + Lockup.Amounts amounts; + } +} + +/// @notice Namespace for the structs used in {SablierV2LockupDynamic}. +library LockupDynamic { + /// @notice Struct encapsulating the parameters of the {SablierV2LockupDynamic.createWithDurations} function. + /// @param sender The address distributing the assets, with the ability to cancel the stream. It doesn't have to be + /// the same as `msg.sender`. + /// @param recipient The address receiving the assets. + /// @param totalAmount The total amount of ERC-20 assets to be distributed, including the stream deposit and any + /// broker fee, denoted in units of the asset's decimals. + /// @param asset The contract address of the ERC-20 asset to be distributed. + /// @param cancelable Indicates if the stream is cancelable. + /// @param transferable Indicates if the stream NFT is transferable. + /// @param segments Segments with durations used to compose the dynamic distribution function. Timestamps are + /// calculated by starting from `block.timestamp` and adding each duration to the previous timestamp. + /// @param broker Struct encapsulating (i) the address of the broker assisting in creating the stream, and (ii) the + /// percentage fee paid to the broker from `totalAmount`, denoted as a fixed-point number. Both can be set to zero. + struct CreateWithDurations { + address sender; + address recipient; + uint128 totalAmount; + IERC20 asset; + bool cancelable; + bool transferable; + SegmentWithDuration[] segments; + Broker broker; + } + + /// @notice Struct encapsulating the parameters of the {SablierV2LockupDynamic.createWithTimestamps} function. + /// @param sender The address distributing the assets, with the ability to cancel the stream. It doesn't have to be + /// the same as `msg.sender`. + /// @param recipient The address receiving the assets. + /// @param totalAmount The total amount of ERC-20 assets to be distributed, including the stream deposit and any + /// broker fee, denoted in units of the asset's decimals. + /// @param asset The contract address of the ERC-20 asset to be distributed. + /// @param cancelable Indicates if the stream is cancelable. + /// @param transferable Indicates if the stream NFT is transferable. + /// @param startTime The Unix timestamp indicating the stream's start. + /// @param segments Segments used to compose the dynamic distribution function. + /// @param broker Struct encapsulating (i) the address of the broker assisting in creating the stream, and (ii) the + /// percentage fee paid to the broker from `totalAmount`, denoted as a fixed-point number. Both can be set to zero. + struct CreateWithTimestamps { + address sender; + address recipient; + uint128 totalAmount; + IERC20 asset; + bool cancelable; + bool transferable; + uint40 startTime; + Segment[] segments; + Broker broker; + } + + /// @notice Segment struct used in the Lockup Dynamic stream. + /// @param amount The amount of assets to be streamed in the segment, denoted in units of the asset's decimals. + /// @param exponent The exponent of the segment, denoted as a fixed-point number. + /// @param timestamp The Unix timestamp indicating the segment's end. + struct Segment { + // slot 0 + uint128 amount; + UD2x18 exponent; + uint40 timestamp; + } + + /// @notice Segment struct used at runtime in {SablierV2LockupDynamic.createWithDurations}. + /// @param amount The amount of assets to be streamed in the segment, denoted in units of the asset's decimals. + /// @param exponent The exponent of the segment, denoted as a fixed-point number. + /// @param duration The time difference in seconds between the segment and the previous one. + struct SegmentWithDuration { + uint128 amount; + UD2x18 exponent; + uint40 duration; + } + + /// @notice Struct encapsulating the full details of a stream. + /// @dev Extends `Lockup.Stream` by including the recipient and the segments. + struct StreamLD { + address sender; + address recipient; + uint40 startTime; + uint40 endTime; + bool isCancelable; + bool wasCanceled; + IERC20 asset; + bool isDepleted; + bool isStream; + bool isTransferable; + Lockup.Amounts amounts; + Segment[] segments; + } + + /// @notice Struct encapsulating the LockupDynamic timestamps. + /// @param start The Unix timestamp indicating the stream's start. + /// @param end The Unix timestamp indicating the stream's end. + struct Timestamps { + uint40 start; + uint40 end; + } +} + +/// @notice Namespace for the structs used in {SablierV2LockupLinear}. +library LockupLinear { + /// @notice Struct encapsulating the parameters of the {SablierV2LockupLinear.createWithDurations} function. + /// @param sender The address distributing the assets, with the ability to cancel the stream. It doesn't have to be + /// the same as `msg.sender`. + /// @param recipient The address receiving the assets. + /// @param totalAmount The total amount of ERC-20 assets to be distributed, including the stream deposit and any + /// broker fee, denoted in units of the asset's decimals. + /// @param asset The contract address of the ERC-20 asset to be distributed. + /// @param cancelable Indicates if the stream is cancelable. + /// @param transferable Indicates if the stream NFT is transferable. + /// @param durations Struct encapsulating (i) cliff period duration and (ii) total stream duration, both in seconds. + /// @param broker Struct encapsulating (i) the address of the broker assisting in creating the stream, and (ii) the + /// percentage fee paid to the broker from `totalAmount`, denoted as a fixed-point number. Both can be set to zero. + struct CreateWithDurations { + address sender; + address recipient; + uint128 totalAmount; + IERC20 asset; + bool cancelable; + bool transferable; + Durations durations; + Broker broker; + } + + /// @notice Struct encapsulating the parameters of the {SablierV2LockupLinear.createWithTimestamps} function. + /// @param sender The address distributing the assets, with the ability to cancel the stream. It doesn't have to be + /// the same as `msg.sender`. + /// @param recipient The address receiving the assets. + /// @param totalAmount The total amount of ERC-20 assets to be distributed, including the stream deposit and any + /// broker fee, denoted in units of the asset's decimals. + /// @param asset The contract address of the ERC-20 asset to be distributed. + /// @param cancelable Indicates if the stream is cancelable. + /// @param transferable Indicates if the stream NFT is transferable. + /// @param timestamps Struct encapsulating (i) the stream's start time, (ii) cliff time, and (iii) end time, all as + /// Unix timestamps. + /// @param broker Struct encapsulating (i) the address of the broker assisting in creating the stream, and (ii) the + /// percentage fee paid to the broker from `totalAmount`, denoted as a fixed-point number. Both can be set to zero. + struct CreateWithTimestamps { + address sender; + address recipient; + uint128 totalAmount; + IERC20 asset; + bool cancelable; + bool transferable; + Timestamps timestamps; + Broker broker; + } + + /// @notice Struct encapsulating the cliff duration and the total duration. + /// @param cliff The cliff duration in seconds. + /// @param total The total duration in seconds. + struct Durations { + uint40 cliff; + uint40 total; + } + + /// @notice Struct encapsulating the full details of a stream. + /// @dev Extends `Lockup.Stream` by including the recipient and the cliff time. + struct StreamLL { + address sender; + address recipient; + uint40 startTime; + bool isCancelable; + bool wasCanceled; + IERC20 asset; + uint40 endTime; + bool isDepleted; + bool isStream; + bool isTransferable; + Lockup.Amounts amounts; + uint40 cliffTime; + } + + /// @notice Struct encapsulating the LockupLinear timestamps. + /// @param start The Unix timestamp for the stream's start. + /// @param cliff The Unix timestamp for the cliff period's end. A value of zero means there is no cliff. + /// @param end The Unix timestamp for the stream's end. + struct Timestamps { + uint40 start; + uint40 cliff; + uint40 end; + } +} + +/// @notice Namespace for the structs used in {SablierV2LockupTranched}. +library LockupTranched { + /// @notice Struct encapsulating the parameters of the {SablierV2LockupTranched.createWithDurations} function. + /// @param sender The address distributing the assets, with the ability to cancel the stream. It doesn't have to be + /// the same as `msg.sender`. + /// @param recipient The address receiving the assets. + /// @param totalAmount The total amount of ERC-20 assets to be distributed, including the stream deposit and any + /// broker fee, denoted in units of the asset's decimals. + /// @param asset The contract address of the ERC-20 asset to be distributed. + /// @param cancelable Indicates if the stream is cancelable. + /// @param transferable Indicates if the stream NFT is transferable. + /// @param tranches Tranches with durations used to compose the tranched distribution function. Timestamps are + /// calculated by starting from `block.timestamp` and adding each duration to the previous timestamp. + /// @param broker Struct encapsulating (i) the address of the broker assisting in creating the stream, and (ii) the + /// percentage fee paid to the broker from `totalAmount`, denoted as a fixed-point number. Both can be set to zero. + struct CreateWithDurations { + address sender; + address recipient; + uint128 totalAmount; + IERC20 asset; + bool cancelable; + bool transferable; + TrancheWithDuration[] tranches; + Broker broker; + } + + /// @notice Struct encapsulating the parameters of the {SablierV2LockupTranched.createWithTimestamps} function. + /// @param sender The address distributing the assets, with the ability to cancel the stream. It doesn't have to be + /// the same as `msg.sender`. + /// @param recipient The address receiving the assets. + /// @param totalAmount The total amount of ERC-20 assets to be distributed, including the stream deposit and any + /// broker fee, denoted in units of the asset's decimals. + /// @param asset The contract address of the ERC-20 asset to be distributed. + /// @param cancelable Indicates if the stream is cancelable. + /// @param transferable Indicates if the stream NFT is transferable. + /// @param startTime The Unix timestamp indicating the stream's start. + /// @param tranches Tranches used to compose the tranched distribution function. + /// @param broker Struct encapsulating (i) the address of the broker assisting in creating the stream, and (ii) the + /// percentage fee paid to the broker from `totalAmount`, denoted as a fixed-point number. Both can be set to zero. + struct CreateWithTimestamps { + address sender; + address recipient; + uint128 totalAmount; + IERC20 asset; + bool cancelable; + bool transferable; + uint40 startTime; + Tranche[] tranches; + Broker broker; + } + + /// @notice Struct encapsulating the full details of a stream. + /// @dev Extends `Lockup.Stream` by including the recipient and the tranches. + struct StreamLT { + address sender; + address recipient; + uint40 startTime; + uint40 endTime; + bool isCancelable; + bool wasCanceled; + IERC20 asset; + bool isDepleted; + bool isStream; + bool isTransferable; + Lockup.Amounts amounts; + Tranche[] tranches; + } + + /// @notice Struct encapsulating the LockupTranched timestamps. + /// @param start The Unix timestamp indicating the stream's start. + /// @param end The Unix timestamp indicating the stream's end. + struct Timestamps { + uint40 start; + uint40 end; + } + + /// @notice Tranche struct used in the Lockup Tranched stream. + /// @param amount The amount of assets to be unlocked in the tranche, denoted in units of the asset's decimals. + /// @param timestamp The Unix timestamp indicating the tranche's end. + struct Tranche { + // slot 0 + uint128 amount; + uint40 timestamp; + } + + /// @notice Tranche struct used at runtime in {SablierV2LockupTranched.createWithDurations}. + /// @param amount The amount of assets to be unlocked in the tranche, denoted in units of the asset's decimals. + /// @param duration The time difference in seconds between the tranche and the previous one. + struct TrancheWithDuration { + uint128 amount; + uint40 duration; + } +} From c39571ac6379c136cfb9d24258d6b450d9ceb4f1 Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Wed, 9 Oct 2024 12:23:58 -0400 Subject: [PATCH 16/21] Use new Sablier interfaces in DecentSablierStreamManagement --- contracts/DecentSablierStreamManagement.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/DecentSablierStreamManagement.sol b/contracts/DecentSablierStreamManagement.sol index 24f44142..dcbafe7e 100644 --- a/contracts/DecentSablierStreamManagement.sol +++ b/contracts/DecentSablierStreamManagement.sol @@ -3,8 +3,8 @@ pragma solidity =0.8.19; import {Enum} from "@gnosis.pm/safe-contracts/contracts/common/Enum.sol"; import {IAvatar} from "@gnosis.pm/zodiac/contracts/interfaces/IAvatar.sol"; -import {ISablierV2Lockup} from "./interfaces/sablier/ISablierV2Lockup.sol"; -import {LockupLinear2} from "./interfaces/sablier/LockupLinear2.sol"; +import {ISablierV2Lockup} from "./interfaces/sablier/full/ISablierV2Lockup.sol"; +import {Lockup} from "./interfaces/sablier/full/types/DataTypes.sol"; contract DecentSablierStreamManagement { string public constant NAME = "DecentSablierStreamManagement"; @@ -42,10 +42,10 @@ contract DecentSablierStreamManagement { function cancelStream(ISablierV2Lockup sablier, uint256 streamId) public { // Check if the stream can be cancelled - LockupLinear2.Status streamStatus = sablier.statusOf(streamId); + Lockup.Status streamStatus = sablier.statusOf(streamId); if ( - streamStatus != LockupLinear2.Status.PENDING && - streamStatus != LockupLinear2.Status.STREAMING + streamStatus != Lockup.Status.PENDING && + streamStatus != Lockup.Status.STREAMING ) { return; } From a4c4141f287ccc08a35ad90b21318843f25f6240 Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Wed, 9 Oct 2024 12:24:33 -0400 Subject: [PATCH 17/21] Remove newly unused Sablier interfaces --- .../interfaces/sablier/ISablierV2Lockup.sol | 26 ---------------- .../interfaces/sablier/LockupLinear2.sol | 31 ------------------- 2 files changed, 57 deletions(-) delete mode 100644 contracts/interfaces/sablier/ISablierV2Lockup.sol delete mode 100644 contracts/interfaces/sablier/LockupLinear2.sol diff --git a/contracts/interfaces/sablier/ISablierV2Lockup.sol b/contracts/interfaces/sablier/ISablierV2Lockup.sol deleted file mode 100644 index cce723e0..00000000 --- a/contracts/interfaces/sablier/ISablierV2Lockup.sol +++ /dev/null @@ -1,26 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; -import {LockupLinear2} from "../sablier/LockupLinear2.sol"; - -interface ISablierV2Lockup { - function withdrawableAmountOf( - uint256 streamId - ) external view returns (uint128 withdrawableAmount); - - function isCancelable(uint256 streamId) external view returns (bool result); - - function withdrawMax( - uint256 streamId, - address to - ) external returns (uint128 withdrawnAmount); - - function getStream( - uint256 streamId - ) external view returns (LockupLinear2.Stream memory); - - function cancel(uint256 streamId) external; - - function statusOf( - uint256 streamId - ) external view returns (LockupLinear2.Status status); -} diff --git a/contracts/interfaces/sablier/LockupLinear2.sol b/contracts/interfaces/sablier/LockupLinear2.sol deleted file mode 100644 index 2507cfb2..00000000 --- a/contracts/interfaces/sablier/LockupLinear2.sol +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -library LockupLinear2 { - struct Stream { - address sender; - uint40 startTime; - uint40 endTime; - uint40 cliffTime; - bool cancelable; - bool wasCanceled; - address asset; - bool transferable; - uint128 totalAmount; - address recipient; - } - - /// @notice Enum representing the different statuses of a stream. - /// @custom:value0 PENDING Stream created but not started; assets are in a pending state. - /// @custom:value1 STREAMING Active stream where assets are currently being streamed. - /// @custom:value2 SETTLED All assets have been streamed; recipient is due to withdraw them. - /// @custom:value3 CANCELED Canceled stream; remaining assets await recipient's withdrawal. - /// @custom:value4 DEPLETED Depleted stream; all assets have been withdrawn and/or refunded. - enum Status { - PENDING, - STREAMING, - SETTLED, - CANCELED, - DEPLETED - } -} From 84e6c4df75837a05797b77bfa2209078ed2e627e Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Wed, 9 Oct 2024 15:34:21 -0400 Subject: [PATCH 18/21] Add deployment script --- deploy/core/018_deploy_DecentSablierStreamManagement.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 deploy/core/018_deploy_DecentSablierStreamManagement.ts diff --git a/deploy/core/018_deploy_DecentSablierStreamManagement.ts b/deploy/core/018_deploy_DecentSablierStreamManagement.ts new file mode 100644 index 00000000..fb33345a --- /dev/null +++ b/deploy/core/018_deploy_DecentSablierStreamManagement.ts @@ -0,0 +1,9 @@ +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { DeployFunction } from "hardhat-deploy/types"; +import { deployNonUpgradeable } from "../helpers/deployNonUpgradeable"; + +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + await deployNonUpgradeable(hre, "DecentSablierStreamManagement"); +}; + +export default func; From c38c0b624c163b8ff51e667b24b4064ec4cfc126 Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Wed, 9 Oct 2024 17:23:38 -0400 Subject: [PATCH 19/21] Update Sablier contracts to their actual solidity pragma --- contracts/interfaces/sablier/full/IAdminable.sol | 2 +- contracts/interfaces/sablier/full/IERC4096.sol | 2 +- contracts/interfaces/sablier/full/ISablierV2Lockup.sol | 2 +- contracts/interfaces/sablier/full/ISablierV2NFTDescriptor.sol | 2 +- contracts/interfaces/sablier/full/types/DataTypes.sol | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/interfaces/sablier/full/IAdminable.sol b/contracts/interfaces/sablier/full/IAdminable.sol index e5ee0956..62a13d1a 100644 --- a/contracts/interfaces/sablier/full/IAdminable.sol +++ b/contracts/interfaces/sablier/full/IAdminable.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity >=0.8.19; +pragma solidity >=0.8.22; /// @title IAdminable /// @notice Contract module that provides a basic access control mechanism, with an admin that can be diff --git a/contracts/interfaces/sablier/full/IERC4096.sol b/contracts/interfaces/sablier/full/IERC4096.sol index 29bcc6ea..017e3b59 100644 --- a/contracts/interfaces/sablier/full/IERC4096.sol +++ b/contracts/interfaces/sablier/full/IERC4096.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // OpenZeppelin Contracts (last updated v5.0.0) (interfaces/IERC4906.sol) -pragma solidity ^0.8.19; +pragma solidity ^0.8.20; import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; diff --git a/contracts/interfaces/sablier/full/ISablierV2Lockup.sol b/contracts/interfaces/sablier/full/ISablierV2Lockup.sol index 8e5b21e3..af593567 100644 --- a/contracts/interfaces/sablier/full/ISablierV2Lockup.sol +++ b/contracts/interfaces/sablier/full/ISablierV2Lockup.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity >=0.8.19; +pragma solidity >=0.8.22; import {IERC4906} from "./IERC4096.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; diff --git a/contracts/interfaces/sablier/full/ISablierV2NFTDescriptor.sol b/contracts/interfaces/sablier/full/ISablierV2NFTDescriptor.sol index 49fec926..1d03bc95 100644 --- a/contracts/interfaces/sablier/full/ISablierV2NFTDescriptor.sol +++ b/contracts/interfaces/sablier/full/ISablierV2NFTDescriptor.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity >=0.8.19; +pragma solidity >=0.8.22; import {IERC721Metadata} from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; diff --git a/contracts/interfaces/sablier/full/types/DataTypes.sol b/contracts/interfaces/sablier/full/types/DataTypes.sol index b7b50c59..c150ccff 100644 --- a/contracts/interfaces/sablier/full/types/DataTypes.sol +++ b/contracts/interfaces/sablier/full/types/DataTypes.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity >=0.8.19; +pragma solidity >=0.8.22; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {UD2x18} from "@prb/math/src/UD2x18.sol"; From cf640158a1b39627f383522ebc809ce6c294c691 Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Wed, 9 Oct 2024 17:24:06 -0400 Subject: [PATCH 20/21] Support solidity version 0.8.28 in hardhat config --- hardhat.config.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/hardhat.config.ts b/hardhat.config.ts index 85d222f6..9f896ddf 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -13,13 +13,26 @@ const dummyPrivateKey = const config: HardhatUserConfig = { solidity: { - version: "0.8.19", - settings: { - optimizer: { - enabled: true, - runs: 200, + compilers: [ + { + version: "0.8.19", + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + }, }, - }, + { + version: "0.8.28", + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + }, + }, + ], }, dependencyCompiler: { paths: [ From 7348526ccc44a8de15b40328e5c2ba453b68d5ec Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Wed, 9 Oct 2024 17:23:55 -0400 Subject: [PATCH 21/21] Make DecentSablierStreamManagement solidity pragma use version 0.8.28 --- contracts/DecentSablierStreamManagement.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/DecentSablierStreamManagement.sol b/contracts/DecentSablierStreamManagement.sol index dcbafe7e..380d9b85 100644 --- a/contracts/DecentSablierStreamManagement.sol +++ b/contracts/DecentSablierStreamManagement.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity =0.8.19; +pragma solidity 0.8.28; import {Enum} from "@gnosis.pm/safe-contracts/contracts/common/Enum.sol"; import {IAvatar} from "@gnosis.pm/zodiac/contracts/interfaces/IAvatar.sol";