// SPDX-License-Identifier: GPLv3 pragma solidity 0.8.15; import "@openzeppelin/contracts/utils/Base64.sol"; import "@openzeppelin/contracts/utils/Strings.sol"; import "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; interface IPangolinStakingPositions { struct ValueVariables { uint96 balance; uint160 sumOfEntryTimes; } struct RewardSummations { uint256 idealPosition; uint256 rewardPerValue; } struct Position { ValueVariables valueVariables; RewardSummations rewardSummationsPaid; uint160 previousValues; uint48 lastUpdate; uint48 lastDevaluation; } function positions(uint256 positionId) external view returns (Position memory); function positionPendingRewards(uint256 positionId) external view returns (uint256); function positionRewardRate(uint256 positionId) external view returns (uint256); } contract TokenMetadata is AccessControlEnumerable { using Strings for uint256; bytes32 public constant METADATA_ROLE = keccak256("METADATA_ROLE"); uint256[10] private exponents = [ 0, 2_718, 7_389, 20_086, 54_598, 148_413, 403_429, 1096_633, 2980_958, 8103_084 ]; uint256 private constant DENOMINATOR = 1_000; bytes1 public thousandSeparator = ","; string public description = "This NFT perpetually receives share from the revenue generated by Pangolin. The share of the position is positively correlated to its staked balance and staking duration."; string public externalUri = "https://app.pangolin.exchange/#/stakev2"; string public regularFontUri = "https://fonts.gstatic.com/s/poppins/v20/pxiEyp8kv8JHgFVrJJfecg.woff2"; string public boldFontUri = "https://fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLCz7Z1xlFQ.woff2"; string public imageBaseUri = "https://static.pangolin.exchange/panguardian/"; string public imageExtension = ".png"; string public tokenSymbol; constructor(address newAdmin, string memory newTokenSymbol) { require(newAdmin != address(0)); _grantRole(DEFAULT_ADMIN_ROLE, newAdmin); _grantRole(METADATA_ROLE, newAdmin); require(bytes(newTokenSymbol).length <= 32); tokenSymbol = newTokenSymbol; } function setThousandSeparator(bytes1 newThousandSeparator) external onlyRole(METADATA_ROLE) { thousandSeparator = newThousandSeparator; } function setDescription(string memory newDescription) external onlyRole(METADATA_ROLE) { description = newDescription; } function setExternalUri(string memory newExternalUri) external onlyRole(METADATA_ROLE) { externalUri = newExternalUri; } function setFont(string memory newRegularFontUri, string memory newBoldFontUri) external onlyRole(METADATA_ROLE) { regularFontUri = newRegularFontUri; boldFontUri = newBoldFontUri; } function setImage(string memory newImageBaseUri, string memory newImageExtension) external onlyRole(METADATA_ROLE) { imageBaseUri = newImageBaseUri; imageExtension = newImageExtension; } function tokenURI(IPangolinStakingPositions pangolinStakingPositions, uint256 positionId) external view returns (string memory) { IPangolinStakingPositions.ValueVariables memory positionValueVariables = pangolinStakingPositions .positions(positionId) .valueVariables; uint256 balance = positionValueVariables.balance; uint256 entryTime = balance == 0 ? block.timestamp : positionValueVariables.sumOfEntryTimes / balance; uint256 apr = balance == 0 ? 0 : (pangolinStakingPositions.positionRewardRate(positionId) * 365 days * 100) / balance; uint256 earned = parseEther(pangolinStakingPositions.positionPendingRewards(positionId)); balance = parseEther(balance); uint256 balanceLevel = getExponent(balance, 404); // level 6 is reached at ~60k PNG. uint256 durationLevel = getExponent((block.timestamp - entryTime), 2 hours + 40 minutes); // level 10 is reached in ~900 days. string memory svg; { bytes memory svgPart1 = abi.encodePacked( ' $', tokenSymbol, ': ', addThousandSeparator(balance), ' APR: ', addThousandSeparator(apr), '% EARNED: $', tokenSymbol, ' ', addThousandSeparator(earned), " " ); svg = Base64.encode(abi.encodePacked(svgPart1, svgPart2)); } string memory json; { json = Base64.encode( abi.encodePacked( '{"name":"Pangolin Staking Position #', positionId.toString(), '","description":"', description, '","external_url":"', externalUri, '","attributes":[{"trait_type":"Balance","value":', balance.toString(), '},{"display_type":"date","trait_type":"Entry Time","value":', entryTime.toString(), '},{"display_type":"number","max_value":"9","trait_type":"Balance Level","value":', balanceLevel.toString(), '},{"display_type":"number","max_value":"9","trait_type":"Duration Level","value":', durationLevel.toString(), '}],"image":"data:image/svg+xml;base64,', svg, '"}\n' ) ); } return string(abi.encodePacked("data:application/json;base64,", json)); } function addThousandSeparator(uint256 value) private view returns (string memory) { bytes memory digits = bytes(value.toString()); uint256 digitsLength = digits.length; uint256 separatorsLength = digitsLength <= 3 ? 0 : (digitsLength - 1) / 3; uint256 shift = digitsLength % 3; uint256 separatorCount = 0; bytes memory buffer = new bytes(digitsLength + separatorsLength); for (uint256 i = 0; i < digitsLength; ++i) { if (i != 0 && i >= shift && i % 3 == shift) { ++separatorCount; buffer[i + separatorCount - 1] = thousandSeparator; } buffer[i + separatorCount] = digits[i]; } return string(buffer); } function getImageUri(uint256 balanceLevel, uint256 durationLevel) private view returns (string memory) { return string( abi.encodePacked( imageBaseUri, balanceLevel.toString(), durationLevel.toString(), imageExtension ) ); } function getExponent(uint256 value, uint256 multiplier) private view returns (uint256) { if (value * DENOMINATOR >= exponents[9] * multiplier) return 9; if (value * DENOMINATOR >= exponents[8] * multiplier) return 8; if (value * DENOMINATOR >= exponents[7] * multiplier) return 7; if (value * DENOMINATOR >= exponents[6] * multiplier) return 6; if (value * DENOMINATOR >= exponents[5] * multiplier) return 5; if (value * DENOMINATOR >= exponents[4] * multiplier) return 4; if (value * DENOMINATOR >= exponents[3] * multiplier) return 3; if (value * DENOMINATOR >= exponents[2] * multiplier) return 2; if (value * DENOMINATOR >= exponents[1] * multiplier) return 1; return 0; } function parseEther(uint256 a) private pure returns (uint256) { return a % 1 ether >= 1 ether / 2 ? a / 1 ether + 1 : a / 1 ether; } }