diff --git a/.forge-snapshots/PositionManager_burn_empty.snap b/.forge-snapshots/PositionManager_burn_empty.snap index 99bfcda6..949dd08a 100644 --- a/.forge-snapshots/PositionManager_burn_empty.snap +++ b/.forge-snapshots/PositionManager_burn_empty.snap @@ -1 +1 @@ -50413 \ No newline at end of file +50481 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_burn_empty_native.snap b/.forge-snapshots/PositionManager_burn_empty_native.snap index 99bfcda6..949dd08a 100644 --- a/.forge-snapshots/PositionManager_burn_empty_native.snap +++ b/.forge-snapshots/PositionManager_burn_empty_native.snap @@ -1 +1 @@ -50413 \ No newline at end of file +50481 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_burn_nonEmpty_native_withClose.snap b/.forge-snapshots/PositionManager_burn_nonEmpty_native_withClose.snap index 23b4ea0b..75b6dc86 100644 --- a/.forge-snapshots/PositionManager_burn_nonEmpty_native_withClose.snap +++ b/.forge-snapshots/PositionManager_burn_nonEmpty_native_withClose.snap @@ -1 +1 @@ -125541 \ No newline at end of file +125609 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_burn_nonEmpty_native_withTakePair.snap b/.forge-snapshots/PositionManager_burn_nonEmpty_native_withTakePair.snap index 932be33b..046f4910 100644 --- a/.forge-snapshots/PositionManager_burn_nonEmpty_native_withTakePair.snap +++ b/.forge-snapshots/PositionManager_burn_nonEmpty_native_withTakePair.snap @@ -1 +1 @@ -124988 \ No newline at end of file +125056 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_burn_nonEmpty_withClose.snap b/.forge-snapshots/PositionManager_burn_nonEmpty_withClose.snap index 4f3b1f22..79ad3f25 100644 --- a/.forge-snapshots/PositionManager_burn_nonEmpty_withClose.snap +++ b/.forge-snapshots/PositionManager_burn_nonEmpty_withClose.snap @@ -1 +1 @@ -132394 \ No newline at end of file +132462 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_burn_nonEmpty_withTakePair.snap b/.forge-snapshots/PositionManager_burn_nonEmpty_withTakePair.snap index af112ed0..dd001ceb 100644 --- a/.forge-snapshots/PositionManager_burn_nonEmpty_withTakePair.snap +++ b/.forge-snapshots/PositionManager_burn_nonEmpty_withTakePair.snap @@ -1 +1 @@ -131841 \ No newline at end of file +131909 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_collect_native.snap b/.forge-snapshots/PositionManager_collect_native.snap index a81bea64..8aca9b34 100644 --- a/.forge-snapshots/PositionManager_collect_native.snap +++ b/.forge-snapshots/PositionManager_collect_native.snap @@ -1 +1 @@ -146241 \ No newline at end of file +146326 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_collect_sameRange.snap b/.forge-snapshots/PositionManager_collect_sameRange.snap index 6e9f06c4..ab042564 100644 --- a/.forge-snapshots/PositionManager_collect_sameRange.snap +++ b/.forge-snapshots/PositionManager_collect_sameRange.snap @@ -1 +1 @@ -154807 \ No newline at end of file +154892 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_collect_withClose.snap b/.forge-snapshots/PositionManager_collect_withClose.snap index 6e9f06c4..ab042564 100644 --- a/.forge-snapshots/PositionManager_collect_withClose.snap +++ b/.forge-snapshots/PositionManager_collect_withClose.snap @@ -1 +1 @@ -154807 \ No newline at end of file +154892 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_collect_withTakePair.snap b/.forge-snapshots/PositionManager_collect_withTakePair.snap index 292a8b05..7f09fc96 100644 --- a/.forge-snapshots/PositionManager_collect_withTakePair.snap +++ b/.forge-snapshots/PositionManager_collect_withTakePair.snap @@ -1 +1 @@ -154128 \ No newline at end of file +154213 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decreaseLiquidity_native.snap b/.forge-snapshots/PositionManager_decreaseLiquidity_native.snap index 720602f5..7f9fa86d 100644 --- a/.forge-snapshots/PositionManager_decreaseLiquidity_native.snap +++ b/.forge-snapshots/PositionManager_decreaseLiquidity_native.snap @@ -1 +1 @@ -111938 \ No newline at end of file +112006 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decreaseLiquidity_withClose.snap b/.forge-snapshots/PositionManager_decreaseLiquidity_withClose.snap index 3da01e1a..5a422f6f 100644 --- a/.forge-snapshots/PositionManager_decreaseLiquidity_withClose.snap +++ b/.forge-snapshots/PositionManager_decreaseLiquidity_withClose.snap @@ -1 +1 @@ -119688 \ No newline at end of file +119773 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decreaseLiquidity_withTakePair.snap b/.forge-snapshots/PositionManager_decreaseLiquidity_withTakePair.snap index 667f6401..7766b70b 100644 --- a/.forge-snapshots/PositionManager_decreaseLiquidity_withTakePair.snap +++ b/.forge-snapshots/PositionManager_decreaseLiquidity_withTakePair.snap @@ -1 +1 @@ -119009 \ No newline at end of file +119094 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decrease_burnEmpty.snap b/.forge-snapshots/PositionManager_decrease_burnEmpty.snap index 560c5653..83fd9ce4 100644 --- a/.forge-snapshots/PositionManager_decrease_burnEmpty.snap +++ b/.forge-snapshots/PositionManager_decrease_burnEmpty.snap @@ -1 +1 @@ -135191 \ No newline at end of file +135259 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decrease_burnEmpty_native.snap b/.forge-snapshots/PositionManager_decrease_burnEmpty_native.snap index 4d369346..acb7f1ad 100644 --- a/.forge-snapshots/PositionManager_decrease_burnEmpty_native.snap +++ b/.forge-snapshots/PositionManager_decrease_burnEmpty_native.snap @@ -1 +1 @@ -128338 \ No newline at end of file +128406 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decrease_sameRange_allLiquidity.snap b/.forge-snapshots/PositionManager_decrease_sameRange_allLiquidity.snap index 8ac56af5..dea30077 100644 --- a/.forge-snapshots/PositionManager_decrease_sameRange_allLiquidity.snap +++ b/.forge-snapshots/PositionManager_decrease_sameRange_allLiquidity.snap @@ -1 +1 @@ -132375 \ No newline at end of file +132460 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decrease_take_take.snap b/.forge-snapshots/PositionManager_decrease_take_take.snap index 3a6b84ea..ce76c048 100644 --- a/.forge-snapshots/PositionManager_decrease_take_take.snap +++ b/.forge-snapshots/PositionManager_decrease_take_take.snap @@ -1 +1 @@ -120264 \ No newline at end of file +120349 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increaseLiquidity_erc20_withClose.snap b/.forge-snapshots/PositionManager_increaseLiquidity_erc20_withClose.snap index c052a144..0c1a9f6f 100644 --- a/.forge-snapshots/PositionManager_increaseLiquidity_erc20_withClose.snap +++ b/.forge-snapshots/PositionManager_increaseLiquidity_erc20_withClose.snap @@ -1 +1 @@ -158992 \ No newline at end of file +159077 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increaseLiquidity_erc20_withSettlePair.snap b/.forge-snapshots/PositionManager_increaseLiquidity_erc20_withSettlePair.snap index 5f62225d..87248cf4 100644 --- a/.forge-snapshots/PositionManager_increaseLiquidity_erc20_withSettlePair.snap +++ b/.forge-snapshots/PositionManager_increaseLiquidity_erc20_withSettlePair.snap @@ -1 +1 @@ -157932 \ No newline at end of file +158017 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increaseLiquidity_native.snap b/.forge-snapshots/PositionManager_increaseLiquidity_native.snap index afef57e7..d9bceda2 100644 --- a/.forge-snapshots/PositionManager_increaseLiquidity_native.snap +++ b/.forge-snapshots/PositionManager_increaseLiquidity_native.snap @@ -1 +1 @@ -140819 \ No newline at end of file +140904 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increase_autocompoundExactUnclaimedFees.snap b/.forge-snapshots/PositionManager_increase_autocompoundExactUnclaimedFees.snap index 5f3bb304..b6d9ed62 100644 --- a/.forge-snapshots/PositionManager_increase_autocompoundExactUnclaimedFees.snap +++ b/.forge-snapshots/PositionManager_increase_autocompoundExactUnclaimedFees.snap @@ -1 +1 @@ -136318 \ No newline at end of file +136403 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increase_autocompoundExcessFeesCredit.snap b/.forge-snapshots/PositionManager_increase_autocompoundExcessFeesCredit.snap index 32b4822f..8249d48b 100644 --- a/.forge-snapshots/PositionManager_increase_autocompoundExcessFeesCredit.snap +++ b/.forge-snapshots/PositionManager_increase_autocompoundExcessFeesCredit.snap @@ -1 +1 @@ -177299 \ No newline at end of file +177384 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increase_autocompound_clearExcess.snap b/.forge-snapshots/PositionManager_increase_autocompound_clearExcess.snap index 54aff219..270700ec 100644 --- a/.forge-snapshots/PositionManager_increase_autocompound_clearExcess.snap +++ b/.forge-snapshots/PositionManager_increase_autocompound_clearExcess.snap @@ -1 +1 @@ -147975 \ No newline at end of file +148060 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_native.snap b/.forge-snapshots/PositionManager_mint_native.snap index 41bb5af1..920e0e3b 100644 --- a/.forge-snapshots/PositionManager_mint_native.snap +++ b/.forge-snapshots/PositionManager_mint_native.snap @@ -1 +1 @@ -364680 \ No newline at end of file +364765 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_nativeWithSweep_withClose.snap b/.forge-snapshots/PositionManager_mint_nativeWithSweep_withClose.snap index f989c5b4..951aef9d 100644 --- a/.forge-snapshots/PositionManager_mint_nativeWithSweep_withClose.snap +++ b/.forge-snapshots/PositionManager_mint_nativeWithSweep_withClose.snap @@ -1 +1 @@ -373203 \ No newline at end of file +373288 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_nativeWithSweep_withSettlePair.snap b/.forge-snapshots/PositionManager_mint_nativeWithSweep_withSettlePair.snap index a6c6e9f3..83fb2c93 100644 --- a/.forge-snapshots/PositionManager_mint_nativeWithSweep_withSettlePair.snap +++ b/.forge-snapshots/PositionManager_mint_nativeWithSweep_withSettlePair.snap @@ -1 +1 @@ -372426 \ No newline at end of file +372511 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_onSameTickLower.snap b/.forge-snapshots/PositionManager_mint_onSameTickLower.snap index 490f9290..bd4d6b2d 100644 --- a/.forge-snapshots/PositionManager_mint_onSameTickLower.snap +++ b/.forge-snapshots/PositionManager_mint_onSameTickLower.snap @@ -1 +1 @@ -317528 \ No newline at end of file +317613 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_onSameTickUpper.snap b/.forge-snapshots/PositionManager_mint_onSameTickUpper.snap index 8a028548..4e64773a 100644 --- a/.forge-snapshots/PositionManager_mint_onSameTickUpper.snap +++ b/.forge-snapshots/PositionManager_mint_onSameTickUpper.snap @@ -1 +1 @@ -318198 \ No newline at end of file +318283 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_sameRange.snap b/.forge-snapshots/PositionManager_mint_sameRange.snap index 6428314a..59ae573a 100644 --- a/.forge-snapshots/PositionManager_mint_sameRange.snap +++ b/.forge-snapshots/PositionManager_mint_sameRange.snap @@ -1 +1 @@ -243767 \ No newline at end of file +243852 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_settleWithBalance_sweep.snap b/.forge-snapshots/PositionManager_mint_settleWithBalance_sweep.snap index 7a992c28..00d17c63 100644 --- a/.forge-snapshots/PositionManager_mint_settleWithBalance_sweep.snap +++ b/.forge-snapshots/PositionManager_mint_settleWithBalance_sweep.snap @@ -1 +1 @@ -418947 \ No newline at end of file +419032 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_warmedPool_differentRange.snap b/.forge-snapshots/PositionManager_mint_warmedPool_differentRange.snap index e6cbe01b..a2b37601 100644 --- a/.forge-snapshots/PositionManager_mint_warmedPool_differentRange.snap +++ b/.forge-snapshots/PositionManager_mint_warmedPool_differentRange.snap @@ -1 +1 @@ -323559 \ No newline at end of file +323644 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_withClose.snap b/.forge-snapshots/PositionManager_mint_withClose.snap index 8ff9088d..03de054e 100644 --- a/.forge-snapshots/PositionManager_mint_withClose.snap +++ b/.forge-snapshots/PositionManager_mint_withClose.snap @@ -1 +1 @@ -420081 \ No newline at end of file +420166 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_withSettlePair.snap b/.forge-snapshots/PositionManager_mint_withSettlePair.snap index fb3a929e..66e3f851 100644 --- a/.forge-snapshots/PositionManager_mint_withSettlePair.snap +++ b/.forge-snapshots/PositionManager_mint_withSettlePair.snap @@ -1 +1 @@ -419139 \ No newline at end of file +419224 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_multicall_initialize_mint.snap b/.forge-snapshots/PositionManager_multicall_initialize_mint.snap index 4e5f499f..19ab0c1e 100644 --- a/.forge-snapshots/PositionManager_multicall_initialize_mint.snap +++ b/.forge-snapshots/PositionManager_multicall_initialize_mint.snap @@ -1 +1 @@ -464241 \ No newline at end of file +464348 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_unsubscribe.snap b/.forge-snapshots/PositionManager_unsubscribe.snap index 0151c604..c0f309cf 100644 --- a/.forge-snapshots/PositionManager_unsubscribe.snap +++ b/.forge-snapshots/PositionManager_unsubscribe.snap @@ -1 +1 @@ -59238 \ No newline at end of file +59260 \ No newline at end of file diff --git a/script/DeployPosm.s.sol b/script/DeployPosm.s.sol index 3ce24250..5bbb6184 100644 --- a/script/DeployPosm.s.sol +++ b/script/DeployPosm.s.sol @@ -8,18 +8,29 @@ import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; import {StateView} from "../src/lens/StateView.sol"; import {PositionManager} from "../src/PositionManager.sol"; import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; +import {IPositionDescriptor} from "../src/interfaces/IPositionDescriptor.sol"; +import {PositionDescriptor} from "../src/PositionDescriptor.sol"; contract DeployPosmTest is Script { function setUp() public {} - function run(address poolManager, address permit2, uint256 unsubscribeGasLimit) - public - returns (PositionManager posm) - { + function run( + address poolManager, + address permit2, + uint256 unsubscribeGasLimit, + address wrappedNative, + string memory nativeCurrencyLabel + ) public returns (PositionDescriptor positionDescriptor, PositionManager posm) { vm.startBroadcast(); + positionDescriptor = new PositionDescriptor(IPoolManager(poolManager), wrappedNative, nativeCurrencyLabel); + console2.log("PositionDescriptor", address(positionDescriptor)); + posm = new PositionManager{salt: hex"03"}( - IPoolManager(poolManager), IAllowanceTransfer(permit2), unsubscribeGasLimit + IPoolManager(poolManager), + IAllowanceTransfer(permit2), + unsubscribeGasLimit, + IPositionDescriptor(address(positionDescriptor)) ); console2.log("PositionManager", address(posm)); diff --git a/src/PositionDescriptor.sol b/src/PositionDescriptor.sol new file mode 100644 index 00000000..7cf39d94 --- /dev/null +++ b/src/PositionDescriptor.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.26; + +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {IPositionManager} from "./interfaces/IPositionManager.sol"; +import {IPositionDescriptor} from "./interfaces/IPositionDescriptor.sol"; +import {PositionInfo, PositionInfoLibrary} from "./libraries/PositionInfoLibrary.sol"; +import {Descriptor} from "./libraries/Descriptor.sol"; +import {CurrencyRatioSortOrder} from "./libraries/CurrencyRatioSortOrder.sol"; +import {SafeCurrencyMetadata} from "./libraries/SafeCurrencyMetadata.sol"; + +/// @title Describes NFT token positions +/// @notice Produces a string containing the data URI for a JSON metadata string +contract PositionDescriptor is IPositionDescriptor { + using StateLibrary for IPoolManager; + using PoolIdLibrary for PoolKey; + using CurrencyLibrary for Currency; + using PositionInfoLibrary for PositionInfo; + + error InvalidTokenId(uint256 tokenId); + + // mainnet addresses + address private constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + address private constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address private constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + address private constant TBTC = 0x8dAEBADE922dF735c38C80C7eBD708Af50815fAa; + address private constant WBTC = 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599; + + address public immutable wrappedNative; + string public nativeCurrencyLabel; + + IPoolManager public immutable poolManager; + + constructor(IPoolManager _poolManager, address _wrappedNative, string memory _nativeCurrencyLabel) { + poolManager = _poolManager; + wrappedNative = _wrappedNative; + nativeCurrencyLabel = _nativeCurrencyLabel; + } + + /// @inheritdoc IPositionDescriptor + function tokenURI(IPositionManager positionManager, uint256 tokenId) + external + view + override + returns (string memory) + { + (PoolKey memory poolKey, PositionInfo positionInfo) = positionManager.getPoolAndPositionInfo(tokenId); + if (positionInfo.poolId() == 0) { + revert InvalidTokenId(tokenId); + } + (, int24 tick,,) = poolManager.getSlot0(poolKey.toId()); + + // 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)); + + // 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; + + return Descriptor.constructTokenURI( + Descriptor.ConstructTokenURIParams({ + tokenId: tokenId, + quoteCurrency: quoteCurrency, + baseCurrency: baseCurrency, + quoteCurrencySymbol: SafeCurrencyMetadata.currencySymbol(quoteCurrency, nativeCurrencyLabel), + baseCurrencySymbol: SafeCurrencyMetadata.currencySymbol(baseCurrency, nativeCurrencyLabel), + quoteCurrencyDecimals: SafeCurrencyMetadata.currencyDecimals(quoteCurrency), + baseCurrencyDecimals: SafeCurrencyMetadata.currencyDecimals(baseCurrency), + flipRatio: _flipRatio, + tickLower: positionInfo.tickLower(), + tickUpper: positionInfo.tickUpper(), + tickCurrent: tick, + tickSpacing: poolKey.tickSpacing, + fee: poolKey.fee, + poolManager: address(poolManager), + hooks: address(poolKey.hooks) + }) + ); + } + + /// @notice Returns true if currency0 has higher priority than currency1 + /// @param currency0 The first currency address + /// @param currency1 The second currency address + /// @return flipRatio True if currency0 has higher priority than currency1 + function flipRatio(address currency0, address currency1) public view returns (bool) { + return currencyRatioPriority(currency0) > currencyRatioPriority(currency1); + } + + /// @notice Returns the priority of a currency. + /// For certain currencies on mainnet, the smaller the currency, the higher the priority + /// @param currency The currency address + /// @return priority The priority of the currency + function currencyRatioPriority(address currency) public view returns (int256) { + // Currencies in order of priority on mainnet: USDC, USDT, DAI, (ETH, WETH), TBTC, WBTC + // wrapped native is different address on different chains. passed in constructor + + // native currency + if (currency == address(0) || currency == wrappedNative) { + return CurrencyRatioSortOrder.DENOMINATOR; + } + if (block.chainid == 1) { + if (currency == USDC) { + return CurrencyRatioSortOrder.NUMERATOR_MOST; + } else if (currency == USDT) { + return CurrencyRatioSortOrder.NUMERATOR_MORE; + } else if (currency == DAI) { + return CurrencyRatioSortOrder.NUMERATOR; + } else if (currency == TBTC) { + return CurrencyRatioSortOrder.DENOMINATOR_MORE; + } else if (currency == WBTC) { + return CurrencyRatioSortOrder.DENOMINATOR_MOST; + } else { + return 0; + } + } + return 0; + } +} diff --git a/src/PositionManager.sol b/src/PositionManager.sol index 52c01923..d5721e34 100644 --- a/src/PositionManager.sol +++ b/src/PositionManager.sol @@ -11,6 +11,7 @@ import {Position} from "@uniswap/v4-core/src/libraries/Position.sol"; import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; import {TransientStateLibrary} from "@uniswap/v4-core/src/libraries/TransientStateLibrary.sol"; import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; +import {IPositionDescriptor} from "./interfaces/IPositionDescriptor.sol"; import {ERC721Permit_v4} from "./base/ERC721Permit_v4.sol"; import {ReentrancyLock} from "./base/ReentrancyLock.sol"; @@ -116,15 +117,24 @@ contract PositionManager is /// @dev The ID of the next token that will be minted. Skips 0 uint256 public nextTokenId = 1; + IPositionDescriptor public immutable tokenDescriptor; + mapping(uint256 tokenId => PositionInfo info) public positionInfo; mapping(bytes25 poolId => PoolKey poolKey) public poolKeys; - constructor(IPoolManager _poolManager, IAllowanceTransfer _permit2, uint256 _unsubscribeGasLimit) + constructor( + IPoolManager _poolManager, + IAllowanceTransfer _permit2, + uint256 _unsubscribeGasLimit, + IPositionDescriptor _tokenDescriptor + ) BaseActionsRouter(_poolManager) Permit2Forwarder(_permit2) - ERC721Permit_v4("Uniswap V4 Positions NFT", "UNI-V4-POSM") + ERC721Permit_v4("Uniswap v4 Positions NFT", "UNI-V4-POSM") Notifier(_unsubscribeGasLimit) - {} + { + tokenDescriptor = _tokenDescriptor; + } /// @notice Reverts if the deadline has passed /// @param deadline The timestamp at which the call is no longer valid, passed in by the caller @@ -143,6 +153,10 @@ contract PositionManager is _; } + function tokenURI(uint256 tokenId) public view override returns (string memory) { + return IPositionDescriptor(tokenDescriptor).tokenURI(this, tokenId); + } + /// @inheritdoc IPositionManager function modifyLiquidities(bytes calldata unlockData, uint256 deadline) external diff --git a/src/V4Router.sol b/src/V4Router.sol index b33d8d90..0aaf7715 100644 --- a/src/V4Router.sol +++ b/src/V4Router.sol @@ -18,7 +18,7 @@ import {Actions} from "./libraries/Actions.sol"; import {ActionConstants} from "./libraries/ActionConstants.sol"; /// @title UniswapV4Router -/// @notice Abstract contract that contains all internal logic needed for routing through Uniswap V4 pools +/// @notice Abstract contract that contains all internal logic needed for routing through Uniswap v4 pools /// @dev the entry point to executing actions in this contract is calling `BaseActionsRouter._executeActions` /// An inheriting contract should call _executeActions at the point that they wish actions to be executed abstract contract V4Router is IV4Router, BaseActionsRouter, DeltaResolver { diff --git a/src/base/ERC721Permit_v4.sol b/src/base/ERC721Permit_v4.sol index b700db89..cb074c9c 100644 --- a/src/base/ERC721Permit_v4.sol +++ b/src/base/ERC721Permit_v4.sol @@ -95,9 +95,4 @@ abstract contract ERC721Permit_v4 is ERC721, IERC721Permit_v4, EIP712_v4, Unorde return spender == ownerOf(tokenId) || getApproved[tokenId] == spender || isApprovedForAll[ownerOf(tokenId)][spender]; } - - // TODO: to be implemented after audits - function tokenURI(uint256) public pure override returns (string memory) { - return "https://example.com"; - } } diff --git a/src/interfaces/IPositionDescriptor.sol b/src/interfaces/IPositionDescriptor.sol new file mode 100644 index 00000000..8c322732 --- /dev/null +++ b/src/interfaces/IPositionDescriptor.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import "./IPositionManager.sol"; + +/// @title Describes position NFT tokens via URI +interface IPositionDescriptor { + /// @notice Produces the URI describing a particular token ID + /// @dev Note this URI may be a data: URI with the JSON contents directly inlined + /// @param positionManager The position manager for which to describe the token + /// @param tokenId The ID of the token for which to produce a description, which may not be valid + /// @return The URI of the ERC721-compliant metadata + function tokenURI(IPositionManager positionManager, uint256 tokenId) external view returns (string memory); +} diff --git a/src/libraries/AddressStringUtil.sol b/src/libraries/AddressStringUtil.sol new file mode 100644 index 00000000..999bc2d9 --- /dev/null +++ b/src/libraries/AddressStringUtil.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +/// @title AddressStringUtil +/// @notice provides utility functions for converting addresses to strings +/// @dev Reference: https://github.com/Uniswap/solidity-lib/blob/master/contracts/libraries/AddressStringUtil.sol +library AddressStringUtil { + error InvalidAddressLength(uint256 len); + + /// @notice Converts an address to the uppercase hex string, extracting only len bytes (up to 20, multiple of 2) + /// @param addr the address to convert + /// @param len the number of bytes to extract + /// @return the hex string + function toAsciiString(address addr, uint256 len) internal pure returns (string memory) { + if (!(len % 2 == 0 && len > 0 && len <= 40)) { + revert InvalidAddressLength(len); + } + + bytes memory s = new bytes(len); + uint256 addrNum = uint256(uint160(addr)); + for (uint256 i = 0; i < len / 2; i++) { + // shift right and truncate all but the least significant byte to extract the byte at position 19-i + uint8 b = uint8(addrNum >> (8 * (19 - i))); + // first hex character is the most significant 4 bits + uint8 hi = b >> 4; + // second hex character is the least significant 4 bits + uint8 lo = b - (hi << 4); + s[2 * i] = char(hi); + s[2 * i + 1] = char(lo); + } + return string(s); + } + + /// @notice Converts a value into is corresponding ASCII character for the hex representation + // hi and lo are only 4 bits and between 0 and 16 + // uses upper case for the characters + /// @param b the value to convert + /// @return c the ASCII character + function char(uint8 b) private pure returns (bytes1 c) { + if (b < 10) { + return bytes1(b + 0x30); + } else { + return bytes1(b + 0x37); + } + } +} diff --git a/src/libraries/CurrencyRatioSortOrder.sol b/src/libraries/CurrencyRatioSortOrder.sol new file mode 100644 index 00000000..1f3a719a --- /dev/null +++ b/src/libraries/CurrencyRatioSortOrder.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +/// @title CurrencyRatioSortOrder +/// @notice Provides constants for sorting currencies when displaying price ratios +/// Currencies given larger values will be in the numerator of the price ratio +/// @dev Reference: https://github.com/Uniswap/v3-periphery/blob/main/contracts/libraries/TokenRatioSortOrder.sol +library CurrencyRatioSortOrder { + int256 constant NUMERATOR_MOST = 300; + int256 constant NUMERATOR_MORE = 200; + int256 constant NUMERATOR = 100; + + int256 constant DENOMINATOR_MOST = -300; + int256 constant DENOMINATOR_MORE = -200; + int256 constant DENOMINATOR = -100; +} diff --git a/src/libraries/Descriptor.sol b/src/libraries/Descriptor.sol new file mode 100644 index 00000000..8e32a35d --- /dev/null +++ b/src/libraries/Descriptor.sol @@ -0,0 +1,525 @@ +// 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"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; +import {SVG} from "./SVG.sol"; +import {HexStrings} from "./HexStrings.sol"; + +/// @title Descriptor +/// @notice Describes NFT token positions +/// @dev Reference: https://github.com/Uniswap/v3-periphery/blob/main/contracts/libraries/NFTDescriptor.sol +library Descriptor { + using TickMath for int24; + using Strings for uint256; + using HexStrings for uint256; + using LPFeeLibrary for uint24; + + uint256 constant sqrt10X128 = 1076067327063303206878105757264492625226; + + struct ConstructTokenURIParams { + uint256 tokenId; + Currency quoteCurrency; + Currency baseCurrency; + string quoteCurrencySymbol; + string baseCurrencySymbol; + uint8 quoteCurrencyDecimals; + uint8 baseCurrencyDecimals; + bool flipRatio; + int24 tickLower; + int24 tickUpper; + int24 tickCurrent; + int24 tickSpacing; + uint24 fee; + address poolManager; + address hooks; + } + + /// @notice Constructs the token URI for a Uniswap v4 NFT + /// @param params Parameters needed to construct the token URI + /// @return The token URI as a string + function constructTokenURI(ConstructTokenURIParams calldata params) public pure returns (string memory) { + string memory name = generateName(params, feeToPercentString(params.fee)); + string memory descriptionPartOne = generateDescriptionPartOne( + escapeQuotes(params.quoteCurrencySymbol), + escapeQuotes(params.baseCurrencySymbol), + addressToString(params.poolManager) + ); + string memory descriptionPartTwo = generateDescriptionPartTwo( + params.tokenId.toString(), + escapeQuotes(params.baseCurrencySymbol), + addressToString(Currency.unwrap(params.quoteCurrency)), + addressToString(Currency.unwrap(params.baseCurrency)), + addressToString(params.hooks), + feeToPercentString(params.fee) + ); + string memory image = Base64.encode(bytes(generateSVGImage(params))); + + return string( + abi.encodePacked( + "data:application/json;base64,", + Base64.encode( + bytes( + abi.encodePacked( + '{"name":"', + name, + '", "description":"', + descriptionPartOne, + descriptionPartTwo, + '", "image": "', + "data:image/svg+xml;base64,", + image, + '"}' + ) + ) + ) + ) + ); + } + + /// @notice Escapes double quotes in a string if they are present + function escapeQuotes(string memory symbol) internal pure returns (string memory) { + bytes memory symbolBytes = bytes(symbol); + uint8 quotesCount = 0; + // count the amount of double quotes (") in the symbol + for (uint8 i = 0; i < symbolBytes.length; i++) { + if (symbolBytes[i] == '"') { + quotesCount++; + } + } + if (quotesCount > 0) { + // create a new bytes array with enough space to hold the original bytes plus space for the backslashes to escape the quotes + bytes memory escapedBytes = new bytes(symbolBytes.length + quotesCount); + uint256 index; + for (uint8 i = 0; i < symbolBytes.length; i++) { + // add a '\' before any double quotes + if (symbolBytes[i] == '"') { + escapedBytes[index++] = "\\"; + } + // copy each byte from original string to the new array + escapedBytes[index++] = symbolBytes[i]; + } + return string(escapedBytes); + } + return symbol; + } + + /// @notice Generates the first part of the description for a Uniswap v4 NFT + /// @param quoteCurrencySymbol The symbol of the quote currency + /// @param baseCurrencySymbol The symbol of the base currency + /// @param poolManager The address of the pool manager + /// @return The first part of the description + function generateDescriptionPartOne( + string memory quoteCurrencySymbol, + string memory baseCurrencySymbol, + string memory poolManager + ) private pure returns (string memory) { + // displays quote currency first, then base currency + return string( + abi.encodePacked( + "This NFT represents a liquidity position in a Uniswap v4 ", + quoteCurrencySymbol, + "-", + baseCurrencySymbol, + " pool. ", + "The owner of this NFT can modify or redeem the position.\\n", + "\\nPool Manager Address: ", + poolManager, + "\\n", + quoteCurrencySymbol + ) + ); + } + + /// @notice Generates the second part of the description for a Uniswap v4 NFTs + /// @param tokenId The token ID + /// @param baseCurrencySymbol The symbol of the base currency + /// @param quoteCurrency The address of the quote currency + /// @param baseCurrency The address of the base currency + /// @param hooks The address of the hooks contract + /// @param feeTier The fee tier of the pool + /// @return The second part of the description + function generateDescriptionPartTwo( + string memory tokenId, + string memory baseCurrencySymbol, + string memory quoteCurrency, + string memory baseCurrency, + string memory hooks, + string memory feeTier + ) private pure returns (string memory) { + return string( + abi.encodePacked( + " Address: ", + quoteCurrency, + "\\n", + baseCurrencySymbol, + " Address: ", + baseCurrency, + "\\nHook Address: ", + hooks, + "\\nFee Tier: ", + feeTier, + "\\nToken ID: ", + tokenId, + "\\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." + ) + ); + } + + /// @notice Generates the name for a Uniswap v4 NFT + /// @param params Parameters needed to generate the name + /// @param feeTier The fee tier of the pool + /// @return The name of the NFT + function generateName(ConstructTokenURIParams calldata params, string memory feeTier) + private + pure + returns (string memory) + { + // image shows in terms of price, ie quoteCurrency/baseCurrency + return string( + abi.encodePacked( + "Uniswap - ", + feeTier, + " - ", + escapeQuotes(params.quoteCurrencySymbol), + "/", + escapeQuotes(params.baseCurrencySymbol), + " - ", + tickToDecimalString( + !params.flipRatio ? params.tickLower : params.tickUpper, + params.tickSpacing, + params.baseCurrencyDecimals, + params.quoteCurrencyDecimals, + params.flipRatio + ), + "<>", + tickToDecimalString( + !params.flipRatio ? params.tickUpper : params.tickLower, + params.tickSpacing, + params.baseCurrencyDecimals, + params.quoteCurrencyDecimals, + params.flipRatio + ) + ) + ); + } + + struct DecimalStringParams { + // significant figures of decimal + uint256 sigfigs; + // length of decimal string + uint8 bufferLength; + // ending index for significant figures (funtion works backwards when copying sigfigs) + uint8 sigfigIndex; + // index of decimal place (0 if no decimal) + uint8 decimalIndex; + // start index for trailing/leading 0's for very small/large numbers + uint8 zerosStartIndex; + // end index for trailing/leading 0's for very small/large numbers + uint8 zerosEndIndex; + // true if decimal number is less than one + bool isLessThanOne; + // true if string should include "%" + bool isPercent; + } + + function generateDecimalString(DecimalStringParams memory params) private pure returns (string memory) { + bytes memory buffer = new bytes(params.bufferLength); + if (params.isPercent) { + buffer[buffer.length - 1] = "%"; + } + if (params.isLessThanOne) { + buffer[0] = "0"; + buffer[1] = "."; + } + + // add leading/trailing 0's + for (uint256 zerosCursor = params.zerosStartIndex; zerosCursor < params.zerosEndIndex + 1; zerosCursor++) { + // converts the ASCII code for 0 (which is 48) into a bytes1 to store in the buffer + buffer[zerosCursor] = bytes1(uint8(48)); + } + // add sigfigs + while (params.sigfigs > 0) { + if (params.decimalIndex > 0 && params.sigfigIndex == params.decimalIndex) { + buffer[params.sigfigIndex--] = "."; + } + buffer[params.sigfigIndex] = bytes1(uint8(48 + (params.sigfigs % 10))); + // can overflow when sigfigIndex = 0 + unchecked { + params.sigfigIndex--; + } + params.sigfigs /= 10; + } + return string(buffer); + } + + /// @notice Gets the price (quote/base) at a specific tick in decimal form + /// MIN or MAX are returned if tick is at the bottom or top of the price curve + /// @param tick The tick (either tickLower or tickUpper) + /// @param tickSpacing The tick spacing of the pool + /// @param baseCurrencyDecimals The decimals of the base currency + /// @param quoteCurrencyDecimals The decimals of the quote currency + /// @param flipRatio True if the ratio was flipped + /// @return The ratio value as a string + function tickToDecimalString( + int24 tick, + int24 tickSpacing, + uint8 baseCurrencyDecimals, + uint8 quoteCurrencyDecimals, + bool flipRatio + ) internal pure returns (string memory) { + if (tick == (TickMath.MIN_TICK / tickSpacing) * tickSpacing) { + return !flipRatio ? "MIN" : "MAX"; + } else if (tick == (TickMath.MAX_TICK / tickSpacing) * tickSpacing) { + return !flipRatio ? "MAX" : "MIN"; + } else { + uint160 sqrtRatioX96 = TickMath.getSqrtPriceAtTick(tick); + if (flipRatio) { + sqrtRatioX96 = uint160(uint256(1 << 192) / sqrtRatioX96); + } + return fixedPointToDecimalString(sqrtRatioX96, baseCurrencyDecimals, quoteCurrencyDecimals); + } + } + + function sigfigsRounded(uint256 value, uint8 digits) private pure returns (uint256, bool) { + bool extraDigit; + if (digits > 5) { + value = value / (10 ** (digits - 5)); + } + bool roundUp = value % 10 > 4; + value = value / 10; + if (roundUp) { + value = value + 1; + } + // 99999 -> 100000 gives an extra sigfig + if (value == 100000) { + value /= 10; + extraDigit = true; + } + return (value, extraDigit); + } + + /// @notice Adjusts the sqrt price for different currencies with different decimals + /// @param sqrtRatioX96 The sqrt price at a specific tick + /// @param baseCurrencyDecimals The decimals of the base currency + /// @param quoteCurrencyDecimals The decimals of the quote currency + /// @return adjustedSqrtRatioX96 The adjusted sqrt price + function adjustForDecimalPrecision(uint160 sqrtRatioX96, uint8 baseCurrencyDecimals, uint8 quoteCurrencyDecimals) + private + pure + returns (uint256 adjustedSqrtRatioX96) + { + uint256 difference = abs(int256(uint256(baseCurrencyDecimals)) - (int256(uint256(quoteCurrencyDecimals)))); + if (difference > 0 && difference <= 18) { + if (baseCurrencyDecimals > quoteCurrencyDecimals) { + adjustedSqrtRatioX96 = sqrtRatioX96 * (10 ** (difference / 2)); + if (difference % 2 == 1) { + adjustedSqrtRatioX96 = FullMath.mulDiv(adjustedSqrtRatioX96, sqrt10X128, 1 << 128); + } + } else { + adjustedSqrtRatioX96 = sqrtRatioX96 / (10 ** (difference / 2)); + if (difference % 2 == 1) { + adjustedSqrtRatioX96 = FullMath.mulDiv(adjustedSqrtRatioX96, 1 << 128, sqrt10X128); + } + } + } else { + adjustedSqrtRatioX96 = uint256(sqrtRatioX96); + } + } + + /// @notice Absolute value of a signed integer + /// @param x The signed integer + /// @return The absolute value of x + function abs(int256 x) private pure returns (uint256) { + return uint256(x >= 0 ? x : -x); + } + + function fixedPointToDecimalString(uint160 sqrtRatioX96, uint8 baseCurrencyDecimals, uint8 quoteCurrencyDecimals) + internal + pure + returns (string memory) + { + uint256 adjustedSqrtRatioX96 = + adjustForDecimalPrecision(sqrtRatioX96, baseCurrencyDecimals, quoteCurrencyDecimals); + uint256 value = FullMath.mulDiv(adjustedSqrtRatioX96, adjustedSqrtRatioX96, 1 << 64); + + bool priceBelow1 = adjustedSqrtRatioX96 < 2 ** 96; + if (priceBelow1) { + // 10 ** 43 is precision needed to retreive 5 sigfigs of smallest possible price + 1 for rounding + value = FullMath.mulDiv(value, 10 ** 44, 1 << 128); + } else { + // leave precision for 4 decimal places + 1 place for rounding + value = FullMath.mulDiv(value, 10 ** 5, 1 << 128); + } + + // get digit count + uint256 temp = value; + uint8 digits; + while (temp != 0) { + digits++; + temp /= 10; + } + // don't count extra digit kept for rounding + digits = digits - 1; + + // address rounding + (uint256 sigfigs, bool extraDigit) = sigfigsRounded(value, digits); + if (extraDigit) { + digits++; + } + + DecimalStringParams memory params; + if (priceBelow1) { + // 7 bytes ( "0." and 5 sigfigs) + leading 0's bytes + params.bufferLength = uint8(uint8(7) + (uint8(43) - digits)); + params.zerosStartIndex = 2; + params.zerosEndIndex = uint8(uint256(43) - digits + 1); + params.sigfigIndex = uint8(params.bufferLength - 1); + } else if (digits >= 9) { + // no decimal in price string + params.bufferLength = uint8(digits - 4); + params.zerosStartIndex = 5; + params.zerosEndIndex = uint8(params.bufferLength - 1); + params.sigfigIndex = 4; + } else { + // 5 sigfigs surround decimal + params.bufferLength = 6; + params.sigfigIndex = 5; + params.decimalIndex = uint8(digits - 5 + 1); + } + params.sigfigs = sigfigs; + params.isLessThanOne = priceBelow1; + params.isPercent = false; + + return generateDecimalString(params); + } + + /// @notice Converts fee amount in pips to decimal string with percent sign + /// @param fee fee amount + /// @return fee as a decimal string with percent sign + function feeToPercentString(uint24 fee) internal pure returns (string memory) { + if (fee.isDynamicFee()) { + return "Dynamic"; + } + if (fee == 0) { + return "0%"; + } + uint24 temp = fee; + uint256 digits; + uint8 numSigfigs; + // iterates over each digit of fee by dividing temp by 10 in each iteration until temp becomes 0 + // calculates number of digits and number of significant figures (non-zero digits) + while (temp != 0) { + if (numSigfigs > 0) { + // count all digits preceding least significant figure + numSigfigs++; + } else if (temp % 10 != 0) { + numSigfigs++; + } + digits++; + temp /= 10; + } + + DecimalStringParams memory params; + uint256 nZeros; + if (digits >= 5) { + // represents fee greater than or equal to 1% + // if decimal > 1 (5th digit is the ones place) + uint256 decimalPlace = digits - numSigfigs >= 4 ? 0 : 1; + nZeros = digits - 5 < numSigfigs - 1 ? 0 : digits - 5 - (numSigfigs - 1); + params.zerosStartIndex = numSigfigs; + params.zerosEndIndex = uint8(params.zerosStartIndex + nZeros - 1); + params.sigfigIndex = uint8(params.zerosStartIndex - 1 + decimalPlace); + params.bufferLength = uint8(nZeros + numSigfigs + 1 + decimalPlace); + } else { + // represents fee less than 1% + // else if decimal < 1 + nZeros = 5 - digits; // number of zeros, inlcuding the zero before decimal + params.zerosStartIndex = 2; // leading zeros will start after the decimal point + params.zerosEndIndex = uint8(nZeros + params.zerosStartIndex - 1); // end index for leading zeros + params.bufferLength = uint8(nZeros + numSigfigs + 2); // total length of string buffer, including "0." and "%" + params.sigfigIndex = uint8(params.bufferLength - 2); // index of starting signficant figure + params.isLessThanOne = true; + } + params.sigfigs = uint256(fee) / (10 ** (digits - numSigfigs)); // the signficant figures of the fee + params.isPercent = true; + params.decimalIndex = digits > 4 ? uint8(digits - 4) : 0; // based on total number of digits in the fee + + return generateDecimalString(params); + } + + function addressToString(address addr) internal pure returns (string memory) { + return (uint256(uint160(addr))).toHexString(20); + } + + /// @notice Generates the SVG image for a Uniswap v4 NFT + /// @param params Parameters needed to generate the SVG image + /// @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)), + hooks: params.hooks, + quoteCurrencySymbol: params.quoteCurrencySymbol, + baseCurrencySymbol: params.baseCurrencySymbol, + feeTier: feeToPercentString(params.fee), + tickLower: params.tickLower, + tickUpper: params.tickUpper, + 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) + }); + + return SVG.generateSVG(svgParams); + } + + /// @notice Checks if the current price is within your position range, above, or below + /// @param tickLower The lower tick + /// @param tickUpper The upper tick + /// @param tickCurrent The current tick + /// @return 0 if the current price is within the position range, -1 if below, 1 if above + function overRange(int24 tickLower, int24 tickUpper, int24 tickCurrent) private pure returns (int8) { + if (tickCurrent < tickLower) { + return -1; + } else if (tickCurrent > tickUpper) { + return 1; + } else { + return 0; + } + } + + function scale(uint256 n, uint256 inMn, uint256 inMx, uint256 outMn, uint256 outMx) + private + pure + returns (string memory) + { + return ((n - inMn) * (outMx - outMn) / (inMx - inMn) + outMn).toString(); + } + + function currencyToColorHex(uint256 currency, uint256 offset) internal pure returns (string memory str) { + return string((currency >> offset).toHexStringNoPrefix(3)); + } + + function getCircleCoord(uint256 currency, uint256 offset, uint256 tokenId) internal pure returns (uint256) { + return (sliceCurrencyHex(currency, offset) * tokenId) % 255; + } + + function sliceCurrencyHex(uint256 currency, uint256 offset) internal pure returns (uint256) { + return uint256(uint8(currency >> offset)); + } +} diff --git a/src/libraries/HexStrings.sol b/src/libraries/HexStrings.sol new file mode 100644 index 00000000..d5a32387 --- /dev/null +++ b/src/libraries/HexStrings.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +/// @title HexStrings +/// @notice Provides function for converting numbers to hexadecimal strings +/// @dev Reference: https://github.com/Uniswap/v3-periphery/blob/main/contracts/libraries/HexStrings.sol +library HexStrings { + bytes16 internal constant ALPHABET = "0123456789abcdef"; + + /// @notice Convert a number to a hex string without the '0x' prefix with a fixed length + /// @param value The number to convert + /// @param length The length of the output string, starting from the last character of the string + /// @return The hex string + function toHexStringNoPrefix(uint256 value, uint256 length) internal pure returns (string memory) { + bytes memory buffer = new bytes(2 * length); + for (uint256 i = buffer.length; i > 0; i--) { + buffer[i - 1] = ALPHABET[value & 0xf]; + value >>= 4; + } + return string(buffer); + } +} diff --git a/src/libraries/SVG.sol b/src/libraries/SVG.sol new file mode 100644 index 00000000..24d71282 --- /dev/null +++ b/src/libraries/SVG.sol @@ -0,0 +1,470 @@ +// 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/utils/Strings.sol"; +import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; + +/// @title SVG +/// @notice Provides a function for generating an SVG associated with a Uniswap NFT +/// @dev Reference: https://github.com/Uniswap/v3-periphery/blob/main/contracts/libraries/NFTSVG.sol +library SVG { + using Strings for uint256; + + // SVG path commands for the curve that represent the steepness of the position + // defined using the Cubic Bezier Curve syntax + // curve1 is the smallest (linear) curve, curve8 is the largest curve + string constant curve1 = "M1 1C41 41 105 105 145 145"; + string constant curve2 = "M1 1C33 49 97 113 145 145"; + string constant curve3 = "M1 1C33 57 89 113 145 145"; + string constant curve4 = "M1 1C25 65 81 121 145 145"; + string constant curve5 = "M1 1C17 73 73 129 145 145"; + string constant curve6 = "M1 1C9 81 65 137 145 145"; + string constant curve7 = "M1 1C1 89 57.5 145 145 145"; + string constant curve8 = "M1 1C1 97 49 145 145 145"; + + struct SVGParams { + string quoteCurrency; + string baseCurrency; + address hooks; + string quoteCurrencySymbol; + string baseCurrencySymbol; + string feeTier; + int24 tickLower; + int24 tickUpper; + int24 tickSpacing; + int8 overRange; + uint256 tokenId; + string color0; + string color1; + string color2; + string color3; + string x1; + string y1; + string x2; + string y2; + string x3; + string y3; + } + + /// @notice Generate the SVG associated with a Uniswap v4 NFT + /// @param params The SVGParams struct containing the parameters for the SVG + /// @return svg The SVG string associated with the NFT + function generateSVG(SVGParams memory params) internal pure returns (string memory svg) { + return string( + abi.encodePacked( + generateSVGDefs(params), + generateSVGBorderText( + params.quoteCurrency, params.baseCurrency, params.quoteCurrencySymbol, params.baseCurrencySymbol + ), + generateSVGCardMantle(params.quoteCurrencySymbol, params.baseCurrencySymbol, params.feeTier), + generageSvgCurve(params.tickLower, params.tickUpper, params.tickSpacing, params.overRange), + generateSVGPositionDataAndLocationCurve( + params.tokenId.toString(), params.hooks, params.tickLower, params.tickUpper + ), + generateSVGRareSparkle(params.tokenId, params.hooks), + "" + ) + ); + } + + /// @notice Generate the SVG defs that create the color scheme for the SVG + /// @param params The SVGParams struct containing the parameters to generate the SVG defs + /// @return svg The SVG defs string + function generateSVGDefs(SVGParams memory params) private pure returns (string memory svg) { + svg = string( + abi.encodePacked( + '", + "", + '" + ) + ) + ), + '"/>" + ) + ) + ), + '"/>" + ) + ) + ), + '" />', + '" + ) + ) + ), + '" /> ', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ' ', + '', + '', + '' + ) + ); + } + + /// @notice Generate the SVG for the moving border text displaying the quote and base currency addresses with their symbols + /// @param quoteCurrency The quote currency + /// @param baseCurrency The base currency + /// @param quoteCurrencySymbol The quote currency symbol + /// @param baseCurrencySymbol The base currency symbol + /// @return svg The SVG for the border NFT's border text + function generateSVGBorderText( + string memory quoteCurrency, + string memory baseCurrency, + string memory quoteCurrencySymbol, + string memory baseCurrencySymbol + ) private pure returns (string memory svg) { + svg = string( + abi.encodePacked( + '', + '', + baseCurrency, + unicode" • ", + baseCurrencySymbol, + ' ', + ' ', + baseCurrency, + unicode" • ", + baseCurrencySymbol, + ' ', + '', + quoteCurrency, + unicode" • ", + quoteCurrencySymbol, + ' ', + quoteCurrency, + unicode" • ", + quoteCurrencySymbol, + ' ' + ) + ); + } + + /// @notice Generate the SVG for the card mantle displaying the quote and base currency symbols and fee tier + /// @param quoteCurrencySymbol The quote currency symbol + /// @param baseCurrencySymbol The base currency symbol + /// @param feeTier The fee tier + /// @return svg The SVG for the card mantle + function generateSVGCardMantle( + string memory quoteCurrencySymbol, + string memory baseCurrencySymbol, + string memory feeTier + ) private pure returns (string memory svg) { + svg = string( + abi.encodePacked( + ' ', + quoteCurrencySymbol, + "/", + baseCurrencySymbol, + '', + feeTier, + "", + '' + ) + ); + } + + /// @notice Generate the SVG for the curve that represents the position. Fade up (top is faded) if current price is above your position range, fade down (bottom is faded) if current price is below your position range + /// Circles are generated at the ends of the curve if the position is in range, or at one end of the curve it is on if not in range + /// @param tickLower The lower tick + /// @param tickUpper The upper tick + /// @param tickSpacing The tick spacing + /// @param overRange Whether the current tick is in range, over range, or under range + /// @return svg The SVG for the curve + function generageSvgCurve(int24 tickLower, int24 tickUpper, int24 tickSpacing, int8 overRange) + private + pure + returns (string memory svg) + { + string memory fade = overRange == 1 ? "#fade-up" : overRange == -1 ? "#fade-down" : "#none"; + string memory curve = getCurve(tickLower, tickUpper, tickSpacing); + svg = string( + abi.encodePacked( + '' + '' '', + '', + '', + '', + generateSVGCurveCircle(overRange) + ) + ); + } + + /// @notice Get the curve based on the tick range + /// The smaller the tick range, the smaller/more linear the curve + /// @param tickLower The lower tick + /// @param tickUpper The upper tick + /// @param tickSpacing The tick spacing + /// @return curve The curve path + function getCurve(int24 tickLower, int24 tickUpper, int24 tickSpacing) + internal + pure + returns (string memory curve) + { + int24 tickRange = (tickUpper - tickLower) / tickSpacing; + if (tickRange <= 4) { + curve = curve1; + } else if (tickRange <= 8) { + curve = curve2; + } else if (tickRange <= 16) { + curve = curve3; + } else if (tickRange <= 32) { + curve = curve4; + } else if (tickRange <= 64) { + curve = curve5; + } else if (tickRange <= 128) { + curve = curve6; + } else if (tickRange <= 256) { + curve = curve7; + } else { + curve = curve8; + } + } + + /// @notice Generate the SVG for the circles on the curve + /// @param overRange 0 if the current tick is in range, 1 if the current tick is over range, -1 if the current tick is under range + /// @return svg The SVG for the circles + function generateSVGCurveCircle(int8 overRange) internal pure returns (string memory svg) { + string memory curvex1 = "73"; + string memory curvey1 = "190"; + string memory curvex2 = "217"; + string memory curvey2 = "334"; + /// If the position is over or under range, generate one circle at the end of the curve on the side of the range it is on with a larger circle around it + if (overRange == 1 || overRange == -1) { + svg = string( + abi.encodePacked( + '' + ) + ); + } else { + /// If the position is in range, generate two circles at the ends of the curve + svg = string( + abi.encodePacked( + '', + '' + ) + ); + } + } + + /// @notice Generate the SVG for the position data (token ID, hooks address, min tick, max tick) and the location curve (where your position falls on the curve) + /// @param tokenId The token ID + /// @param hook The hooks address + /// @param tickLower The lower tick + /// @param tickUpper The upper tick + /// @return svg The SVG for the position data and location curve + function generateSVGPositionDataAndLocationCurve( + string memory tokenId, + address hook, + int24 tickLower, + int24 tickUpper + ) private pure returns (string memory svg) { + string memory hookStr = (uint256(uint160(hook))).toHexString(20); + string memory tickLowerStr = tickToString(tickLower); + string memory tickUpperStr = tickToString(tickUpper); + uint256 str1length = bytes(tokenId).length + 4; + string memory hookSlice = string(abi.encodePacked(substring(hookStr, 0, 5), "...", substring(hookStr, 37, 40))); + uint256 str2length = bytes(hookSlice).length + 5; + uint256 str3length = bytes(tickLowerStr).length + 10; + uint256 str4length = bytes(tickUpperStr).length + 10; + (string memory xCoord, string memory yCoord) = rangeLocation(tickLower, tickUpper); + svg = string( + abi.encodePacked( + ' ', + '', + 'ID: ', + tokenId, + "", + ' ', + '', + 'Hook: ', + hookSlice, + "", + ' ', + '', + 'Min Tick: ', + tickLowerStr, + "", + ' ', + '', + 'Max Tick: ', + tickUpperStr, + "" '', + '', + '', + '' + ) + ); + } + + function substring(string memory str, uint256 startIndex, uint256 endIndex) internal pure returns (string memory) { + bytes memory strBytes = bytes(str); + bytes memory result = new bytes(endIndex - startIndex); + for (uint256 i = startIndex; i < endIndex; i++) { + result[i - startIndex] = strBytes[i]; + } + return string(result); + } + + function tickToString(int24 tick) private pure returns (string memory) { + string memory sign = ""; + if (tick < 0) { + tick = tick * -1; + sign = "-"; + } + return string(abi.encodePacked(sign, uint256(uint24(tick)).toString())); + } + + /// @notice Get the location of where your position falls on the curve + /// @param tickLower The lower tick + /// @param tickUpper The upper tick + /// @return The x and y coordinates of the location of the liquidity + function rangeLocation(int24 tickLower, int24 tickUpper) internal pure returns (string memory, string memory) { + int24 midPoint = (tickLower + tickUpper) / 2; + if (midPoint < -125_000) { + return ("8", "7"); + } else if (midPoint < -75_000) { + return ("8", "10.5"); + } else if (midPoint < -25_000) { + return ("8", "14.25"); + } else if (midPoint < -5_000) { + return ("10", "18"); + } else if (midPoint < 0) { + return ("11", "21"); + } else if (midPoint < 5_000) { + return ("13", "23"); + } else if (midPoint < 25_000) { + return ("15", "25"); + } else if (midPoint < 75_000) { + return ("18", "26"); + } else if (midPoint < 125_000) { + return ("21", "27"); + } else { + return ("24", "27"); + } + } + + /// @notice Generates the SVG for a rare sparkle if the NFT is rare. Else, returns an empty string + /// @param tokenId The token ID + /// @param hooks The hooks address + /// @return svg The SVG for the rare sparkle + function generateSVGRareSparkle(uint256 tokenId, address hooks) private pure returns (string memory svg) { + if (isRare(tokenId, hooks)) { + svg = string( + abi.encodePacked( + '', + '', + '' + ) + ); + } else { + svg = ""; + } + } + + /// @notice Determines if an NFT is rare based on the token ID and hooks address + /// @param tokenId The token ID + /// @param hooks The hooks address + /// @return Whether the NFT is rare or not + function isRare(uint256 tokenId, address hooks) internal pure returns (bool) { + bytes32 h = keccak256(abi.encodePacked(tokenId, hooks)); + return uint256(h) < type(uint256).max / (1 + BitMath.mostSignificantBit(tokenId) * 2); + } +} diff --git a/src/libraries/SafeCurrencyMetadata.sol b/src/libraries/SafeCurrencyMetadata.sol new file mode 100644 index 00000000..84aa2881 --- /dev/null +++ b/src/libraries/SafeCurrencyMetadata.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {IERC20Metadata} from "@openzeppelin/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; + + /// @notice attempts to extract the token symbol. if it does not implement symbol, returns a symbol derived from the address + /// @param currency The currency + /// @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 nativeLabel; + } + address currencyAddress = Currency.unwrap(currency); + string memory symbol = callAndParseStringReturn(currencyAddress, IERC20Metadata.symbol.selector); + if (bytes(symbol).length == 0) { + // fallback to 6 uppercase hex of address + return addressToSymbol(currencyAddress); + } + return symbol; + } + + /// @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()) { + return 18; + } + (bool success, bytes memory data) = + Currency.unwrap(currency).staticcall(abi.encodeCall(IERC20Metadata.decimals, ())); + if (!success) { + return 0; + } + if (data.length == 32) { + return abi.decode(data, (uint8)); + } + return 0; + } + + function bytes32ToString(bytes32 x) private pure returns (string memory) { + bytes memory bytesString = new bytes(32); + uint256 charCount = 0; + for (uint256 j = 0; j < 32; j++) { + bytes1 char = x[j]; + if (char != 0) { + bytesString[charCount] = char; + charCount++; + } + } + bytes memory bytesStringTrimmed = new bytes(charCount); + for (uint256 j = 0; j < charCount; j++) { + bytesStringTrimmed[j] = bytesString[j]; + } + return string(bytesStringTrimmed); + } + + /// @notice produces a symbol from the address - the first 6 hex of the address string in upper case + /// @param currencyAddress the address of the currency + /// @return the symbol + function addressToSymbol(address currencyAddress) private pure returns (string memory) { + return AddressStringUtil.toAsciiString(currencyAddress, 6); + } + + /// @notice calls an external view contract method that returns a symbol, and parses the output into a string + /// @param currencyAddress the address of the currency + /// @param selector the selector of the symbol method + /// @return the symbol + function callAndParseStringReturn(address currencyAddress, bytes4 selector) private view returns (string memory) { + (bool success, bytes memory data) = currencyAddress.staticcall(abi.encodeWithSelector(selector)); + // if not implemented, return empty string + if (!success) { + return ""; + } + // bytes32 data always has length 32 + if (data.length == 32) { + bytes32 decoded = abi.decode(data, (bytes32)); + return bytes32ToString(decoded); + } else if (data.length > 64) { + return abi.decode(data, (string)); + } + return ""; + } +} diff --git a/test/PositionDescriptor.t.sol b/test/PositionDescriptor.t.sol new file mode 100644 index 00000000..6658a735 --- /dev/null +++ b/test/PositionDescriptor.t.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {PositionDescriptor} from "../src/PositionDescriptor.sol"; +import {CurrencyRatioSortOrder} from "../src/libraries/CurrencyRatioSortOrder.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {PositionConfig} from "./shared/PositionConfig.sol"; +import {PosmTestSetup} from "./shared/PosmTestSetup.sol"; +import {ActionConstants} from "../src/libraries/ActionConstants.sol"; +import {Base64} from "./base64.sol"; + +contract PositionDescriptorTest is Test, PosmTestSetup { + using Base64 for string; + + address public WETH9 = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address public DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + address public USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address public USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + address public TBTC = 0x8dAEBADE922dF735c38C80C7eBD708Af50815fAa; + address public WBTC = 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599; + string public nativeCurrencyLabel = "ETH"; + + struct Token { + string description; + string image; + string name; + } + + function setUp() public { + deployFreshManager(); + (currency0, currency1) = deployAndMint2Currencies(); + (key,) = initPool(currency0, currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1, ZERO_BYTES); + deployAndApprovePosm(manager); + } + + function test_setup_succeeds() public view { + assertEq(address(positionDescriptor.poolManager()), address(manager)); + assertEq(positionDescriptor.wrappedNative(), WETH9); + assertEq(positionDescriptor.nativeCurrencyLabel(), nativeCurrencyLabel); + } + + function test_currencyRatioPriority_mainnet_succeeds() public { + vm.chainId(1); + assertEq(positionDescriptor.currencyRatioPriority(WETH9), CurrencyRatioSortOrder.DENOMINATOR); + assertEq(positionDescriptor.currencyRatioPriority(address(0)), CurrencyRatioSortOrder.DENOMINATOR); + assertEq(positionDescriptor.currencyRatioPriority(USDC), CurrencyRatioSortOrder.NUMERATOR_MOST); + assertEq(positionDescriptor.currencyRatioPriority(USDT), CurrencyRatioSortOrder.NUMERATOR_MORE); + assertEq(positionDescriptor.currencyRatioPriority(DAI), CurrencyRatioSortOrder.NUMERATOR); + assertEq(positionDescriptor.currencyRatioPriority(TBTC), CurrencyRatioSortOrder.DENOMINATOR_MORE); + assertEq(positionDescriptor.currencyRatioPriority(WBTC), CurrencyRatioSortOrder.DENOMINATOR_MOST); + assertEq(positionDescriptor.currencyRatioPriority(makeAddr("ALICE")), 0); + } + + function test_currencyRatioPriority_notMainnet_succeeds() public { + assertEq(positionDescriptor.currencyRatioPriority(WETH9), CurrencyRatioSortOrder.DENOMINATOR); + assertEq(positionDescriptor.currencyRatioPriority(address(0)), CurrencyRatioSortOrder.DENOMINATOR); + assertEq(positionDescriptor.currencyRatioPriority(USDC), 0); + assertEq(positionDescriptor.currencyRatioPriority(USDT), 0); + assertEq(positionDescriptor.currencyRatioPriority(DAI), 0); + assertEq(positionDescriptor.currencyRatioPriority(TBTC), 0); + assertEq(positionDescriptor.currencyRatioPriority(WBTC), 0); + assertEq(positionDescriptor.currencyRatioPriority(makeAddr("ALICE")), 0); + } + + function test_flipRatio_succeeds() public { + vm.chainId(1); + // bc price = token1/token0 + assertTrue(positionDescriptor.flipRatio(USDC, WETH9)); + assertFalse(positionDescriptor.flipRatio(DAI, USDC)); + assertFalse(positionDescriptor.flipRatio(WBTC, WETH9)); + assertFalse(positionDescriptor.flipRatio(WBTC, USDC)); + assertFalse(positionDescriptor.flipRatio(WBTC, DAI)); + } + + function test_tokenURI_succeeds() public { + int24 tickLower = int24(key.tickSpacing); + int24 tickUpper = int24(key.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: key, tickLower: tickLower, tickUpper: tickUpper}); + uint256 tokenId = lpm.nextTokenId(); + mint(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)); + } + + function test_tokenURI_revertsWithInvalidTokenId() public { + int24 tickLower = int24(key.tickSpacing); + int24 tickUpper = int24(key.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: key, tickLower: tickLower, tickUpper: tickUpper}); + uint256 tokenId = lpm.nextTokenId(); + mint(config, liquidityToAdd, ActionConstants.MSG_SENDER, ZERO_BYTES); + + vm.expectRevert(abi.encodeWithSelector(PositionDescriptor.InvalidTokenId.selector, tokenId + 1)); + + positionDescriptor.tokenURI(lpm, tokenId + 1); + } +} diff --git a/test/base64.sol b/test/base64.sol new file mode 100644 index 00000000..811a8f09 --- /dev/null +++ b/test/base64.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title Base64 +/// @author Brecht Devos - +/// @notice Provides functions for decoding base64 +library Base64 { + bytes internal constant TABLE_DECODE = hex"0000000000000000000000000000000000000000000000000000000000000000" + hex"00000000000000000000003e0000003f3435363738393a3b3c3d000000000000" + hex"00000102030405060708090a0b0c0d0e0f101112131415161718190000000000" + hex"001a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132330000000000"; + + function decode(string memory _data) internal pure returns (bytes memory) { + bytes memory data = bytes(_data); + + if (data.length == 0) return new bytes(0); + require(data.length % 4 == 0, "invalid base64 decoder input"); + + // load the table into memory + bytes memory table = TABLE_DECODE; + + // every 4 characters represent 3 bytes + uint256 decodedLen = (data.length / 4) * 3; + + // add some extra buffer at the end required for the writing + bytes memory result = new bytes(decodedLen + 32); + + assembly { + // padding with '=' + let lastBytes := mload(add(data, mload(data))) + if eq(and(lastBytes, 0xFF), 0x3d) { + decodedLen := sub(decodedLen, 1) + if eq(and(lastBytes, 0xFFFF), 0x3d3d) { decodedLen := sub(decodedLen, 1) } + } + + // set the actual output length + mstore(result, decodedLen) + + // prepare the lookup table + let tablePtr := add(table, 1) + + // input ptr + let dataPtr := data + let endPtr := add(dataPtr, mload(data)) + + // result ptr, jump over length + let resultPtr := add(result, 32) + + // run over the input, 4 characters at a time + for {} lt(dataPtr, endPtr) {} { + // read 4 characters + dataPtr := add(dataPtr, 4) + let input := mload(dataPtr) + + // write 3 bytes + let output := + add( + add( + shl(18, and(mload(add(tablePtr, and(shr(24, input), 0xFF))), 0xFF)), + shl(12, and(mload(add(tablePtr, and(shr(16, input), 0xFF))), 0xFF)) + ), + add( + shl(6, and(mload(add(tablePtr, and(shr(8, input), 0xFF))), 0xFF)), + and(mload(add(tablePtr, and(input, 0xFF))), 0xFF) + ) + ) + mstore(resultPtr, shl(232, output)) + resultPtr := add(resultPtr, 3) + } + } + + return result; + } +} diff --git a/test/libraries/Descriptor.t.sol b/test/libraries/Descriptor.t.sol new file mode 100644 index 00000000..e191c5a5 --- /dev/null +++ b/test/libraries/Descriptor.t.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Descriptor} from "../../src/libraries/Descriptor.sol"; +import {Test} from "forge-std/Test.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; + +contract DescriptorTest is Test { + function test_feeToPercentString_succeeds() public pure { + assertEq(Descriptor.feeToPercentString(0x800000), "Dynamic"); + assertEq(Descriptor.feeToPercentString(0), "0%"); + assertEq(Descriptor.feeToPercentString(1), "0.0001%"); + assertEq(Descriptor.feeToPercentString(30), "0.003%"); + assertEq(Descriptor.feeToPercentString(33), "0.0033%"); + assertEq(Descriptor.feeToPercentString(500), "0.05%"); + assertEq(Descriptor.feeToPercentString(2500), "0.25%"); + assertEq(Descriptor.feeToPercentString(3000), "0.3%"); + assertEq(Descriptor.feeToPercentString(10000), "1%"); + assertEq(Descriptor.feeToPercentString(17000), "1.7%"); + assertEq(Descriptor.feeToPercentString(100000), "10%"); + assertEq(Descriptor.feeToPercentString(150000), "15%"); + assertEq(Descriptor.feeToPercentString(102000), "10.2%"); + assertEq(Descriptor.feeToPercentString(1000000), "100%"); + assertEq(Descriptor.feeToPercentString(1005000), "100.5%"); + assertEq(Descriptor.feeToPercentString(10000000), "1000%"); + assertEq(Descriptor.feeToPercentString(12300000), "1230%"); + } + + function test_addressToString_succeeds() public pure { + assertEq(Descriptor.addressToString(address(0)), "0x0000000000000000000000000000000000000000"); + assertEq(Descriptor.addressToString(address(1)), "0x0000000000000000000000000000000000000001"); + assertEq( + Descriptor.addressToString(0x1111111111111111111111111111111111111111), + "0x1111111111111111111111111111111111111111" + ); + assertEq( + Descriptor.addressToString(0x1234AbcdEf1234abcDef1234aBCdEF1234ABCDEF), + "0x1234abcdef1234abcdef1234abcdef1234abcdef" + ); + } + + function test_escapeQuotes_succeeds() public pure { + assertEq(Descriptor.escapeQuotes(""), ""); + assertEq(Descriptor.escapeQuotes("a"), "a"); + assertEq(Descriptor.escapeQuotes("abc"), "abc"); + assertEq(Descriptor.escapeQuotes("a\"bc"), "a\\\"bc"); + assertEq(Descriptor.escapeQuotes("a\"b\"c"), "a\\\"b\\\"c"); + assertEq(Descriptor.escapeQuotes("a\"b\"c\""), "a\\\"b\\\"c\\\""); + assertEq(Descriptor.escapeQuotes("\"a\"b\"c\""), "\\\"a\\\"b\\\"c\\\""); + assertEq(Descriptor.escapeQuotes("\"a\"b\"c\"\""), "\\\"a\\\"b\\\"c\\\"\\\""); + } + + function test_tickToDecimalString_withTickSpacing10() public pure { + int24 tickSpacing = 10; + int24 minTick = (TickMath.MIN_TICK / tickSpacing) * tickSpacing; + int24 maxTick = (TickMath.MAX_TICK / tickSpacing) * tickSpacing; + assertEq(Descriptor.tickToDecimalString(minTick, tickSpacing, 18, 18, false), "MIN"); + assertEq(Descriptor.tickToDecimalString(maxTick, tickSpacing, 18, 18, false), "MAX"); + assertEq(Descriptor.tickToDecimalString(1, tickSpacing, 18, 18, false), "1.0001"); + int24 otherMinTick = (TickMath.MIN_TICK / 60) * 60; + assertEq( + Descriptor.tickToDecimalString(otherMinTick, tickSpacing, 18, 18, false), + "0.0000000000000000000000000000000000000029387" + ); + } + + function test_tickToDecimalString_withTickSpacing60() public pure { + int24 tickSpacing = 60; + int24 minTick = (TickMath.MIN_TICK / tickSpacing) * tickSpacing; + int24 maxTick = (TickMath.MAX_TICK / tickSpacing) * tickSpacing; + assertEq(Descriptor.tickToDecimalString(minTick, tickSpacing, 18, 18, false), "MIN"); + assertEq(Descriptor.tickToDecimalString(maxTick, tickSpacing, 18, 18, false), "MAX"); + assertEq(Descriptor.tickToDecimalString(-1, tickSpacing, 18, 18, false), "0.99990"); + int24 otherMinTick = (TickMath.MIN_TICK / 200) * 200; + assertEq( + Descriptor.tickToDecimalString(otherMinTick, tickSpacing, 18, 18, false), + "0.0000000000000000000000000000000000000029387" + ); + } + + function test_tickToDecimalString_withTickSpacing200() public pure { + int24 tickSpacing = 200; + int24 minTick = (TickMath.MIN_TICK / tickSpacing) * tickSpacing; + int24 maxTick = (TickMath.MAX_TICK / tickSpacing) * tickSpacing; + assertEq(Descriptor.tickToDecimalString(minTick, tickSpacing, 18, 18, false), "MIN"); + assertEq(Descriptor.tickToDecimalString(maxTick, tickSpacing, 18, 18, false), "MAX"); + assertEq(Descriptor.tickToDecimalString(0, tickSpacing, 18, 18, false), "1.0000"); + int24 otherMinTick = (TickMath.MIN_TICK / 60) * 60; + assertEq( + Descriptor.tickToDecimalString(otherMinTick, tickSpacing, 18, 18, false), + "0.0000000000000000000000000000000000000029387" + ); + } + + function test_tickToDecimalString_ratio_returnsInverseMediumNumbers() public pure { + int24 tickSpacing = 200; + assertEq(Descriptor.tickToDecimalString(10, tickSpacing, 18, 18, false), "1.0010"); + assertEq(Descriptor.tickToDecimalString(10, tickSpacing, 18, 18, true), "0.99900"); + } + + function test_tickToDecimalString_ratio_returnsInverseLargeNumbers() public pure { + int24 tickSpacing = 200; + assertEq(Descriptor.tickToDecimalString(487272, tickSpacing, 18, 18, false), "1448400000000000000000"); + assertEq(Descriptor.tickToDecimalString(487272, tickSpacing, 18, 18, true), "0.00000000000000000000069041"); + } + + function test_tickToDecimalString_ratio_returnsInverseSmallNumbers() public pure { + int24 tickSpacing = 200; + assertEq(Descriptor.tickToDecimalString(-387272, tickSpacing, 18, 18, false), "0.000000000000000015200"); + assertEq(Descriptor.tickToDecimalString(-387272, tickSpacing, 18, 18, true), "65791000000000000"); + } + + function test_tickToDecimalString_differentDecimals() public pure { + int24 tickSpacing = 200; + assertEq(Descriptor.tickToDecimalString(1000, tickSpacing, 18, 18, true), "0.90484"); + assertEq(Descriptor.tickToDecimalString(1000, tickSpacing, 18, 10, true), "90484000"); + assertEq(Descriptor.tickToDecimalString(1000, tickSpacing, 10, 18, true), "0.0000000090484"); + } + + function test_fixedPointToDecimalString() public pure { + assertEq( + Descriptor.fixedPointToDecimalString(1457647476727839560029885420909913413788472405159, 18, 18), + "338490000000000000000000000000000000000" + ); + assertEq( + Descriptor.fixedPointToDecimalString(4025149349925610116743993887520032712, 18, 18), "2581100000000000" + ); + assertEq(Descriptor.fixedPointToDecimalString(3329657202331788924044422905302854, 18, 18), "1766200000"); + assertEq(Descriptor.fixedPointToDecimalString(16241966553695418990605751641065, 18, 18), "42026"); + assertEq(Descriptor.fixedPointToDecimalString(2754475062069337566441091812235, 18, 18), "1208.7"); + assertEq(Descriptor.fixedPointToDecimalString(871041495427277622831427623669, 18, 18), "120.87"); + assertEq(Descriptor.fixedPointToDecimalString(275447506206933756644109181223, 18, 18), "12.087"); + + assertEq(Descriptor.fixedPointToDecimalString(88028870788706913884596530851, 18, 18), "1.2345"); + assertEq(Descriptor.fixedPointToDecimalString(79228162514264337593543950336, 18, 18), "1.0000"); + assertEq(Descriptor.fixedPointToDecimalString(27837173154497669652482281089, 18, 18), "0.12345"); + assertEq(Descriptor.fixedPointToDecimalString(1559426812423768092342, 18, 18), "0.00000000000000038741"); + assertEq(Descriptor.fixedPointToDecimalString(74532606916587, 18, 18), "0.00000000000000000000000000000088498"); + assertEq( + Descriptor.fixedPointToDecimalString(4947797163, 18, 18), "0.0000000000000000000000000000000000000029387" + ); + + assertEq(Descriptor.fixedPointToDecimalString(79228162514264337593543950336, 18, 16), "100.00"); + assertEq(Descriptor.fixedPointToDecimalString(250541448375047931186413801569, 18, 17), "100.00"); + assertEq(Descriptor.fixedPointToDecimalString(79228162514264337593543950336, 24, 5), "1.0000"); + + assertEq(Descriptor.fixedPointToDecimalString(79228162514264337593543950336, 10, 18), "0.000000010000"); + assertEq(Descriptor.fixedPointToDecimalString(79228162514264337593543950336, 7, 18), "0.000000000010000"); + } +} diff --git a/test/libraries/SVG.t.sol b/test/libraries/SVG.t.sol new file mode 100644 index 00000000..c322483e --- /dev/null +++ b/test/libraries/SVG.t.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {SVG} from "../../src/libraries/SVG.sol"; +import {Test} from "forge-std/Test.sol"; + +contract DescriptorTest is Test { + function test_rangeLocation_succeeds() public pure { + (string memory x, string memory y) = SVG.rangeLocation(-887_272, -887_100); + assertEq(x, "8"); + assertEq(y, "7"); + (x, y) = SVG.rangeLocation(-100_000, -90_000); + assertEq(x, "8"); + assertEq(y, "10.5"); + (x, y) = SVG.rangeLocation(-50_000, -20_000); + assertEq(x, "8"); + assertEq(y, "14.25"); + (x, y) = SVG.rangeLocation(-10_000, -5_000); + assertEq(x, "10"); + assertEq(y, "18"); + (x, y) = SVG.rangeLocation(-5_000, -4_000); + assertEq(x, "11"); + assertEq(y, "21"); + (x, y) = SVG.rangeLocation(4_000, 5_000); + assertEq(x, "13"); + assertEq(y, "23"); + (x, y) = SVG.rangeLocation(10_000, 15_000); + assertEq(x, "15"); + assertEq(y, "25"); + (x, y) = SVG.rangeLocation(25_000, 50_000); + assertEq(x, "18"); + assertEq(y, "26"); + (x, y) = SVG.rangeLocation(100_000, 125_000); + assertEq(x, "21"); + assertEq(y, "27"); + (x, y) = SVG.rangeLocation(200_000, 100_000); + assertEq(x, "24"); + assertEq(y, "27"); + (x, y) = SVG.rangeLocation(887_272, 887_272); + assertEq(x, "24"); + assertEq(y, "27"); + } + + function test_isRare_succeeds() public pure { + bool result = SVG.isRare(1, 0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB); + assertTrue(result); + result = SVG.isRare(2, 0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB); + assertFalse(result); + } +} diff --git a/test/mocks/MockERC721Permit.sol b/test/mocks/MockERC721Permit.sol index 6056a638..bf379208 100644 --- a/test/mocks/MockERC721Permit.sol +++ b/test/mocks/MockERC721Permit.sol @@ -12,4 +12,8 @@ contract MockERC721Permit is ERC721Permit_v4 { tokenId = ++lastTokenId; _mint(msg.sender, tokenId); } + + function tokenURI(uint256) public pure override returns (string memory) { + return "mock"; + } } diff --git a/test/position-managers/Permit.t.sol b/test/position-managers/Permit.t.sol index ea3a9c6d..392fc17e 100644 --- a/test/position-managers/Permit.t.sol +++ b/test/position-managers/Permit.t.sol @@ -64,7 +64,7 @@ contract PermitTest is Test, PosmTestSetup { keccak256( abi.encode( keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"), - keccak256("Uniswap V4 Positions NFT"), // storage is private on EIP712.sol so we need to hardcode these + keccak256("Uniswap v4 Positions NFT"), // storage is private on EIP712.sol so we need to hardcode these block.chainid, address(lpm) ) diff --git a/test/position-managers/PositionManager.t.sol b/test/position-managers/PositionManager.t.sol index c0c0b96d..5762768b 100644 --- a/test/position-managers/PositionManager.t.sol +++ b/test/position-managers/PositionManager.t.sol @@ -95,7 +95,8 @@ contract PositionManagerTest is Test, PosmTestSetup, LiquidityFuzzers { (key, poolId) = initPool(currency0, currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1, ZERO_BYTES); // Try to add liquidity at that range, but the token reenters posm - PositionConfig memory config = PositionConfig({poolKey: key, tickLower: 0, tickUpper: 60}); + PositionConfig memory config = + PositionConfig({poolKey: key, tickLower: -int24(key.tickSpacing), tickUpper: int24(key.tickSpacing)}); bytes memory calls = getMintEncoded(config, 1e18, ActionConstants.MSG_SENDER, ""); // Permit2.transferFrom does not bubble the ContractLocked error and instead reverts with its own error diff --git a/test/shared/PosmTestSetup.sol b/test/shared/PosmTestSetup.sol index 81c4f9bf..0a79edd1 100644 --- a/test/shared/PosmTestSetup.sol +++ b/test/shared/PosmTestSetup.sol @@ -15,6 +15,7 @@ import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol" import {DeployPermit2} from "permit2/test/utils/DeployPermit2.sol"; import {HookSavesDelta} from "./HookSavesDelta.sol"; import {HookModifyLiquidities} from "./HookModifyLiquidities.sol"; +import {PositionDescriptor} from "../../src/PositionDescriptor.sol"; import {ERC721PermitHash} from "../../src/libraries/ERC721PermitHash.sol"; /// @notice A shared test contract that wraps the v4-core deployers contract and exposes basic liquidity operations on posm. @@ -22,6 +23,7 @@ contract PosmTestSetup is Test, Deployers, DeployPermit2, LiquidityOperations { uint256 constant STARTING_USER_BALANCE = 10_000_000 ether; IAllowanceTransfer permit2; + PositionDescriptor public positionDescriptor; HookSavesDelta hook; address hookAddr = address(uint160(Hooks.AFTER_ADD_LIQUIDITY_FLAG | Hooks.AFTER_REMOVE_LIQUIDITY_FLAG)); @@ -57,7 +59,8 @@ contract PosmTestSetup is Test, Deployers, DeployPermit2, LiquidityOperations { function deployPosm(IPoolManager poolManager) internal { // We use deployPermit2() to prevent having to use via-ir in this repository. permit2 = IAllowanceTransfer(deployPermit2()); - lpm = new PositionManager(poolManager, permit2, 100_000); + positionDescriptor = new PositionDescriptor(poolManager, 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, "ETH"); + lpm = new PositionManager(poolManager, permit2, 100_000, positionDescriptor); } function seedBalance(address to) internal {