diff --git a/contracts/resolvers/DotnsContentResolver.sol b/contracts/resolvers/DotnsContentResolver.sol index 83df29b9d..ba91e7b7c 100644 --- a/contracts/resolvers/DotnsContentResolver.sol +++ b/contracts/resolvers/DotnsContentResolver.sol @@ -43,9 +43,14 @@ contract DotnsContentResolver is /// @notice Protocol-level address registry for all DotNS contracts. IDotnsProtocolRegistry public protocolRegistry; + /// @notice Block number of the last `setContenthash` per node; zero when never recorded. + /// @dev Added in 1.1.0, taking one slot from `__gap`. Hashes written by 1.0.0 have no + /// record here and read zero until their next write. + mapping(bytes32 node => uint64 blockNumber) private contenthashUpdatedAtBlocks; + /// @dev Reserved storage space to allow for layout changes in the future. // forge-lint: disable-next-line(mixed-case-variable) - uint256[50] private __gap; + uint256[49] private __gap; /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -68,6 +73,7 @@ contract DotnsContentResolver is function setContenthash(bytes32 node, bytes calldata hash) external override { _requireNodeOwnerOrOperator(node); contenthashes[node] = hash; + contenthashUpdatedAtBlocks[node] = uint64(block.number); emit ContentHashUpdated(node, hash); } @@ -76,6 +82,16 @@ contract DotnsContentResolver is return contenthashes[node]; } + /// @inheritdoc IDotnsContentResolver + function contenthashUpdatedAtBlock(bytes32 node) + external + view + override + returns (uint64 blockNumber) + { + return contenthashUpdatedAtBlocks[node]; + } + /// @inheritdoc IDotnsContentResolver function setText(bytes32 node, string calldata key, string calldata value) external override { _requireNodeOwnerOrOperator(node); @@ -137,7 +153,7 @@ contract DotnsContentResolver is /// @notice Returns implementation version. /// @return versionString Current version string. function version() external pure virtual returns (string memory versionString) { - versionString = "1.0.0"; + versionString = "1.1.0"; } /// @inheritdoc ERC165Upgradeable diff --git a/contracts/resolvers/IDotnsContentResolver.sol b/contracts/resolvers/IDotnsContentResolver.sol index f2d11d071..482816cda 100644 --- a/contracts/resolvers/IDotnsContentResolver.sol +++ b/contracts/resolvers/IDotnsContentResolver.sol @@ -33,8 +33,9 @@ interface IDotnsContentResolver { /// @notice Sets the content hash for a node. /// @dev The caller must own the node in the DotNS registry or be an approved operator, /// otherwise @custom:reverts NotAuthorised. Content hashes are opaque bytes (e.g. an - /// IPFS CID); the resolver stores them as-is and never interprets the payload. Emits - /// @custom:emits ContentHashUpdated on every successful write. + /// IPFS CID); the resolver stores them as-is and never interprets the payload. Every + /// successful write records the current block number for `contenthashUpdatedAtBlock` + /// and emits @custom:emits ContentHashUpdated. /// @param node The node whose content hash is being set. /// @param hash Opaque content hash bytes. function setContenthash(bytes32 node, bytes calldata hash) external; @@ -44,6 +45,15 @@ interface IDotnsContentResolver { /// @return hash The stored content hash bytes, or empty if unset. function contenthash(bytes32 node) external view returns (bytes memory hash); + /// @notice Returns the block in which a node's content hash was last written. + /// @dev Recorded by every successful `setContenthash`, so it answers "when did this hash + /// last change" without scanning `ContentHashUpdated` logs. Zero means no write has + /// been recorded: the node's hash was never set, or it was set before this field + /// existed and has not been rewritten since. + /// @param node The node to query. + /// @return blockNumber Block number of the last `setContenthash`, or zero if none recorded. + function contenthashUpdatedAtBlock(bytes32 node) external view returns (uint64 blockNumber); + /// @notice Sets a text record for a node. /// @dev The caller must own the node in the DotNS registry or be an approved operator, /// otherwise @custom:reverts NotAuthorised. Text records are arbitrary key/value strings diff --git a/test/unit/resolver/DotnsContentResolver.t.sol b/test/unit/resolver/DotnsContentResolver.t.sol index ed771a58a..1dd07cf93 100644 --- a/test/unit/resolver/DotnsContentResolver.t.sol +++ b/test/unit/resolver/DotnsContentResolver.t.sol @@ -72,4 +72,87 @@ contract DotnsContentResolverTests is BaseDotns { assertEq(dotnsContentResolver.text(node, textKey), textValue); } + + function test_contenthash_updated_at_block_is_zero_when_unset() public { + bytes32 node = _register("unsetblock01", ed, IPopRules.PopStatus.NoStatus); + + assertEq(dotnsContentResolver.contenthashUpdatedAtBlock(node), 0); + } + + function test_set_contenthash_records_block_number() public { + address nameOwner = ed; + + bytes32 node = _register("blockrecord01", nameOwner, IPopRules.PopStatus.NoStatus); + + bytes memory contentHash = + hex"e30101701220aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + vm.roll(1_234); + vm.startPrank(nameOwner); + dotnsContentResolver.setContenthash(node, contentHash); + vm.stopPrank(); + + assertEq(dotnsContentResolver.contenthashUpdatedAtBlock(node), 1_234); + } + + function test_set_contenthash_again_moves_block_number() public { + address nameOwner = ed; + + bytes32 node = _register("blockmove0001", nameOwner, IPopRules.PopStatus.NoStatus); + + bytes memory contentHash = + hex"e30101701220aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + vm.roll(100); + vm.startPrank(nameOwner); + dotnsContentResolver.setContenthash(node, contentHash); + vm.stopPrank(); + assertEq(dotnsContentResolver.contenthashUpdatedAtBlock(node), 100); + + // A rewrite of the identical hash is still a write and is recorded as one, matching + // ContentHashUpdated, which is emitted on every call rather than only on a change. + vm.roll(250); + vm.startPrank(nameOwner); + dotnsContentResolver.setContenthash(node, contentHash); + vm.stopPrank(); + assertEq(dotnsContentResolver.contenthashUpdatedAtBlock(node), 250); + } + + function test_set_text_does_not_touch_contenthash_block() public { + address nameOwner = ed; + + bytes32 node = _register("textnoblock01", nameOwner, IPopRules.PopStatus.NoStatus); + + bytes memory contentHash = + hex"e30101701220aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + vm.roll(40); + vm.startPrank(nameOwner); + dotnsContentResolver.setContenthash(node, contentHash); + vm.stopPrank(); + + vm.roll(80); + vm.startPrank(nameOwner); + dotnsContentResolver.setText(node, "url", "https://example.org"); + vm.stopPrank(); + + assertEq(dotnsContentResolver.contenthashUpdatedAtBlock(node), 40); + } + + function testFuzz_set_contenthash_records_any_block(uint64 blockNumber) public { + blockNumber = uint64(bound(blockNumber, 1, type(uint64).max)); + address nameOwner = ed; + + bytes32 node = _register("fuzzblock0001", nameOwner, IPopRules.PopStatus.NoStatus); + + bytes memory contentHash = + hex"e30101701220aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + vm.roll(blockNumber); + vm.startPrank(nameOwner); + dotnsContentResolver.setContenthash(node, contentHash); + vm.stopPrank(); + + assertEq(dotnsContentResolver.contenthashUpdatedAtBlock(node), blockNumber); + } }