diff --git a/contracts/modules/DecentHatsCreationModule.sol b/contracts/modules/DecentHatsCreationModule.sol index 5df50a4..cc638c7 100644 --- a/contracts/modules/DecentHatsCreationModule.sol +++ b/contracts/modules/DecentHatsCreationModule.sol @@ -99,7 +99,8 @@ contract DecentHatsCreationModule is DecentHatsModuleUtils { hatsElectionsEligibilityImplementation: treeParams .hatsElectionsEligibilityImplementation, adminHatId: adminHatId, - hats: treeParams.hats + hats: treeParams.hats, + keyValuePairs: treeParams.keyValuePairs }) ); } diff --git a/contracts/modules/DecentHatsModuleUtils.sol b/contracts/modules/DecentHatsModuleUtils.sol index 7436ba7..cd35665 100644 --- a/contracts/modules/DecentHatsModuleUtils.sol +++ b/contracts/modules/DecentHatsModuleUtils.sol @@ -9,6 +9,7 @@ import {IHats} from "../interfaces/hats/IHats.sol"; import {LockupLinear, Broker} from "../interfaces/sablier/types/DataTypes.sol"; import {IHatsModuleFactory} from "../interfaces/hats/IHatsModuleFactory.sol"; import {ISablierV2LockupLinear} from "../interfaces/sablier/ISablierV2LockupLinear.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; abstract contract DecentHatsModuleUtils { bytes32 public constant SALT = @@ -41,6 +42,7 @@ abstract contract DecentHatsModuleUtils { address hatsAccountImplementation; uint256 topHatId; address topHatAccount; + address keyValuePairs; IHatsModuleFactory hatsModuleFactory; address hatsElectionsEligibilityImplementation; uint256 adminHatId; @@ -86,7 +88,9 @@ abstract contract DecentHatsModuleUtils { // Create streams _processSablierStreams( hatParams.sablierStreamsParams, - streamRecipient + streamRecipient, + roleHatsParams.keyValuePairs, + hatId ); unchecked { @@ -205,7 +209,9 @@ abstract contract DecentHatsModuleUtils { function _processSablierStreams( SablierStreamParams[] memory streamParams, - address streamRecipient + address streamRecipient, + address keyValuePairs, + uint256 hatId ) private { for (uint256 i = 0; i < streamParams.length; ) { SablierStreamParams memory sablierStreamParams = streamParams[i]; @@ -221,6 +227,9 @@ abstract contract DecentHatsModuleUtils { ), Enum.Operation.Call ); + uint256 streamId = ISablierV2LockupLinear( + sablierStreamParams.sablier + ).nextStreamId(); // Proxy the Sablier call through IAvatar IAvatar(msg.sender).execTransactionFromModule( @@ -242,6 +251,29 @@ abstract contract DecentHatsModuleUtils { Enum.Operation.Call ); + // Update KeyValuePairs with the stream ID and Hat ID + string[] memory keys = new string[](1); + string[] memory values = new string[](1); + keys[0] = "hatIdToStreamId"; + values[0] = string( + abi.encodePacked( + Strings.toString(hatId), + ":", + Strings.toString(streamId) + ) + ); + + IAvatar(msg.sender).execTransactionFromModule( + keyValuePairs, + 0, + abi.encodeWithSignature( + "updateValues(string[],string[])", + keys, + values + ), + Enum.Operation.Call + ); + unchecked { ++i; } diff --git a/test/modules/DecentHatsModificationModule.test.ts b/test/modules/DecentHatsModificationModule.test.ts index 7ad920e..54118e3 100644 --- a/test/modules/DecentHatsModificationModule.test.ts +++ b/test/modules/DecentHatsModificationModule.test.ts @@ -282,6 +282,7 @@ describe('DecentHatsModificationModule', () => { hatsElectionsEligibilityImplementation: mockHatsElectionsEligibilityImplementationAddress, hatsModuleFactory: mockHatsModuleFactoryAddress, + keyValuePairs: await keyValuePairs.getAddress(), }, ], ), @@ -383,6 +384,7 @@ describe('DecentHatsModificationModule', () => { hatsElectionsEligibilityImplementation: mockHatsElectionsEligibilityImplementationAddress, hatsModuleFactory: mockHatsModuleFactoryAddress, + keyValuePairs: await keyValuePairs.getAddress(), }, ], ), diff --git a/test/modules/DecentHatsModuleUtils.test.ts b/test/modules/DecentHatsModuleUtils.test.ts index 6fc1d97..09053d0 100644 --- a/test/modules/DecentHatsModuleUtils.test.ts +++ b/test/modules/DecentHatsModuleUtils.test.ts @@ -19,6 +19,8 @@ import { MockERC20__factory, GnosisSafeL2, GnosisSafeL2__factory, + KeyValuePairs, + KeyValuePairs__factory, } from '../../typechain-types'; import { getGnosisSafeL2Singleton, getGnosisSafeProxyFactory } from '../GlobalSafeDeployments.test'; @@ -44,6 +46,8 @@ describe('DecentHatsModuleUtils', () => { let mockERC20: MockERC20; let gnosisSafe: GnosisSafeL2; let gnosisSafeAddress: string; + let keyValuePairs: KeyValuePairs; + let keyValuePairsAddress: string; let topHatId: bigint; let topHatAccount: string; @@ -62,7 +66,9 @@ describe('DecentHatsModuleUtils', () => { mockHatsModuleFactory = await new MockHatsModuleFactory__factory(deployer).deploy(); mockSablier = await new MockSablierV2LockupLinear__factory(deployer).deploy(); mockERC20 = await new MockERC20__factory(deployer).deploy('MockERC20', 'MCK'); - + // deploy keyValuePairs contract + keyValuePairs = await new KeyValuePairs__factory(deployer).deploy(); + keyValuePairsAddress = await keyValuePairs.getAddress(); // Deploy Safe const gnosisSafeProxyFactory = getGnosisSafeProxyFactory(); const gnosisSafeL2Singleton = getGnosisSafeL2Singleton(); @@ -190,6 +196,7 @@ describe('DecentHatsModuleUtils', () => { mockHatsElectionsEligibilityImplementationAddress, adminHatId, hats: [hatParams], + keyValuePairs: keyValuePairsAddress, }, ], ), @@ -235,6 +242,7 @@ describe('DecentHatsModuleUtils', () => { mockHatsElectionsEligibilityImplementationAddress, adminHatId, hats: [hatParams], + keyValuePairs: keyValuePairsAddress, }, ], ), @@ -274,7 +282,7 @@ describe('DecentHatsModuleUtils', () => { isMutable: false, }; - await executeSafeTransaction({ + const processRoleHatTx = await executeSafeTransaction({ safe: gnosisSafe, to: await mockDecentHatsModuleUtils.getAddress(), transactionData: MockDecentHatsModuleUtils__factory.createInterface().encodeFunctionData( @@ -291,6 +299,7 @@ describe('DecentHatsModuleUtils', () => { mockHatsElectionsEligibilityImplementationAddress, adminHatId, hats: [hatParams], + keyValuePairs: keyValuePairsAddress, }, ], ), @@ -306,9 +315,97 @@ describe('DecentHatsModuleUtils', () => { expect(stream1.startTime).to.equal(currentBlockTimestamp); expect(stream1.endTime).to.equal(currentBlockTimestamp + 2592000); + // get the last hat created event + const hatCreatedEvents = await mockHats.queryFilter(mockHats.filters.HatCreated()); + const hatId = hatCreatedEvents[hatCreatedEvents.length - 1].args.id; const event = streamCreatedEvents[0]; + + expect(event.args.sender).to.equal(await mockDecentHatsModuleUtils.getAddress()); + expect(event.args.totalAmount).to.equal(hre.ethers.parseEther('100')); + const expectedResult = `${hatId}:${streamCreatedEvents[0].args.streamId}`; + await expect(processRoleHatTx) + .to.emit(keyValuePairs, 'ValueUpdated') + .withArgs(gnosisSafeAddress, 'hatIdToStreamId', expectedResult); + }); + + it('Creates a termed hat with a stream', async () => { + const currentBlockTimestamp = (await hre.ethers.provider.getBlock('latest'))!.timestamp; + const termEndDateTs = BigInt(Math.floor(Date.now() / 1000) + 100000); + const hatParams = { + wearer: wearer.address, + details: '', + imageURI: '', + sablierStreamsParams: [ + { + sablier: await mockSablier.getAddress(), + sender: await mockDecentHatsModuleUtils.getAddress(), + asset: await mockERC20.getAddress(), + timestamps: { + start: currentBlockTimestamp, + cliff: 0, + end: currentBlockTimestamp + 2592000, // 30 days + }, + broker: { account: hre.ethers.ZeroAddress, fee: 0 }, + totalAmount: hre.ethers.parseEther('100'), + cancelable: true, + transferable: false, + }, + ], + termEndDateTs, + maxSupply: 1, + isMutable: false, + }; + + const roleHatId = await mockHats.getNextId(adminHatId); + const processRoleHatTx = await executeSafeTransaction({ + safe: gnosisSafe, + to: await mockDecentHatsModuleUtils.getAddress(), + transactionData: MockDecentHatsModuleUtils__factory.createInterface().encodeFunctionData( + 'processRoleHats', + [ + { + hatsProtocol: await mockHats.getAddress(), + erc6551Registry: await erc6551Registry.getAddress(), + hatsAccountImplementation: await mockHatsAccount.getAddress(), + topHatId, + topHatAccount, + hatsModuleFactory: await mockHatsModuleFactory.getAddress(), + hatsElectionsEligibilityImplementation: + mockHatsElectionsEligibilityImplementationAddress, + adminHatId, + hats: [hatParams], + keyValuePairs: keyValuePairsAddress, + }, + ], + ), + signers: [safeSigner], + }); + + expect(await mockHats.isWearerOfHat.staticCall(wearer.address, roleHatId)).to.equal(true); + expect(await mockHats.getHatEligibilityModule(roleHatId)).to.not.equal( + hre.ethers.ZeroAddress, + ); + + const streamCreatedEvents = await mockSablier.queryFilter( + mockSablier.filters.StreamCreated(), + ); + expect(streamCreatedEvents.length).to.equal(1); + + const stream1 = await mockSablier.getStream(streamCreatedEvents[0].args.streamId); + expect(stream1.startTime).to.equal(currentBlockTimestamp); + expect(stream1.endTime).to.equal(currentBlockTimestamp + 2592000); + + // get the last hat created event + const hatCreatedEvents = await mockHats.queryFilter(mockHats.filters.HatCreated()); + const hatId = hatCreatedEvents[hatCreatedEvents.length - 1].args.id; + const event = streamCreatedEvents[0]; + expect(event.args.sender).to.equal(await mockDecentHatsModuleUtils.getAddress()); expect(event.args.totalAmount).to.equal(hre.ethers.parseEther('100')); + const expectedResult = `${hatId}:${streamCreatedEvents[0].args.streamId}`; + await expect(processRoleHatTx) + .to.emit(keyValuePairs, 'ValueUpdated') + .withArgs(gnosisSafeAddress, 'hatIdToStreamId', expectedResult); }); });