From 161779254cd5c9e5f7ca44e04967ed240b347c34 Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Thu, 5 Sep 2024 19:05:34 -0400 Subject: [PATCH] Beginnings of a DecentSablier module --- contracts/DecentSablier_0_1_0.sol | 42 +++++++ contracts/interfaces/sablier/ISablier.sol | 28 +++++ contracts/mock/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/mock/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/mock/MockSablier.sol b/contracts/mock/MockSablier.sol new file mode 100644 index 00000000..0d823195 --- /dev/null +++ b/contracts/mock/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") + ); + }); + }); + }); +});