Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DecentSablier module #100

Merged
merged 23 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ed62fee
Beginnings of a DecentSablier module
adamgall Sep 27, 2024
ff7adfa
Update contract and create some new Sablier interfaces
adamgall Sep 27, 2024
e0df21e
Remove unused interface, finish mock implementation
adamgall Sep 27, 2024
fa03691
Comment out new tests, for now
adamgall Sep 27, 2024
0f747ac
Update withdrawMaxFromStream
DarksightKellar Oct 4, 2024
67601b1
Add `cancelStream`
DarksightKellar Oct 4, 2024
da60def
Add (probably inaccurate) withdrawMaxFromStream tests
DarksightKellar Oct 8, 2024
01bdfd1
Add cancel stream tests (with 1 bug)
DarksightKellar Oct 8, 2024
868d785
bug fixes
DarksightKellar Oct 8, 2024
a9b8ba9
Fix tests
DarksightKellar Oct 8, 2024
fac2378
Cleanup, use more accurate code in sablier mock
DarksightKellar Oct 8, 2024
2f5bd7a
Autoformat typescript files
adamgall Oct 8, 2024
1524e94
Separate new and/or mock structs from existing and/or production code
DarksightKellar Oct 9, 2024
14b4c7f
Merge branch 'decent-sablier-0.1.0' of github.com:decentdao/decent-co…
DarksightKellar Oct 9, 2024
a1e23c4
Add PRBMath to package, for Sablier interfaces
adamgall Oct 9, 2024
1ec9650
Added minimum amount of "full" Sablier interfaces needed for DecentSa…
adamgall Oct 9, 2024
c39571a
Use new Sablier interfaces in DecentSablierStreamManagement
adamgall Oct 9, 2024
a4c4141
Remove newly unused Sablier interfaces
adamgall Oct 9, 2024
84e6c4d
Add deployment script
adamgall Oct 9, 2024
1dfb558
Merge branch 'develop' into decent-sablier-0.1.0
adamgall Oct 9, 2024
c38c0b6
Update Sablier contracts to their actual solidity pragma
adamgall Oct 9, 2024
cf64015
Support solidity version 0.8.28 in hardhat config
adamgall Oct 9, 2024
7348526
Make DecentSablierStreamManagement solidity pragma use version 0.8.28
adamgall Oct 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions contracts/DecentSablierStreamManagement.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// 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 {ISablierV2LockupLinear} from "./interfaces/sablier/ISablierV2LockupLinear.sol";
import {LockupLinear} from "./interfaces/sablier/LockupLinear.sol";

contract DecentSablierStreamManagement {
string public constant NAME = "DecentSablierStreamManagement";

function withdrawMaxFromStream(
ISablierV2LockupLinear sablier,
address recipientHatAccount,
uint256 streamId,
address to
) public {
// 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(
recipientHatAccount,
0,
abi.encodeWithSignature(
"execute(address,uint256,bytes,uint8)",
address(sablier),
0,
abi.encodeWithSignature(
"withdrawMax(uint256,address)",
streamId,
to
),
0
),
Enum.Operation.Call
);
}

function cancelStream(
ISablierV2LockupLinear sablier,
uint256 streamId
) public {
// Check if the stream can be cancelled
LockupLinear.Status streamStatus = sablier.statusOf(streamId);
if (
streamStatus != LockupLinear.Status.PENDING &&
streamStatus != LockupLinear.Status.STREAMING
) {
return;
}

IAvatar(msg.sender).execTransactionFromModule(
address(sablier),
0,
abi.encodeWithSignature("cancel(uint256)", streamId),
Enum.Operation.Call
);
}
}
26 changes: 26 additions & 0 deletions contracts/interfaces/sablier/ISablierV2Lockup.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {LockupLinear} from "../sablier/LockupLinear.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 (LockupLinear.Stream memory);

function cancel(uint256 streamId) external;

function statusOf(
uint256 streamId
) external view returns (LockupLinear.Status status);
}
3 changes: 2 additions & 1 deletion contracts/interfaces/sablier/ISablierV2LockupLinear.sol
Copy link
Member Author

@adamgall adamgall Oct 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DarksightKellar

So actually, since DecentHats_0_1_0 uses this interface, and we want to effectively "lock down" that contract from any changes in the future, we're not able to make any changes to this interface: it'll result in any newly compiled versions of DecentHats_0_1_0 having different bytecode from that which has already been deployed onchain, which will mean our deployment scripts will attempt to re-deploy it and then publish a new contract address in our NPM package. Which will break our front end smartAccount address prediction code.

This is definitely frustrating but I'm not sure of any way around it. If you want to make edits to this ISablierV2LockupLinerar.sol file, you need to copy the existing code into a new interface file then make the changes there.

You can see what I mean by attempting to run the deployment scripts on Sepolia (with this PR as-is). You'll see that it deploys a new instance of DecentHats_0_1_0. We need to avoid that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeahh I see what you mean. Alrighty I'll make the necessary changes

Copy link
Member Author

@adamgall adamgall Oct 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My recommendation is to create a new interface called ISablierV2LockupLinearFull.sol, and just copy in the whole-ass Interface structure from their official repo, to make sure we've got it properly future proofed.

