diff --git a/packages/smartcontracts/contracts/StreamRegistry/StreamRegistryV3.sol b/packages/smartcontracts/contracts/StreamRegistry/StreamRegistryV3.sol new file mode 100644 index 000000000..caf395c25 --- /dev/null +++ b/packages/smartcontracts/contracts/StreamRegistry/StreamRegistryV3.sol @@ -0,0 +1,411 @@ +/** + * Upgraded on: not deployed yet + */ + +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; +pragma experimental ABIEncoderV2; +/* solhint-disable not-rely-on-time */ + +import "@openzeppelin/contracts-upgradeable/metatx/ERC2771ContextUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "../chainlinkClient/ENSCache.sol"; +import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +contract StreamRegistryV3 is Initializable, UUPSUpgradeable, ERC2771ContextUpgradeable, AccessControlUpgradeable { + + bytes32 public constant TRUSTED_ROLE = keccak256("TRUSTED_ROLE"); + uint256 constant public MAX_INT = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff; + + event StreamCreated(string id, string metadata); + event StreamDeleted(string id); + event StreamUpdated(string id, string metadata); + event PermissionUpdated(string streamId, address user, bool canEdit, bool canDelete, uint256 publishExpiration, uint256 subscribeExpiration, bool canGrant); + + enum PermissionType { Edit, Delete, Publish, Subscribe, Grant } + + struct Permission { + bool canEdit; + bool canDelete; + uint256 publishExpiration; + uint256 subscribeExpiration; + bool canGrant; + } + + // streamid -> keccak256(version, useraddress) -> permission struct above + mapping (string => mapping(bytes32 => Permission)) public streamIdToPermissions; + mapping (string => string) public streamIdToMetadata; + ENSCache private ensCache; + + // incremented when stream is (re-)created, so that users from old streams with same don't re-appear in the new stream (if they have permissions) + mapping (string => uint32) private streamIdToVersion; + + modifier hasGrantPermission(string calldata streamId) { + require(streamIdToPermissions[streamId][getAddressKey(streamId, _msgSender())].canGrant, "error_noSharePermission"); //|| + _; + } + modifier hasSharePermissionOrIsRemovingOwn(string calldata streamId, address user) { + require(streamIdToPermissions[streamId][getAddressKey(streamId, _msgSender())].canGrant || + _msgSender() == user, "error_noSharePermission"); //|| + _; + } + modifier hasDeletePermission(string calldata streamId) { + require(streamIdToPermissions[streamId][getAddressKey(streamId, _msgSender())].canDelete, "error_noDeletePermission"); //|| + _; + } + modifier hasEditPermission(string calldata streamId) { + require(streamIdToPermissions[streamId][getAddressKey(streamId, _msgSender())].canEdit, "error_noEditPermission"); //|| + _; + } + modifier streamExists(string calldata streamId) { + require(exists(streamId), "error_streamDoesNotExist"); + _; + } + modifier isTrusted() { + require(hasRole(TRUSTED_ROLE, _msgSender()), "error_mustBeTrustedRole"); + _; + } + + // Constructor can't be used with upgradeable contracts, so use initialize instead + // this will not be called upon each upgrade, only once during first deployment + function initialize(address ensCacheAddr, address trustedForwarderAddress) public initializer { + ensCache = ENSCache(ensCacheAddr); + __AccessControl_init(); + _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); + ERC2771ContextUpgradeable.__ERC2771Context_init(trustedForwarderAddress); + } + + function _authorizeUpgrade(address) internal override isTrusted() {} + + + function _msgSender() internal view virtual override(ContextUpgradeable, ERC2771ContextUpgradeable) returns (address sender) { + return super._msgSender(); + } + + function _msgData() internal view virtual override(ContextUpgradeable, ERC2771ContextUpgradeable) returns (bytes calldata) { + return super._msgData(); + } + + function setEnsCache(address ensCacheAddr) public isTrusted() { + ensCache = ENSCache(ensCacheAddr); + } + + function createStream(string calldata streamIdPath, string calldata metadataJsonString) public { + string memory ownerstring = addressToString(_msgSender()); + _createStreamAndPermission(_msgSender(), ownerstring, streamIdPath, metadataJsonString); + } + + function createStreamWithENS(string calldata ensName, string calldata streamIdPath, string calldata metadataJsonString) public { + if (ensCache.owners(ensName) == _msgSender()) { + _createStreamAndPermission(_msgSender(), ensName, streamIdPath, metadataJsonString); + } else { + ensCache.requestENSOwnerAndCreateStream(ensName, streamIdPath, metadataJsonString, _msgSender()); + } + } + + function exists(string calldata streamId) public view returns (bool) { + return bytes(streamIdToMetadata[streamId]).length != 0; + } + + /** + * Called by the ENSCache when the lookup / update is complete + */ + // solhint-disable-next-line func-name-mixedcase + function ENScreateStreamCallback(address ownerAddress, string memory ensName, string calldata streamIdPath, string calldata metadataJsonString) public isTrusted() { + require(ensCache.owners(ensName) == ownerAddress, "error_notOwnerOfENSName"); + _createStreamAndPermission(ownerAddress, ensName, streamIdPath, metadataJsonString); + } + + function _createStreamAndPermission(address ownerAddress, string memory ownerstring, string calldata streamIdPath, string calldata metadataJsonString) internal { + require(bytes(metadataJsonString).length != 0, "error_metadataJsonStringIsEmpty"); + + bytes memory pathBytes = bytes(streamIdPath); + for (uint i = 1; i < pathBytes.length; i++) { + // - . / 0 1 2 ... 9 + require((bytes1("-") <= pathBytes[i] && pathBytes[i] <= bytes1("9")) || + ((bytes1("A") <= pathBytes[i] && pathBytes[i] <= bytes1("Z"))) || + ((bytes1("a") <= pathBytes[i] && pathBytes[i] <= bytes1("z"))) || + pathBytes[i] == "_" + , "error_invalidPathChars"); + } + require(pathBytes[0] == "/", "error_pathMustStartWithSlash"); + + // abi.encodePacked does simple string concatenation here + string memory streamId = string(abi.encodePacked(ownerstring, streamIdPath)); + require(bytes(streamIdToMetadata[streamId]).length == 0, "error_streamAlreadyExists"); + + streamIdToVersion[streamId] = streamIdToVersion[streamId] + 1; + streamIdToMetadata[streamId] = metadataJsonString; + streamIdToPermissions[streamId][getAddressKey(streamId, ownerAddress)] = Permission({ + canEdit: true, + canDelete: true, + publishExpiration: MAX_INT, + subscribeExpiration: MAX_INT, + canGrant: true + }); + emit StreamCreated(streamId, metadataJsonString); + emit PermissionUpdated(streamId, ownerAddress, true, true, MAX_INT, MAX_INT, true); + } + + function getAddressKey(string memory streamId, address user) public view returns (bytes32) { + return keccak256(abi.encode(streamIdToVersion[streamId], user)); + } + + function updateStreamMetadata(string calldata streamId, string calldata metadata) public streamExists(streamId) hasEditPermission(streamId) { + streamIdToMetadata[streamId] = metadata; + emit StreamUpdated(streamId, metadata); + } + + function getStreamMetadata(string calldata streamId) public view streamExists(streamId) returns (string memory des) { + return streamIdToMetadata[streamId]; + } + + function deleteStream(string calldata streamId) public streamExists(streamId) hasDeletePermission(streamId) { + delete streamIdToMetadata[streamId]; + emit StreamDeleted(streamId); + } + + function getPermissionsForUser(string calldata streamId, address user) public view streamExists(streamId) returns (Permission memory permission) { + permission = streamIdToPermissions[streamId][getAddressKey(streamId, user)]; + Permission memory publicPermission = streamIdToPermissions[streamId][getAddressKey(streamId, address(0))]; + if (permission.publishExpiration < block.timestamp && publicPermission.publishExpiration >= block.timestamp) { + permission.publishExpiration = publicPermission.publishExpiration; + } + if (permission.subscribeExpiration < block.timestamp && publicPermission.subscribeExpiration >= block.timestamp) { + permission.subscribeExpiration = publicPermission.subscribeExpiration; + } + return permission; + } + + function getDirectPermissionsForUser(string calldata streamId, address user) public view streamExists(streamId) returns (Permission memory permission) { + return streamIdToPermissions[streamId][getAddressKey(streamId, user)]; + } + + function setPermissionsForUser(string calldata streamId, address user, bool canEdit, + bool deletePerm, uint256 publishExpiration, uint256 subscribeExpiration, bool canGrant) public hasGrantPermission(streamId) { + _setPermissionBooleans(streamId, user, canEdit, deletePerm, publishExpiration, subscribeExpiration, canGrant); + } + + function _setPermissionBooleans(string calldata streamId, address user, bool canEdit, + bool deletePerm, uint256 publishExpiration, uint256 subscribeExpiration, bool canGrant) private { + require(user != address(0) || !(canEdit || deletePerm || canGrant), + "error_publicCanOnlySubsPubl"); + Permission memory perm = Permission({ + canEdit: canEdit, + canDelete: deletePerm, + publishExpiration: publishExpiration, + subscribeExpiration: subscribeExpiration, + canGrant: canGrant + }); + streamIdToPermissions[streamId][getAddressKey(streamId, user)] = perm; + _cleanUpIfAllFalse(streamId, user, perm); + emit PermissionUpdated(streamId, user, canEdit, deletePerm, publishExpiration, subscribeExpiration, canGrant); + } + + function revokeAllPermissionsForUser(string calldata streamId, address user) public hasSharePermissionOrIsRemovingOwn(streamId, user){ + delete streamIdToPermissions[streamId][getAddressKey(streamId, user)]; + emit PermissionUpdated(streamId, user, false, false, 0, 0, false); + } + + function hasPermission(string calldata streamId, address user, PermissionType permissionType) public view returns (bool userHasPermission) { + return hasDirectPermission(streamId, user, permissionType) || + hasDirectPermission(streamId, address(0), permissionType); + } + + function hasPublicPermission(string calldata streamId, PermissionType permissionType) public view returns (bool userHasPermission) { + return hasDirectPermission(streamId, address(0), permissionType); + } + + function hasDirectPermission(string calldata streamId, address user, PermissionType permissionType) public view returns (bool userHasPermission) { + if (permissionType == PermissionType.Edit) { + return streamIdToPermissions[streamId][getAddressKey(streamId, user)].canEdit; + } + else if (permissionType == PermissionType.Delete) { + return streamIdToPermissions[streamId][getAddressKey(streamId, user)].canDelete; + } + else if (permissionType == PermissionType.Publish) { + return streamIdToPermissions[streamId][getAddressKey(streamId, user)].publishExpiration >= block.timestamp; + } + else if (permissionType == PermissionType.Subscribe) { + return streamIdToPermissions[streamId][getAddressKey(streamId, user)].subscribeExpiration >= block.timestamp; + } + else if (permissionType == PermissionType.Grant) { + return streamIdToPermissions[streamId][getAddressKey(streamId, user)].canGrant; + } + } + + function setPermissions(string calldata streamId, address[] calldata users, Permission[] calldata permissions) public hasGrantPermission(streamId) { + require(users.length == permissions.length, "error_invalidInputArrayLengths"); + uint arrayLength = users.length; + for (uint i=0; i 0 || permSender.subscribeExpiration > 0 || + permSender.canGrant, "error_noPermissionToTransfer"); + Permission memory permRecipient = streamIdToPermissions[streamId][getAddressKey(streamId, recipient)]; + uint256 publishExpiration = permSender.publishExpiration > permRecipient.publishExpiration ? permSender.publishExpiration : permRecipient.publishExpiration; + uint256 subscribeExpiration = permSender.subscribeExpiration > permRecipient.subscribeExpiration ? permSender.subscribeExpiration : permRecipient.subscribeExpiration; + _setPermissionBooleans(streamId, recipient, permSender.canEdit || permRecipient.canEdit, permSender.canDelete || permRecipient.canDelete, + publishExpiration, subscribeExpiration, permSender.canGrant || permRecipient.canGrant); + _setPermissionBooleans(streamId, _msgSender(), false, false, 0, 0, false); + } + + function transferPermissionToUser(string calldata streamId, address recipient, PermissionType permissionType) public { + require(hasDirectPermission(streamId, _msgSender(), permissionType), "error_noPermissionToTransfer"); + _setPermission(streamId, _msgSender(), permissionType, false); + _setPermission(streamId, recipient, permissionType, true); + } + + function trustedSetStreamMetadata(string calldata streamId, string calldata metadata) public isTrusted() { + streamIdToMetadata[streamId] = metadata; + emit StreamUpdated(streamId, metadata); + } + + function trustedCreateStreams(string[] calldata streamIds, string[] calldata metadatas) public isTrusted() { + uint arrayLength = streamIds.length; + for (uint i = 0; i < arrayLength; i++) { + streamIdToMetadata[streamIds[i]] = metadatas[i]; + emit StreamUpdated(streamIds[i], metadatas[i]); + } + } + + function trustedSetStreamWithPermission( + string calldata streamId, + string calldata metadata, + address user, + bool canEdit, + bool deletePerm, + uint256 publishExpiration, + uint256 subscribeExpiration, + bool canGrant + ) public isTrusted() { + streamIdToMetadata[streamId] = metadata; + _setPermissionBooleans(streamId, user, canEdit, deletePerm, publishExpiration, subscribeExpiration, canGrant); + emit StreamUpdated(streamId, metadata); + } + + function trustedSetPermissionsForUser( + string calldata streamId, + address user, + bool canEdit, + bool deletePerm, + uint256 publishExpiration, + uint256 subscribeExpiration, + bool canGrant + ) public isTrusted() { + _setPermissionBooleans(streamId, user, canEdit, deletePerm, publishExpiration, subscribeExpiration, canGrant); + } + + function trustedSetStreams(string[] calldata streamids, address[] calldata users, string[] calldata metadatas, Permission[] calldata permissions) public isTrusted() { + uint arrayLength = streamids.length; + for (uint i = 0; i < arrayLength; i++) { + string calldata streamId = streamids[i]; + streamIdToMetadata[streamId] = metadatas[i]; + Permission memory permission = permissions[i]; + _setPermissionBooleans(streamId, users[i], permission.canEdit, permission.canDelete, permission.publishExpiration, permission.subscribeExpiration, permission.canGrant); + emit StreamCreated(streamId, metadatas[i]); + emit PermissionUpdated(streamId, users[i], permission.canEdit, permission.canDelete, permission.publishExpiration, permission.subscribeExpiration, permission.canGrant); + } + } + + function trustedSetPermissions(string[] calldata streamids, address[] calldata users, Permission[] calldata permissions) public isTrusted() { + uint arrayLength = streamids.length; + for (uint i = 0; i < arrayLength; i++) { + string calldata streamId = streamids[i]; + Permission memory permission = permissions[i]; + _setPermissionBooleans(streamId, users[i], permission.canEdit, permission.canDelete, permission.publishExpiration, permission.subscribeExpiration, permission.canGrant); + emit PermissionUpdated(streamId, users[i], permission.canEdit, permission.canDelete, permission.publishExpiration, permission.subscribeExpiration, permission.canGrant); + } + } + + function addressToString(address _address) public pure returns(string memory) { + bytes32 _bytes = bytes32(uint256(uint160(_address))); + bytes memory _hex = "0123456789abcdef"; + bytes memory _string = new bytes(42); + _string[0] = "0"; + _string[1] = "x"; + for(uint i = 0; i < 20; i++) { + _string[2+i*2] = _hex[uint8(_bytes[i + 12] >> 4)]; + _string[3+i*2] = _hex[uint8(_bytes[i + 12] & 0x0f)]; + } + return string(_string); + } + + function getTrustedRole() public pure returns (bytes32) { + return TRUSTED_ROLE; + } +} diff --git a/packages/smartcontracts/test/StreamRegistry.test.ts b/packages/smartcontracts/test/StreamRegistry.test.ts index 37f79c987..88b5e58df 100644 --- a/packages/smartcontracts/test/StreamRegistry.test.ts +++ b/packages/smartcontracts/test/StreamRegistry.test.ts @@ -78,19 +78,30 @@ describe('StreamRegistry', (): void => { const trustedAddress: string = wallets[3].address const streamPath0: string = '/streamPath0' const streamPath1: string = '/streamPath1' + const streamPath2: string = '/streamPath2' const streamId0: string = adminAdress.toLowerCase() + streamPath0 const streamId1: string = adminAdress.toLowerCase() + streamPath1 + const streamId2: string = adminAdress.toLowerCase() + streamPath2 const metadata0: string = 'streammetadata0' const metadata1: string = 'streammetadata1' before(async (): Promise => { - minimalForwarderFromUser0 = await deployContract(wallets[1], ForwarderJson) as MinimalForwarder - const streamRegistryFactory = await ethers.getContractFactory('StreamRegistry') - const streamRegistryFactoryTx = await upgrades.deployProxy(streamRegistryFactory, + minimalForwarderFromUser0 = await deployContract(wallets[9], ForwarderJson) as MinimalForwarder + const streamRegistryFactoryV2 = await ethers.getContractFactory('StreamRegistryV2', wallets[0]) + const streamRegistryFactoryV2Tx = await upgrades.deployProxy(streamRegistryFactoryV2, ['0x0000000000000000000000000000000000000000', minimalForwarderFromUser0.address], { kind: 'uups' }) - registryFromAdmin = await streamRegistryFactoryTx.deployed() as StreamRegistry + registryFromAdmin = await streamRegistryFactoryV2Tx.deployed() as StreamRegistry + // to upgrade the deployer must also have the trusted role + // we will grant it and revoke it after the upgrade to keep admin and trusted roles separate + await registryFromAdmin.grantRole(await registryFromAdmin.TRUSTED_ROLE(), wallets[0].address) + const streamregistryFactoryV3 = await ethers.getContractFactory('StreamRegistryV3', wallets[0]) + const streamRegistryFactoryV3Tx = await upgrades.upgradeProxy(streamRegistryFactoryV2Tx.address, + streamregistryFactoryV3) + await registryFromAdmin.revokeRole(await registryFromAdmin.TRUSTED_ROLE(), wallets[0].address) + // eslint-disable-next-line require-atomic-updates + registryFromAdmin = await streamRegistryFactoryV3Tx.deployed() as StreamRegistry registryFromUser0 = registryFromAdmin.connect(wallets[1]) registryFromUser1 = registryFromAdmin.connect(wallets[2]) registryFromMigrator = registryFromAdmin.connect(wallets[3]) @@ -400,6 +411,11 @@ describe('StreamRegistry', (): void => { await registryFromAdmin.grantPublicPermission(streamId0, PermissionType.Subscribe) expect(await registryFromAdmin.getPermissionsForUser(streamId0, user0Address)) .to.deep.equal([false, false, BigNumber.from(MAX_INT), BigNumber.from(MAX_INT), false]) + expect(await registryFromAdmin.hasPublicPermission(streamId0, PermissionType.Publish)).to.equal(true) + expect(await registryFromAdmin.hasPublicPermission(streamId0, PermissionType.Subscribe)).to.equal(true) + expect(await registryFromAdmin.hasPublicPermission(streamId0, PermissionType.Edit)).to.equal(false) + expect(await registryFromAdmin.hasPublicPermission(streamId0, PermissionType.Delete)).to.equal(false) + expect(await registryFromAdmin.hasPublicPermission(streamId0, PermissionType.Share)).to.equal(false) }) it('negativetest grantPublicPermission', async (): Promise => { @@ -473,6 +489,42 @@ describe('StreamRegistry', (): void => { ) }) + it('positivetest setPermissionsMultipleStreams', async (): Promise => { + const userA = ethers.Wallet.createRandom().address + const userB = ethers.Wallet.createRandom().address + await registryFromAdmin.createStream(streamPath2, metadata1) + expect(await registryFromAdmin.getStreamMetadata(streamId2)).to.equal(metadata1) + const permissionA = { + canEdit: true, + canDelete: false, + publishExpiration: MAX_INT, + subscribeExpiration: MAX_INT, + canGrant: false + } + const permissionB = { + canEdit: false, + canDelete: true, + publishExpiration: 1, + subscribeExpiration: 1, + canGrant: true + } + + await registryFromAdmin.setPermissionsMultipleStreans([streamId0, streamId2], + [[userA, userB], [userA, userB]], [[permissionA, permissionB], [permissionA, permissionB]]) + expect(await registryFromAdmin.getDirectPermissionsForUser(streamId0, userA)).to.deep.equal( + [true, false, BigNumber.from(MAX_INT), BigNumber.from(MAX_INT), false] + ) + expect(await registryFromAdmin.getDirectPermissionsForUser(streamId0, userB)).to.deep.equal( + [false, true, BigNumber.from(1), BigNumber.from(1), true] + ) + expect(await registryFromAdmin.getDirectPermissionsForUser(streamId2, userA)).to.deep.equal( + [true, false, BigNumber.from(MAX_INT), BigNumber.from(MAX_INT), false] + ) + expect(await registryFromAdmin.getDirectPermissionsForUser(streamId2, userB)).to.deep.equal( + [false, true, BigNumber.from(1), BigNumber.from(1), true] + ) + }) + // negativetest setPublicPermission is trivial, was tested in setPermissionsForUser negativetest it('positivetest trustedRoleSetStream', async (): Promise => { expect(await registryFromAdmin.getPermissionsForUser(streamId0, trustedAddress)) @@ -484,6 +536,10 @@ describe('StreamRegistry', (): void => { .to.deep.equal(metadata1) }) + it('positivetest getTrustedRoleId', async (): Promise => { + expect(await registryFromAdmin.getTrustedRole()).to.equal('0x2de84d9fbdf6d06e2cc584295043dbd76046423b9f8bae9426d4fa5e7c03f4a7') + }) + it('positivetest trustedRoleSetPermissionsForUser', async (): Promise => { expect(await registryFromAdmin.getPermissionsForUser(streamId0, trustedAddress)) .to.deep.equal([false, false, BigNumber.from(0), BigNumber.from(0), false]) @@ -819,7 +875,7 @@ describe('StreamRegistry', (): void => { .to.equal(false) }) - it('positiveTest test bulk migrate', async (): Promise => { + it('positiveTest trustedSetStreams', async (): Promise => { const STREAMS_TO_MIGRATE = 50 const streamIds: string[] = [] const users: string[] = [] @@ -843,4 +899,44 @@ describe('StreamRegistry', (): void => { expect(await registryFromAdmin.getStreamMetadata(streamIds[i])).to.equal(metadatas[i]) } }) + + it('positiveTest trustedSetPermissions', async (): Promise => { + const STREAMS_TO_MIGRATE = 50 + const streamIds: string[] = [] + const users: string[] = [] + const metadatas: string[] = [] + const permissions = [] + for (let i = 0; i < STREAMS_TO_MIGRATE; i++) { + const user = Wallet.createRandom() + streamIds.push(`${user.address}/streamidbulkmigrate/id${i}`) + users.push(user.address) + metadatas.push(`metadata-${i}`) + permissions.push({ + canEdit: true, + canDelete: true, + publishExpiration: MAX_INT, + subscribeExpiration: MAX_INT, + canGrant: true + }) + } + await registryFromMigrator.trustedCreateStreams(streamIds, metadatas) + await registryFromMigrator.trustedSetPermissions(streamIds, users, permissions) + for (let i = 0; i < STREAMS_TO_MIGRATE; i++) { + expect(await registryFromAdmin.getStreamMetadata(streamIds[i])).to.equal(metadatas[i]) + } + }) + + it('negativetest trustedSetPermissions', async (): Promise => { + const permissions = { + canEdit: true, + canDelete: true, + publishExpiration: MAX_INT, + subscribeExpiration: MAX_INT, + canGrant: true + } + await expect(registryFromUser0.trustedCreateStreams([`${user0Address}/test`], ['meta'])) + .to.be.revertedWith('error_mustBeTrustedRole') + await expect(registryFromUser0.trustedSetPermissions([`${user0Address}/test`], [user0Address], [permissions])) + .to.be.revertedWith('error_mustBeTrustedRole') + }) })