diff --git a/src/tokens/wrappers/clawback/ClawbackMetadata.sol b/src/tokens/wrappers/clawback/ClawbackMetadata.sol index c6ae45e..f36b318 100644 --- a/src/tokens/wrappers/clawback/ClawbackMetadata.sol +++ b/src/tokens/wrappers/clawback/ClawbackMetadata.sol @@ -4,6 +4,8 @@ pragma solidity ^0.8.19; import {IMetadataProvider} from "../../common/IMetadataProvider.sol"; import {IClawbackFunctions} from "./IClawback.sol"; +import {Duration} from "../../../utils/Duration.sol"; + import {LibString} from "solady/utils/LibString.sol"; import {Base64} from "solady/utils/Base64.sol"; @@ -71,16 +73,17 @@ contract ClawbackMetadata is IMetadataProvider, IERC165 { // From clawback bool hasTokenId = details.tokenType == IClawbackFunctions.TokenType.ERC721 || details.tokenType == IClawbackFunctions.TokenType.ERC1155; - properties = new MetadataProperty[](hasTokenId ? 8 : 7); + properties = new MetadataProperty[](hasTokenId ? 9 : 8); properties[0] = MetadataProperty("token_type", _toTokenTypeStr(details.tokenType)); properties[1] = MetadataProperty("token_address", details.tokenAddr.toHexStringChecksummed()); properties[2] = MetadataProperty("template_id", details.templateId.toString()); properties[3] = MetadataProperty("locked_at", details.lockedAt.toString()); - properties[4] = MetadataProperty("duration", template.duration.toString()); - properties[5] = MetadataProperty("destruction_only", _boolToString(template.destructionOnly)); - properties[6] = MetadataProperty("transfer_open", _boolToString(template.transferOpen)); + properties[4] = MetadataProperty("unlocks_in", _formatUnlocksAt(details.lockedAt, template.duration)); + properties[5] = MetadataProperty("duration", Duration.format(template.duration)); + properties[6] = MetadataProperty("destruction_only", _boolToString(template.destructionOnly)); + properties[7] = MetadataProperty("transfer_open", _boolToString(template.transferOpen)); if (hasTokenId) { - properties[7] = MetadataProperty("token_id", details.tokenId.toString()); + properties[8] = MetadataProperty("token_id", details.tokenId.toString()); } // From contract @@ -194,4 +197,18 @@ contract ClawbackMetadata is IMetadataProvider, IERC165 { } return false; } + + function _formatUnlocksAt(uint256 lockedAt, uint256 duration) internal view returns (string memory) { + uint256 unlocksAt = lockedAt + duration; + if (block.timestamp >= unlocksAt) { + return "Unlocked"; + } + + uint256 remaining = unlocksAt - block.timestamp; + if (remaining >= 999999 days) { + return "Never"; + } + + return Duration.format(remaining); + } } diff --git a/src/utils/Duration.sol b/src/utils/Duration.sol new file mode 100644 index 0000000..f9fce3a --- /dev/null +++ b/src/utils/Duration.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.19; + +import {LibString} from "solady/utils/LibString.sol"; + + +library Duration { + using LibString for *; + + function format(uint256 totalSeconds) internal pure returns (string memory) { + uint256 d = totalSeconds / (24 * 60 * 60); + uint256 h = (totalSeconds % (24 * 60 * 60)) / (60 * 60); + uint256 m = (totalSeconds % (60 * 60)) / 60; + uint256 s = totalSeconds % 60; + + string memory result; + + if (d > 0) { + result = string(abi.encodePacked(d.toString(), " days")); + } + if (h > 0) { + result = bytes(result).length > 0 + ? string(abi.encodePacked(result, ", ", h.toString(), " hours")) + : string(abi.encodePacked(h.toString(), " hours")); + } + if (m > 0) { + result = bytes(result).length > 0 + ? string(abi.encodePacked(result, ", ", m.toString(), " minutes")) + : string(abi.encodePacked(m.toString(), " minutes")); + } + if (s > 0) { + result = bytes(result).length > 0 + ? string(abi.encodePacked(result, ", ", s.toString(), " seconds")) + : string(abi.encodePacked(s.toString(), " seconds")); + } + + return result; + } +} diff --git a/test/tokens/wrappers/clawback/ClawbackMetadata.t.sol b/test/tokens/wrappers/clawback/ClawbackMetadata.t.sol index 070d4ba..34a49b7 100644 --- a/test/tokens/wrappers/clawback/ClawbackMetadata.t.sol +++ b/test/tokens/wrappers/clawback/ClawbackMetadata.t.sol @@ -5,6 +5,7 @@ import {ClawbackTestBase, IClawbackFunctions, ClawbackMetadata} from "./Clawback import {console, stdError} from "forge-std/Test.sol"; import {IMetadataProvider} from "src/tokens/common/IMetadataProvider.sol"; +import {Duration} from "src/utils/Duration.sol"; import {IERC165} from "@0xsequence/erc-1155/contracts/interfaces/IERC165.sol"; @@ -51,7 +52,7 @@ contract ClawbackMetadataTest is ClawbackTestBase { details.tokenAddr = address(erc20); ClawbackMetadata.MetadataProperty[] memory properties = clawbackMetadata.metadataProperties(details, template); - assertEq(properties.length, 10); + assertEq(properties.length, 11); _checkCommonProperties(properties, details, template, "ERC-20"); @@ -71,7 +72,7 @@ contract ClawbackMetadataTest is ClawbackTestBase { erc721.mint(address(this), details.tokenId, 1); ClawbackMetadata.MetadataProperty[] memory properties = clawbackMetadata.metadataProperties(details, template); - assertEq(properties.length, 11); + assertEq(properties.length, 12); _checkCommonProperties(properties, details, template, "ERC-721"); @@ -90,7 +91,7 @@ contract ClawbackMetadataTest is ClawbackTestBase { details.tokenAddr = address(erc1155); ClawbackMetadata.MetadataProperty[] memory properties = clawbackMetadata.metadataProperties(details, template); - assertEq(properties.length, 9); + assertEq(properties.length, 10); _checkCommonProperties(properties, details, template, "ERC-1155"); @@ -98,6 +99,78 @@ contract ClawbackMetadataTest is ClawbackTestBase { _hasProperty(properties, "original_URI", erc1155.uri(details.tokenId)); } + function testDurationAndUnlocksAt() public { + IClawbackFunctions.TokenDetails memory details; + IClawbackFunctions.Template memory template; + + vm.warp(1688184000); + + details.lockedAt = uint56(block.timestamp - 1 days); + template.duration = uint56(1 days); + + ClawbackMetadata.MetadataProperty[] memory properties; + + // Test when details.lockedAt is set to more than duration ago (unlocked) + details.lockedAt = uint56(block.timestamp - 200 days); + properties = clawbackMetadata.metadataProperties(details, template); + _hasProperty(properties, "unlocks_in", "Unlocked"); + _hasProperty(properties, "duration", "1 days"); + + // Test when details.lockedAt is set to just now (locked for 1 day) + details.lockedAt = uint56(block.timestamp); + properties = clawbackMetadata.metadataProperties(details, template); + _hasProperty(properties, "unlocks_in", "1 days"); + _hasProperty(properties, "duration", "1 days"); + + // Test when template.duration is set to a very high value (never unlocks) + template.duration = uint56(999999 days); + properties = clawbackMetadata.metadataProperties(details, template); + _hasProperty(properties, "unlocks_in", "Never"); + _hasProperty(properties, "duration", "999999 days"); + + // Test when template.duration is set to a small value and almost unlocked + template.duration = uint56(12); + details.lockedAt = uint56(block.timestamp - 11); + properties = clawbackMetadata.metadataProperties(details, template); + _hasProperty(properties, "unlocks_in", "1 seconds"); + _hasProperty(properties, "duration", "12 seconds"); + + // Test when template.duration is 0 (should be unlocked) + template.duration = uint56(0); + details.lockedAt = uint56(block.timestamp); + properties = clawbackMetadata.metadataProperties(details, template); + _hasProperty(properties, "unlocks_in", "Unlocked"); + _hasProperty(properties, "duration", ""); + + // Test for multiple units (e.g., 1 day, 3 hours, and 30 minutes) + template.duration = uint56(1 days + 3 hours + 30 minutes); + details.lockedAt = uint56(block.timestamp - 30 minutes); + properties = clawbackMetadata.metadataProperties(details, template); + _hasProperty(properties, "unlocks_in", "1 days, 3 hours"); + _hasProperty(properties, "duration", "1 days, 3 hours, 30 minutes"); + + // Test for a very short duration (e.g., 5 seconds) + template.duration = uint56(5 seconds + 1 minutes); + details.lockedAt = uint56(block.timestamp - 1 minutes); + properties = clawbackMetadata.metadataProperties(details, template); + _hasProperty(properties, "unlocks_in", "5 seconds"); + _hasProperty(properties, "duration", "1 minutes, 5 seconds"); + + // Test when duration includes all units (days, hours, minutes, and seconds) + template.duration = uint56(2 days + 5 hours + 10 minutes + 15 seconds); + details.lockedAt = uint56(block.timestamp); + properties = clawbackMetadata.metadataProperties(details, template); + _hasProperty(properties, "unlocks_in", "2 days, 5 hours, 10 minutes, 15 seconds"); + _hasProperty(properties, "duration", "2 days, 5 hours, 10 minutes, 15 seconds"); + + // Test for unlocking after a period less than a day (e.g., 10 hours) + template.duration = uint56(10 hours); + details.lockedAt = uint56(block.timestamp - 8 hours); + properties = clawbackMetadata.metadataProperties(details, template); + _hasProperty(properties, "unlocks_in", "2 hours"); + _hasProperty(properties, "duration", "10 hours"); + } + function _checkCommonProperties( ClawbackMetadata.MetadataProperty[] memory properties, IClawbackFunctions.TokenDetails memory details, @@ -110,15 +183,30 @@ contract ClawbackMetadataTest is ClawbackTestBase { _hasProperty(properties, "locked_at", details.lockedAt.toString()); _hasProperty(properties, "destruction_only", template.destructionOnly ? "true" : "false"); _hasProperty(properties, "transfer_open", template.transferOpen ? "true" : "false"); - _hasProperty(properties, "duration", template.duration.toString()); + _hasProperty(properties, "duration", Duration.format(template.duration)); + _hasProperty(properties, "unlocks_in", _formatUnlocksAt(details.lockedAt, template.duration)); + } + + function _formatUnlocksAt(uint256 lockedAt, uint256 duration) internal view returns (string memory) { + uint256 unlocksAt = lockedAt + duration; + if (block.timestamp >= unlocksAt) { + return "Unlocked"; + } + + uint256 remaining = unlocksAt - block.timestamp; + if (remaining >= 999999 days) { + return "Never"; + } + + return Duration.format(remaining); } function _hasProperty(ClawbackMetadata.MetadataProperty[] memory properties, string memory key, string memory value) internal { - bytes32 hasedKey = keccak256(abi.encodePacked(key)); + bytes32 hashedKey = keccak256(abi.encodePacked(key)); for (uint256 i = 0; i < properties.length; i++) { - if (keccak256(abi.encodePacked(properties[i].key)) == hasedKey) { + if (keccak256(abi.encodePacked(properties[i].key)) == hashedKey) { assertEq(properties[i].value, value, key); return; }