fix: update the updateRoot method to accept a string instead of bytes32 (#56)

This commit is contained in:
moebius
2025-03-26 08:49:40 -06:00
committed by GitHub
9 changed files with 94 additions and 63 deletions

View File

@@ -67,8 +67,8 @@ contract UpdateRoot is Script {
// @notice The deployed Entrypoint
Entrypoint public entrypoint;
// @notice Placeholder IPFS hash
bytes32 public IPFS_HASH = keccak256('ipfs_hash');
// @notice Placeholder IPFS CID
string public IPFS_CID = 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_';
// @notice New computed root
uint256 public newRoot;
@@ -96,7 +96,7 @@ contract UpdateRoot is Script {
vm.startBroadcast();
// Update root
entrypoint.updateRoot(newRoot, IPFS_HASH);
entrypoint.updateRoot(newRoot, IPFS_CID);
vm.stopBroadcast();
}

View File

@@ -87,16 +87,17 @@ contract Entrypoint is AccessControlUpgradeable, UUPSUpgradeable, ReentrancyGuar
//////////////////////////////////////////////////////////////*/
/// @inheritdoc IEntrypoint
function updateRoot(uint256 _root, bytes32 _ipfsHash) external onlyRole(_ASP_POSTMAN) returns (uint256 _index) {
function updateRoot(uint256 _root, string memory _ipfsCID) external onlyRole(_ASP_POSTMAN) returns (uint256 _index) {
// Check provided values are non-zero
if (_root == 0) revert EmptyRoot();
if (_ipfsHash == 0) revert EmptyIPFSHash();
uint256 _cidLength = bytes(_ipfsCID).length;
if (_cidLength < 32 || _cidLength > 64) revert InvalidIPFSCIDLength();
// Push new association set and update index
associationSets.push(AssociationSetData(_root, _ipfsHash, block.timestamp));
associationSets.push(AssociationSetData(_root, _ipfsCID, block.timestamp));
_index = associationSets.length - 1;
emit RootUpdated(_root, _ipfsHash, block.timestamp);
emit RootUpdated(_root, _ipfsCID, block.timestamp);
}
/*///////////////////////////////////////////////////////////////

View File

@@ -43,12 +43,14 @@ interface IEntrypoint {
/**
* @notice Struct for the onchain association set data
* @param root The ASP root
* @param ipfsHash The IPFS hash of the ASP data
* @param ipfsCID The IPFS v1 CID of the ASP data. A content-addressed identifier computed by hashing
* the content with SHA-256, adding multicodec/multihash prefixes, and encoding in base32/58.
* This uniquely identifies data by its content rather than location.
* @param timestamp The timestamp on which the root was updated
*/
struct AssociationSetData {
uint256 root;
bytes32 ipfsHash;
string ipfsCID;
uint256 timestamp;
}
@@ -59,10 +61,10 @@ interface IEntrypoint {
/**
* @notice Emitted when pushing a new root to the association root set
* @param _root The latest ASP root
* @param _ipfsHash The IPFS hash of the association set data
* @param _ipfsCID The IPFS CID of the association set data
* @param _timestamp The timestamp of root update
*/
event RootUpdated(uint256 _root, bytes32 _ipfsHash, uint256 _timestamp);
event RootUpdated(uint256 _root, string _ipfsCID, uint256 _timestamp);
/**
* @notice Emitted when pushing a new root to the association root set
@@ -186,9 +188,9 @@ interface IEntrypoint {
error InvalidPoolState();
/**
* @notice Thrown when trying to push a root with an empty IPFS hash
* @notice Thrown when trying to push a an IPFS CID with an invalid length
*/
error EmptyIPFSHash();
error InvalidIPFSCIDLength();
/**
* @notice Thrown when trying to push a root with an empty root
@@ -244,10 +246,10 @@ interface IEntrypoint {
/**
* @notice Push a new root to the association root set
* @param _root The new ASP root
* @param _ipfsHash The IPFS hash of the association set data
* @param _ipfsCID The IPFS v1 CID of the association set data
* @return _index The index of the newly added root
*/
function updateRoot(uint256 _root, bytes32 _ipfsHash) external returns (uint256 _index);
function updateRoot(uint256 _root, string memory _ipfsCID) external returns (uint256 _index);
/**
* @notice Make a native asset deposit into the Privacy Pool
@@ -354,10 +356,13 @@ interface IEntrypoint {
* @notice Returns the association set data at an index
* @param _index The index of the array
* @return _root The updated ASP root
* @return _ipfsHash The IPFS hash for the association set data
* @return _ipfsCID The IPFS v1 CID for the association set data
* @return _timestamp The timestamp of the root update
*/
function associationSets(uint256 _index) external view returns (uint256 _root, bytes32 _ipfsHash, uint256 _timestamp);
function associationSets(uint256 _index)
external
view
returns (uint256 _root, string memory _ipfsCID, uint256 _timestamp);
/**
* @notice Returns the latest ASP root

View File

@@ -27,7 +27,7 @@ contract IntegrationERC20 is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Bob withdraws the total amount of Alice's commitment
_selfWithdraw(
@@ -53,7 +53,7 @@ contract IntegrationERC20 is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Bob receives the total amount of Alice's commitment
_withdrawThroughRelayer(
@@ -79,7 +79,7 @@ contract IntegrationERC20 is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Bob withdraws 2000 DAI of Alice's commitment
_selfWithdraw(
@@ -105,7 +105,7 @@ contract IntegrationERC20 is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Withdraw 2000 DAI to Bob
_commitment = _selfWithdraw(
@@ -167,7 +167,7 @@ contract IntegrationERC20 is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Bob receives half of Alice's commitment
_withdrawThroughRelayer(
@@ -193,7 +193,7 @@ contract IntegrationERC20 is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Withdraw 2000 DAI to Bob
_commitment = _withdrawThroughRelayer(
@@ -255,7 +255,9 @@ contract IntegrationERC20 is IntegrationBase {
// Push ASP root without label
vm.prank(_POSTMAN);
_entrypoint.updateRoot(uint256(keccak256('some_root')) % SNARK_SCALAR_FIELD, bytes32('IPFS_HASH'));
_entrypoint.updateRoot(
uint256(keccak256('some_root')) % SNARK_SCALAR_FIELD, 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid'
);
// Fail to withdraw
_withdrawThroughRelayer(
@@ -284,7 +286,7 @@ contract IntegrationERC20 is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Withdraw 4000 DAI through relayer
_commitment = _withdrawThroughRelayer(
@@ -300,7 +302,9 @@ contract IntegrationERC20 is IntegrationBase {
// Remove label from ASP
vm.prank(_POSTMAN);
_entrypoint.updateRoot(uint256(keccak256('some_root')) % SNARK_SCALAR_FIELD, bytes32('IPFS_HASH'));
_entrypoint.updateRoot(
uint256(keccak256('some_root')) % SNARK_SCALAR_FIELD, 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid'
);
// Fail to withdraw
_withdrawThroughRelayer(
@@ -329,7 +333,7 @@ contract IntegrationERC20 is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Fully spend child commitment
_selfWithdraw(
@@ -370,7 +374,7 @@ contract IntegrationERC20 is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Fail to withdraw commitment that was already ragequitted
_selfWithdraw(

View File

@@ -23,7 +23,7 @@ contract IntegrationNative is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Bob withdraws the total amount of Alice's commitment
_selfWithdraw(
@@ -49,7 +49,7 @@ contract IntegrationNative is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Bob receives withdraws total amount of Alice's commitment through a relayer
_withdrawThroughRelayer(
@@ -75,7 +75,7 @@ contract IntegrationNative is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Bob withdraws the total amount of Alice's commitment
_selfWithdraw(
@@ -101,7 +101,7 @@ contract IntegrationNative is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Withdraw 20 ETH to Bob
_commitment = _selfWithdraw(
@@ -175,7 +175,7 @@ contract IntegrationNative is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Bob receives the total amount of Alice's commitment through a relayer
_withdrawThroughRelayer(
@@ -201,7 +201,7 @@ contract IntegrationNative is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Withdraw 20 ETH to Bob
_commitment = _withdrawThroughRelayer(
@@ -275,7 +275,9 @@ contract IntegrationNative is IntegrationBase {
// Push ASP root without label
vm.prank(_POSTMAN);
_entrypoint.updateRoot(uint256(keccak256('some_root')) % SNARK_SCALAR_FIELD, bytes32('IPFS_HASH'));
_entrypoint.updateRoot(
uint256(keccak256('some_root')) % SNARK_SCALAR_FIELD, 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid'
);
// Fail to withdraw because the label is not included in the latest ASP root
_withdrawThroughRelayer(
@@ -304,7 +306,7 @@ contract IntegrationNative is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Withdraw 40 ETH through relayer
_commitment = _withdrawThroughRelayer(
@@ -320,7 +322,9 @@ contract IntegrationNative is IntegrationBase {
// Remove label from ASP
vm.prank(_POSTMAN);
_entrypoint.updateRoot(uint256(keccak256('some_root')) % SNARK_SCALAR_FIELD, bytes32('IPFS_HASH'));
_entrypoint.updateRoot(
uint256(keccak256('some_root')) % SNARK_SCALAR_FIELD, 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid'
);
// Fail to withdraw because label is not included in the latest ASP root
_withdrawThroughRelayer(
@@ -349,7 +353,7 @@ contract IntegrationNative is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Fully spend commitment
_selfWithdraw(
@@ -390,7 +394,7 @@ contract IntegrationNative is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Fail to withdraw commitment that was already ragequitted
_selfWithdraw(

View File

@@ -25,7 +25,7 @@ contract IntegrationProofs is IntegrationBase {
// Push ASP root with label included
vm.prank(_POSTMAN);
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), bytes32('IPFS_HASH'));
_entrypoint.updateRoot(_shadowASPMerkleTree._root(), 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
_withdrawal = IPrivacyPool.Withdrawal({processooor: _BOB, data: abi.encode(_BOB, address(0), 0)});

View File

@@ -59,7 +59,7 @@ contract Setup is HandlerActors, GhostStorage, FuzzUtils {
entrypoint.registerPool(token, IPrivacyPool(tokenPool), MIN_DEPOSIT, FEE_VETTING, MAX_RELAY_FEE);
vm.prank(POSTMAN);
entrypoint.updateRoot(1, 'a');
entrypoint.updateRoot(1, 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
createNewActors(5);

View File

@@ -90,8 +90,8 @@ contract EntrypointForTest is Entrypoint {
scopeToPool[_scope] = IPrivacyPool(_pool);
}
function mockAssociationSets(uint256 _root, bytes32 _ipfsHash) external {
associationSets.push(IEntrypoint.AssociationSetData({root: _root, ipfsHash: _ipfsHash, timestamp: block.timestamp}));
function mockAssociationSets(uint256 _root, string memory _ipfsCID) external {
associationSets.push(IEntrypoint.AssociationSetData({root: _root, ipfsCID: _ipfsCID, timestamp: block.timestamp}));
}
function mockMaxRelayFeeBPS(IERC20 _asset, uint256 _maxRelayFeeBPS) external {
@@ -220,49 +220,66 @@ contract UnitRootUpdate is UnitEntrypoint {
*/
function test_UpdateRootGivenValidRootAndIpfsHash(
uint256 _root,
bytes32 _ipfsHash,
string memory _ipfsCID,
uint256 _timestamp
) external givenCallerHasPostmanRole {
vm.assume(_root != 0);
vm.assume(_ipfsHash != 0);
uint256 _length = bytes(_ipfsCID).length;
vm.assume(_length >= 32 && _length <= 64);
vm.warp(_timestamp);
vm.expectEmit(address(_entrypoint));
emit IEntrypoint.RootUpdated(_root, _ipfsHash, _timestamp);
emit IEntrypoint.RootUpdated(_root, _ipfsCID, _timestamp);
uint256 _index = _entrypoint.updateRoot(_root, _ipfsHash);
(uint256 _retrievedRoot, bytes32 _retrievedIpfsHash, uint256 _retrievedTimestamp) = _entrypoint.associationSets(0);
uint256 _index = _entrypoint.updateRoot(_root, _ipfsCID);
(uint256 _retrievedRoot, string memory _retrievedIpfsCID, uint256 _retrievedTimestamp) =
_entrypoint.associationSets(0);
assertEq(_retrievedRoot, _root, 'Retrieved root should match input root');
assertEq(_retrievedIpfsHash, _ipfsHash, 'Retrieved IPFS hash should match input hash');
assertEq(_retrievedIpfsCID, _ipfsCID, 'Retrieved IPFS CID should match input CID');
assertEq(_retrievedTimestamp, _timestamp, 'Retrieved timestamp should match block timestamp');
assertEq(_index, 0, 'First root update should have index 0');
_index = _entrypoint.updateRoot(_root, _ipfsHash);
vm.expectEmit(address(_entrypoint));
emit IEntrypoint.RootUpdated(_root, 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid', _timestamp);
_index = _entrypoint.updateRoot(_root, 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
assertEq(_index, 1, 'Second root update should have index 1');
}
function test_UpdateRootWhenRootIsZero(bytes32 _ipfsHash) external givenCallerHasPostmanRole {
vm.assume(_ipfsHash != 0);
function test_UpdateRootWhenRootIsZero(string memory _ipfsCID) external givenCallerHasPostmanRole {
uint256 _length = bytes(_ipfsCID).length;
vm.assume(_length >= 32 && _length <= 64);
vm.expectRevert(abi.encodeWithSelector(IEntrypoint.EmptyRoot.selector));
_entrypoint.updateRoot(0, _ipfsHash);
_entrypoint.updateRoot(0, _ipfsCID);
}
/**
* @notice Test that the Entrypoint reverts when the IPFS hash is zero
*/
function test_UpdateRootWhenIpfsHashIsZero(uint256 _root) external givenCallerHasPostmanRole {
function test_UpdateRootWhenIpfsCIDHasInvalidLength(uint256 _root) external givenCallerHasPostmanRole {
vm.assume(_root != 0);
vm.expectRevert(abi.encodeWithSelector(IEntrypoint.EmptyIPFSHash.selector));
_entrypoint.updateRoot(_root, 0);
string memory _shortCID = 'This is a 31-byte string exampl';
assertEq(bytes(_shortCID).length, 31);
vm.expectRevert(abi.encodeWithSelector(IEntrypoint.InvalidIPFSCIDLength.selector));
_entrypoint.updateRoot(_root, _shortCID);
string memory _longCID = 'This string contains exactly sixty-five bytes for your testing ne';
assertEq(bytes(_longCID).length, 65);
vm.expectRevert(abi.encodeWithSelector(IEntrypoint.InvalidIPFSCIDLength.selector));
_entrypoint.updateRoot(_root, _longCID);
}
/**
* @notice Test that the Entrypoint reverts when the caller lacks the postman role
*/
function test_UpdateRootWhenCallerLacksPostmanRole(address _caller, uint256 _root, bytes32 _ipfsHash) external {
function test_UpdateRootWhenCallerLacksPostmanRole(address _caller, uint256 _root, string memory _ipfsCID) external {
vm.assume(_caller != _POSTMAN);
uint256 _length = bytes(_ipfsCID).length;
vm.assume(_length >= 32 && _length <= 64);
vm.expectRevert(
abi.encodeWithSelector(
@@ -270,7 +287,7 @@ contract UnitRootUpdate is UnitEntrypoint {
)
);
vm.prank(_caller);
_entrypoint.updateRoot(_root, _ipfsHash);
_entrypoint.updateRoot(_root, _ipfsCID);
}
}
@@ -1441,7 +1458,7 @@ contract UnitViewMethods is UnitEntrypoint {
*/
function test_LatestRootGivenAssociationSetsExist() external {
// Mock association set with root value 1
_entrypoint.mockAssociationSets(1, keccak256('ipfsHash'));
_entrypoint.mockAssociationSets(1, 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Verify latest root is returned correctly
assertEq(_entrypoint.latestRoot(), 1, 'Latest root should be 1');
@@ -1452,8 +1469,8 @@ contract UnitViewMethods is UnitEntrypoint {
*/
function test_RootByIndexGivenValidIndex() external {
// Mock multiple association sets with different roots
_entrypoint.mockAssociationSets(1, keccak256('ipfsHash'));
_entrypoint.mockAssociationSets(2, keccak256('ipfsHash'));
_entrypoint.mockAssociationSets(1, 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
_entrypoint.mockAssociationSets(2, 'ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid_ipfs_cid');
// Verify roots are returned correctly by index
assertEq(_entrypoint.rootByIndex(0), 1, 'First root should be 1');

View File

@@ -20,8 +20,8 @@ Entrypoint::updateRoot
│ │ └── It emits RootUpdated event
│ ├── When root is zero
│ │ └── It reverts with EmptyRoot
│ └── When ipfs hash is zero
│ └── It reverts with EmptyIPFSHash
│ └── When ipfs CID is longer or shorter than expected
│ └── It reverts with InvalidIPFSCIDLength
└── When caller lacks postman role
└── It reverts with AccessControlUnauthorizedAccount