I wasn't really thinking about this when creating the first ISablierV2LockupLinear.sol file for DecentHats_0_1_0, and so now we've gotta do this. Oops!

edit: might need to the same thing with LockupLinear.sol, because there's a reference to that one in DecentHats_0_1_0, too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adamgall XYZ_Full.sol with future-proof code sounds like the best plan (and name lol). Will update

Copy link
Contributor

@DarksightKellar DarksightKellar Oct 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adamgall Yeah this is gonna take a little more thought than a quick copy-paste. Not more difficult, just mismatching variables names and a potential package install (@prb/math/src/UD60x18.sol), or cherry picking which code to copy-paste instead of all.

I'll push what I have meantime, which uses XYZ.sol and XYZ_2.sol for locked original, and edited code respectively

Copy link
Member Author

@adamgall adamgall Oct 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DarksightKellar i've taken the liberty of finishing this cleanup effort. i think it's all good now

See these commits:

Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions contracts/interfaces/sablier/LockupLinear.sol
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,31 @@ 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;
}
Copy link
Member Author

@adamgall adamgall Oct 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DarksightKellar

Let's make sure that this struct (and any others that we've re-created into our various Sablier Interface files) are fully separated from our production contracts. Like, we have our actual contracts, and a handful of Mock contracts to assist with testing. In order to minimize our contract bytecode for the contracts we're deploying to mainnets, let's keep those different structs fully separate and import the necessary files into the necessary contracts.


/// @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
}
}
11 changes: 11 additions & 0 deletions contracts/mocks/MockHatsAccount.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
90 changes: 43 additions & 47 deletions contracts/mocks/MockSablierV2LockupLinear.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
DarksightKellar marked this conversation as resolved.
Show resolved Hide resolved
mapping(uint256 => LockupLinear.Stream) public streams;
uint256 public nextStreamId = 1;

// Add this event declaration at the contract level
Expand Down Expand Up @@ -49,12 +36,13 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear {
);

streamId = nextStreamId++;
streams[streamId] = Stream({
streams[streamId] = LockupLinear.Stream({
sender: params.sender,
recipient: params.recipient,
totalAmount: params.totalAmount,
asset: address(params.asset),
cancelable: params.cancelable,
wasCanceled: false,
transferable: params.transferable,
startTime: params.timestamps.start,
cliffTime: params.timestamps.cliff,
Expand All @@ -78,14 +66,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;
}
Expand All @@ -99,27 +89,35 @@ contract MockSablierV2LockupLinear is ISablierV2LockupLinear {
);
}

function withdraw(uint256 streamId, uint128 amount) external {
Stream storage stream = streams[streamId];
require(msg.sender == stream.recipient, "Only recipient can withdraw");
function withdrawMax(
uint256 streamId,
address to
) external returns (uint128 withdrawnAmount) {
withdrawnAmount = withdrawableAmountOf(streamId);
LockupLinear.Stream storage stream = streams[streamId];

require(
amount <= withdrawableAmountOf(streamId),
msg.sender == stream.recipient,
"Only recipient can call withdraw"
);
require(
withdrawnAmount <= withdrawableAmountOf(streamId),
"Insufficient withdrawable amount"
);

stream.totalAmount -= amount;
IERC20(stream.asset).transfer(stream.recipient, amount);
stream.totalAmount -= withdrawnAmount;
IERC20(stream.asset).transfer(to, withdrawnAmount);
}

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");

uint128 withdrawableAmount = withdrawableAmountOf(streamId);
uint128 refundAmount = stream.totalAmount - withdrawableAmount;

delete streams[streamId];
streams[streamId].wasCanceled = true;

if (withdrawableAmount > 0) {
IERC20(stream.asset).transfer(stream.recipient, withdrawableAmount);
Expand All @@ -129,31 +127,29 @@ 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];
function isCancelable(uint256 streamId) external view returns (bool) {
return streams[streamId].cancelable;
}

if (withdrawableAmount > 0) {
IERC20(stream.asset).transfer(stream.recipient, withdrawableAmount);
}
if (refundAmount > 0) {
IERC20(stream.asset).transfer(stream.sender, refundAmount);
/// @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;
}
}

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"
);
if (block.timestamp < streams[streamId].startTime) {
return LockupLinear.Status.PENDING;
}

stream.recipient = recipient;
if (block.timestamp < streams[streamId].endTime) {
return LockupLinear.Status.STREAMING;
} else {
return LockupLinear.Status.SETTLED;
}
}
}
56 changes: 11 additions & 45 deletions test/DecentHats_0_1_0.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,47 +29,11 @@ import {
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;

Expand Down Expand Up @@ -166,7 +130,7 @@ describe("DecentHats_0_1_0", () => {
await mockERC20.mint(gnosisSafeAddress, ethers.parseEther("1000000"));
});

describe("DecentHats as a Module", () => {
describe("DecentHats", () => {
let enableModuleTx: ethers.ContractTransactionResponse;

beforeEach(async () => {
Expand All @@ -182,14 +146,16 @@ describe("DecentHats_0_1_0", () => {
});
});

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", () => {
Expand Down
Loading