diff --git a/.forge-snapshots/positionDescriptor bytecode size.snap b/.forge-snapshots/positionDescriptor bytecode size.snap index 6f097d73..4babbfb1 100644 --- a/.forge-snapshots/positionDescriptor bytecode size.snap +++ b/.forge-snapshots/positionDescriptor bytecode size.snap @@ -1 +1 @@ -31619 \ No newline at end of file +31728 \ No newline at end of file diff --git a/src/PositionDescriptor.sol b/src/PositionDescriptor.sol index 7cf39d94..7f1ac799 100644 --- a/src/PositionDescriptor.sol +++ b/src/PositionDescriptor.sol @@ -54,14 +54,17 @@ contract PositionDescriptor is IPositionDescriptor { } (, int24 tick,,) = poolManager.getSlot0(poolKey.toId()); + address currency0 = Currency.unwrap(poolKey.currency0); + address currency1 = Currency.unwrap(poolKey.currency1); + // If possible, flip currencies to get the larger currency as the base currency, so that the price (quote/base) is more readable // flip if currency0 priority is greater than currency1 priority - bool _flipRatio = flipRatio(Currency.unwrap(poolKey.currency0), Currency.unwrap(poolKey.currency1)); + bool _flipRatio = flipRatio(currency0, currency1); // If not flipped, quote currency is currency1, base currency is currency0 // If flipped, quote currency is currency0, base currency is currency1 - Currency quoteCurrency = !_flipRatio ? poolKey.currency1 : poolKey.currency0; - Currency baseCurrency = !_flipRatio ? poolKey.currency0 : poolKey.currency1; + address quoteCurrency = !_flipRatio ? currency1 : currency0; + address baseCurrency = !_flipRatio ? currency0 : currency1; return Descriptor.constructTokenURI( Descriptor.ConstructTokenURIParams({ diff --git a/src/libraries/Descriptor.sol b/src/libraries/Descriptor.sol index b3d5ef29..ba261f85 100644 --- a/src/libraries/Descriptor.sol +++ b/src/libraries/Descriptor.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0; -import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol"; import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol"; @@ -23,8 +22,8 @@ library Descriptor { struct ConstructTokenURIParams { uint256 tokenId; - Currency quoteCurrency; - Currency baseCurrency; + address quoteCurrency; + address baseCurrency; string quoteCurrencySymbol; string baseCurrencySymbol; uint8 quoteCurrencyDecimals; @@ -52,8 +51,8 @@ library Descriptor { string memory descriptionPartTwo = generateDescriptionPartTwo( params.tokenId.toString(), escapeSpecialCharacters(params.baseCurrencySymbol), - addressToString(Currency.unwrap(params.quoteCurrency)), - addressToString(Currency.unwrap(params.baseCurrency)), + params.quoteCurrency == address(0) ? "Native" : addressToString(params.quoteCurrency), + params.baseCurrency == address(0) ? "Native" : addressToString(params.baseCurrency), params.hooks == address(0) ? "No Hook" : addressToString(params.hooks), feeToPercentString(params.fee) ); @@ -462,8 +461,8 @@ library Descriptor { /// @return svg The SVG image as a string function generateSVGImage(ConstructTokenURIParams memory params) internal pure returns (string memory svg) { SVG.SVGParams memory svgParams = SVG.SVGParams({ - quoteCurrency: addressToString(Currency.unwrap(params.quoteCurrency)), - baseCurrency: addressToString(Currency.unwrap(params.baseCurrency)), + quoteCurrency: addressToString(params.quoteCurrency), + baseCurrency: addressToString(params.baseCurrency), hooks: params.hooks, quoteCurrencySymbol: params.quoteCurrencySymbol, baseCurrencySymbol: params.baseCurrencySymbol, @@ -473,16 +472,16 @@ library Descriptor { tickSpacing: params.tickSpacing, overRange: overRange(params.tickLower, params.tickUpper, params.tickCurrent), tokenId: params.tokenId, - color0: currencyToColorHex(params.quoteCurrency.toId(), 136), - color1: currencyToColorHex(params.baseCurrency.toId(), 136), - color2: currencyToColorHex(params.quoteCurrency.toId(), 0), - color3: currencyToColorHex(params.baseCurrency.toId(), 0), - x1: scale(getCircleCoord(params.quoteCurrency.toId(), 16, params.tokenId), 0, 255, 16, 274), - y1: scale(getCircleCoord(params.baseCurrency.toId(), 16, params.tokenId), 0, 255, 100, 484), - x2: scale(getCircleCoord(params.quoteCurrency.toId(), 32, params.tokenId), 0, 255, 16, 274), - y2: scale(getCircleCoord(params.baseCurrency.toId(), 32, params.tokenId), 0, 255, 100, 484), - x3: scale(getCircleCoord(params.quoteCurrency.toId(), 48, params.tokenId), 0, 255, 16, 274), - y3: scale(getCircleCoord(params.baseCurrency.toId(), 48, params.tokenId), 0, 255, 100, 484) + color0: currencyToColorHex(uint256(uint160(params.quoteCurrency)), 136), + color1: currencyToColorHex(uint256(uint160(params.baseCurrency)), 136), + color2: currencyToColorHex(uint256(uint160(params.quoteCurrency)), 0), + color3: currencyToColorHex(uint256(uint160(params.baseCurrency)), 0), + x1: scale(getCircleCoord(uint256(uint160(params.quoteCurrency)), 16, params.tokenId), 0, 255, 16, 274), + y1: scale(getCircleCoord(uint256(uint160(params.baseCurrency)), 16, params.tokenId), 0, 255, 100, 484), + x2: scale(getCircleCoord(uint256(uint160(params.quoteCurrency)), 32, params.tokenId), 0, 255, 16, 274), + y2: scale(getCircleCoord(uint256(uint160(params.baseCurrency)), 32, params.tokenId), 0, 255, 100, 484), + x3: scale(getCircleCoord(uint256(uint160(params.quoteCurrency)), 48, params.tokenId), 0, 255, 16, 274), + y3: scale(getCircleCoord(uint256(uint160(params.baseCurrency)), 48, params.tokenId), 0, 255, 100, 484) }); return SVG.generateSVG(svgParams); diff --git a/src/libraries/SVG.sol b/src/libraries/SVG.sol index 90733ddc..2731a6ca 100644 --- a/src/libraries/SVG.sol +++ b/src/libraries/SVG.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0; -import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; import {BitMath} from "@uniswap/v4-core/src/libraries/BitMath.sol"; import {Strings} from "openzeppelin-contracts/contracts/utils/Strings.sol"; diff --git a/src/libraries/SafeCurrencyMetadata.sol b/src/libraries/SafeCurrencyMetadata.sol index b220bf29..cc88ab84 100644 --- a/src/libraries/SafeCurrencyMetadata.sol +++ b/src/libraries/SafeCurrencyMetadata.sol @@ -2,30 +2,26 @@ pragma solidity ^0.8.0; import {IERC20Metadata} from "openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; import {AddressStringUtil} from "./AddressStringUtil.sol"; /// @title SafeCurrencyMetadata /// @notice can produce symbols and decimals from inconsistent or absent ERC20 implementations /// @dev Reference: https://github.com/Uniswap/solidity-lib/blob/master/contracts/libraries/SafeERC20Namer.sol library SafeCurrencyMetadata { - using CurrencyLibrary for Currency; - uint8 constant MAX_SYMBOL_LENGTH = 12; - /// @notice attempts to extract the token symbol. if it does not implement symbol, returns a symbol derived from the address - /// @param currency The currency + /// @notice attempts to extract the currency symbol. if it does not implement symbol, returns a symbol derived from the address + /// @param currency The currency address /// @param nativeLabel The native label - /// @return the token symbol - function currencySymbol(Currency currency, string memory nativeLabel) internal view returns (string memory) { - if (currency.isAddressZero()) { + /// @return the currency symbol + function currencySymbol(address currency, string memory nativeLabel) internal view returns (string memory) { + if (currency == address(0)) { return nativeLabel; } - address currencyAddress = Currency.unwrap(currency); - string memory symbol = callAndParseStringReturn(currencyAddress, IERC20Metadata.symbol.selector); + string memory symbol = callAndParseStringReturn(currency, IERC20Metadata.symbol.selector); if (bytes(symbol).length == 0) { // fallback to 6 uppercase hex of address - return addressToSymbol(currencyAddress); + return addressToSymbol(currency); } if (bytes(symbol).length > MAX_SYMBOL_LENGTH) { return truncateSymbol(symbol); @@ -34,14 +30,13 @@ library SafeCurrencyMetadata { } /// @notice attempts to extract the token decimals, returns 0 if not implemented or not a uint8 - /// @param currency The currency - /// @return the token decimals - function currencyDecimals(Currency currency) internal view returns (uint8) { - if (currency.isAddressZero()) { + /// @param currency The currency address + /// @return the currency decimals + function currencyDecimals(address currency) internal view returns (uint8) { + if (currency == address(0)) { return 18; } - (bool success, bytes memory data) = - Currency.unwrap(currency).staticcall(abi.encodeCall(IERC20Metadata.decimals, ())); + (bool success, bytes memory data) = currency.staticcall(abi.encodeCall(IERC20Metadata.decimals, ())); if (!success) { return 0; } diff --git a/test/PositionDescriptor.t.sol b/test/PositionDescriptor.t.sol index bc991e7b..4b326814 100644 --- a/test/PositionDescriptor.t.sol +++ b/test/PositionDescriptor.t.sol @@ -12,9 +12,14 @@ import {PosmTestSetup} from "./shared/PosmTestSetup.sol"; import {ActionConstants} from "../src/libraries/ActionConstants.sol"; import {Base64} from "./base64.sol"; import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +import {SafeCurrencyMetadata} from "../src/libraries/SafeCurrencyMetadata.sol"; +import {AddressStringUtil} from "../src/libraries/AddressStringUtil.sol"; +import {Descriptor} from "../src/libraries/Descriptor.sol"; contract PositionDescriptorTest is Test, PosmTestSetup, GasSnapshot { using Base64 for string; + using CurrencyLibrary for Currency; address public WETH9 = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address public DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; @@ -119,10 +124,185 @@ contract PositionDescriptorTest is Test, PosmTestSetup, GasSnapshot { bytes memory data = vm.parseJson(json); Token memory token = abi.decode(data, (Token)); - assertEq(token.name, "Uniswap - 0.3% - TEST/TEST - 1.0060<>1.0121"); + // quote is currency1, base is currency0 + assertFalse(positionDescriptor.flipRatio(Currency.unwrap(key.currency0), Currency.unwrap(key.currency1))); + + string memory symbol0 = SafeCurrencyMetadata.currencySymbol(Currency.unwrap(currency0), nativeCurrencyLabel); + string memory symbol1 = SafeCurrencyMetadata.currencySymbol(Currency.unwrap(currency1), nativeCurrencyLabel); + string memory managerAddress = toHexString(address(manager)); + string memory currency0Address = toHexString(Currency.unwrap(currency0)); + string memory currency1Address = toHexString(Currency.unwrap(currency1)); + string memory id = uintToString(tokenId); + string memory hookAddress = address(key.hooks) == address(0) + ? "No Hook" + : string(abi.encodePacked("0x", toHexString(address(key.hooks)))); + string memory fee = Descriptor.feeToPercentString(key.fee); + string memory tickToDecimal0 = Descriptor.tickToDecimalString( + tickLower, + key.tickSpacing, + SafeCurrencyMetadata.currencyDecimals(Currency.unwrap(currency0)), + SafeCurrencyMetadata.currencyDecimals(Currency.unwrap(currency1)), + false + ); + string memory tickToDecimal1 = Descriptor.tickToDecimalString( + tickUpper, + key.tickSpacing, + SafeCurrencyMetadata.currencyDecimals(Currency.unwrap(currency0)), + SafeCurrencyMetadata.currencyDecimals(Currency.unwrap(currency1)), + false + ); + + assertEq( + token.name, + string( + abi.encodePacked( + "Uniswap - ", fee, " - ", symbol1, "/", symbol0, " - ", tickToDecimal0, "<>", tickToDecimal1 + ) + ) + ); + assertEq( + token.description, + string( + abi.encodePacked( + unicode"This NFT represents a liquidity position in a Uniswap v4 ", + symbol1, + "-", + symbol0, + " pool. The owner of this NFT can modify or redeem the position.\n\nPool Manager Address: ", + managerAddress, + "\n", + symbol1, + " Address: ", + currency1Address, + "\n", + symbol0, + " Address: ", + currency0Address, + "\nHook Address: ", + hookAddress, + "\nFee Tier: ", + fee, + "\nToken ID: ", + id, + "\n\n", + unicode"⚠️ DISCLAIMER: Due diligence is imperative when assessing this NFT. Make sure currency addresses match the expected currencies, as currency symbols may be imitated." + ) + ) + ); + } + + function test_native_tokenURI_succeeds() public { + (nativeKey,) = initPool(CurrencyLibrary.ADDRESS_ZERO, currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1); + int24 tickLower = int24(nativeKey.tickSpacing); + int24 tickUpper = int24(nativeKey.tickSpacing * 2); + uint256 amount0Desired = 100e18; + uint256 amount1Desired = 100e18; + uint256 liquidityToAdd = LiquidityAmounts.getLiquidityForAmounts( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(tickLower), + TickMath.getSqrtPriceAtTick(tickUpper), + amount0Desired, + amount1Desired + ); + + PositionConfig memory config = PositionConfig({poolKey: nativeKey, tickLower: tickLower, tickUpper: tickUpper}); + uint256 tokenId = lpm.nextTokenId(); + mintWithNative(SQRT_PRICE_1_1, config, liquidityToAdd, ActionConstants.MSG_SENDER, ZERO_BYTES); + + // The prefix length is calculated by converting the string to bytes and finding its length + uint256 prefixLength = bytes("data:application/json;base64,").length; + + string memory uri = positionDescriptor.tokenURI(lpm, tokenId); + // Convert the uri to bytes + bytes memory uriBytes = bytes(uri); + + // Slice the uri to get only the base64-encoded part + bytes memory base64Part = new bytes(uriBytes.length - prefixLength); + + for (uint256 i = 0; i < base64Part.length; i++) { + base64Part[i] = uriBytes[i + prefixLength]; + } + + // Decode the base64-encoded part + bytes memory decoded = Base64.decode(string(base64Part)); + string memory json = string(decoded); + + // decode json + bytes memory data = vm.parseJson(json); + Token memory token = abi.decode(data, (Token)); + + // quote is currency1, base is currency0 + assertFalse( + positionDescriptor.flipRatio(Currency.unwrap(nativeKey.currency0), Currency.unwrap(nativeKey.currency1)) + ); + + string memory symbol0 = + SafeCurrencyMetadata.currencySymbol(Currency.unwrap(nativeKey.currency0), nativeCurrencyLabel); + string memory symbol1 = + SafeCurrencyMetadata.currencySymbol(Currency.unwrap(nativeKey.currency1), nativeCurrencyLabel); + string memory managerAddress = toHexString(address(manager)); + string memory currency0Address = Currency.unwrap(nativeKey.currency0) == address(0) + ? "Native" + : toHexString(Currency.unwrap(nativeKey.currency0)); + string memory currency1Address = Currency.unwrap(nativeKey.currency1) == address(0) + ? "Native" + : toHexString(Currency.unwrap(nativeKey.currency1)); + string memory id = uintToString(tokenId); + string memory hookAddress = address(nativeKey.hooks) == address(0) + ? "No Hook" + : string(abi.encodePacked("0x", toHexString(address(nativeKey.hooks)))); + string memory fee = Descriptor.feeToPercentString(nativeKey.fee); + string memory tickToDecimal0 = Descriptor.tickToDecimalString( + tickLower, + nativeKey.tickSpacing, + SafeCurrencyMetadata.currencyDecimals(Currency.unwrap(currency0)), + SafeCurrencyMetadata.currencyDecimals(Currency.unwrap(currency1)), + false + ); + string memory tickToDecimal1 = Descriptor.tickToDecimalString( + tickUpper, + nativeKey.tickSpacing, + SafeCurrencyMetadata.currencyDecimals(Currency.unwrap(currency0)), + SafeCurrencyMetadata.currencyDecimals(Currency.unwrap(currency1)), + false + ); + + assertEq( + token.name, + string( + abi.encodePacked( + "Uniswap - ", fee, " - ", symbol1, "/", symbol0, " - ", tickToDecimal0, "<>", tickToDecimal1 + ) + ) + ); assertEq( token.description, - unicode"This NFT represents a liquidity position in a Uniswap v4 TEST-TEST pool. The owner of this NFT can modify or redeem the position.\n\nPool Manager Address: 0x2e234dae75c793f67a35089c9d99245e1c58470b\nTEST Address: 0xf62849f9a0b5bf2913b396098f7c7019b51a820a\nTEST Address: 0xc7183455a4c133ae270771860664b6b7ec320bb1\nHook Address: No Hook\nFee Tier: 0.3%\nToken ID: 1\n\n⚠️ DISCLAIMER: Due diligence is imperative when assessing this NFT. Make sure currency addresses match the expected currencies, as currency symbols may be imitated." + string( + abi.encodePacked( + unicode"This NFT represents a liquidity position in a Uniswap v4 ", + symbol1, + "-", + symbol0, + " pool. The owner of this NFT can modify or redeem the position.\n\nPool Manager Address: ", + managerAddress, + "\n", + symbol1, + " Address: ", + currency1Address, + "\n", + symbol0, + " Address: ", + currency0Address, + "\nHook Address: ", + hookAddress, + "\nFee Tier: ", + fee, + "\nToken ID: ", + id, + "\n\n", + unicode"⚠️ DISCLAIMER: Due diligence is imperative when assessing this NFT. Make sure currency addresses match the expected currencies, as currency symbols may be imitated." + ) + ) ); } @@ -147,4 +327,42 @@ contract PositionDescriptorTest is Test, PosmTestSetup, GasSnapshot { positionDescriptor.tokenURI(lpm, tokenId + 1); } + + // Helper functions for testing purposes + function toHexString(address account) internal pure returns (string memory) { + return toHexString(uint256(uint160(account)), 20); + } + + // different from AddressStringUtil.toHexString. this one is all lowercase hex and includes the 0x prefix + function toHexString(uint256 value, uint256 length) internal pure returns (string memory) { + bytes memory buffer = new bytes(2 * length + 2); + buffer[0] = "0"; + buffer[1] = "x"; + for (uint256 i = 2 * length + 1; i > 1; --i) { + uint8 digit = uint8(value & 0xf); + buffer[i] = digit < 10 ? bytes1(digit + 48) : bytes1(digit + 87); // Lowercase hex (0x61 is 'a' in ASCII) + value >>= 4; + } + require(value == 0, "Hex length insufficient"); + return string(buffer); + } + + function uintToString(uint256 value) internal pure returns (string memory) { + if (value == 0) { + return "0"; + } + uint256 temp = value; + uint256 digits; + while (temp != 0) { + digits++; + temp /= 10; + } + bytes memory buffer = new bytes(digits); + while (value != 0) { + digits -= 1; + buffer[digits] = bytes1(uint8(48 + uint256(value % 10))); + value /= 10; + } + return string(buffer); + } } diff --git a/test/libraries/SVG.t.sol b/test/libraries/SVG.t.sol index c322483e..915557cb 100644 --- a/test/libraries/SVG.t.sol +++ b/test/libraries/SVG.t.sol @@ -47,4 +47,11 @@ contract DescriptorTest is Test { result = SVG.isRare(2, 0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB); assertFalse(result); } + + function test_substring_succeeds() public pure { + string memory result = SVG.substring("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", 0, 5); + assertEq(result, "0xC02"); + result = SVG.substring("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", 39, 42); + assertEq(result, "Cc2"); + } }