diff --git a/.gitmodules b/.gitmodules index 5312e13ab..b3caf1fcb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,15 @@ [submodule "solidity_contracts/lib/forge-std"] path = solidity_contracts/lib/forge-std url = https://github.com/foundry-rs/forge-std - branch = v1.3.0 [submodule "solidity_contracts/lib/kakarot-lib"] path = solidity_contracts/lib/kakarot-lib url = https://github.com/kkrt-labs/kakarot-lib +[submodule "solidity_contracts/lib/v3-core"] + path = solidity_contracts/lib/v3-core + url = https://github.com/Uniswap/v3-core +[submodule "solidity_contracts/lib/openzeppelin"] + path = solidity_contracts/lib/openzeppelin + url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "solidity_contracts/lib/base64-sol"] + path = solidity_contracts/lib/base64-sol + url = https://github.com/Brechtpd/base64 diff --git a/kakarot_scripts/constants.py b/kakarot_scripts/constants.py index 1f192b15c..6c69c7d47 100644 --- a/kakarot_scripts/constants.py +++ b/kakarot_scripts/constants.py @@ -55,7 +55,7 @@ class NetworkType(Enum): "l1_rpc_url": "http://127.0.0.1:8545", "type": NetworkType.DEV, "check_interval": 0.01, - "max_wait": 1, + "max_wait": 3, }, "katana": { "name": "katana", @@ -64,7 +64,7 @@ class NetworkType(Enum): "l1_rpc_url": "http://127.0.0.1:8545", "type": NetworkType.DEV, "check_interval": 0.01, - "max_wait": 2, + "max_wait": 3, }, "madara": { "name": "madara", @@ -182,6 +182,7 @@ class ChainId(IntEnum): 16, ) SOURCE_DIR = Path("src") +SOURCE_PATH = Path("solidity_contracs/src") SOURCE_DIR_FIXTURES = Path("tests/fixtures") CONTRACTS = {p.stem: p for p in list(SOURCE_DIR.glob("**/*.cairo"))} CONTRACTS_FIXTURES = {p.stem: p for p in list(SOURCE_DIR_FIXTURES.glob("**/*.cairo"))} diff --git a/kakarot_scripts/utils/kakarot.py b/kakarot_scripts/utils/kakarot.py index 6dea3956e..89a07ca02 100644 --- a/kakarot_scripts/utils/kakarot.py +++ b/kakarot_scripts/utils/kakarot.py @@ -1,9 +1,10 @@ +import asyncio import functools import json import logging from pathlib import Path from types import MethodType -from typing import List, Optional, Union, cast +from typing import Any, Dict, List, Optional, Tuple, Union, cast import rlp from eth_abi import decode @@ -34,6 +35,7 @@ EVM_PRIVATE_KEY, NETWORK, RPC_CLIENT, + SOURCE_PATH, WEB3, ChainId, ) @@ -53,6 +55,8 @@ logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) +LIBRARY_PLACEHOLDER_PREFIX_BYTES = 17 + class EvmTransactionError(Exception): pass @@ -115,21 +119,24 @@ def get_solidity_artifacts( def get_contract( contract_app: str, contract_name: str, + deployed_libraries: List[Dict[str, str]] = None, address=None, caller_eoa: Optional[Account] = None, ) -> Web3Contract: - artifacts = get_solidity_artifacts(contract_app, contract_name) + bytecode, bytecode_runtime = replace_library_placeholders( + artifacts, deployed_libraries or [] + ) contract = cast( Web3Contract, WEB3.eth.contract( address=to_checksum_address(address) if address is not None else address, abi=artifacts["abi"], - bytecode=artifacts["bytecode"], + bytecode=bytecode, ), ) - contract.bytecode_runtime = HexBytes(artifacts["bytecode_runtime"]) + contract.bytecode_runtime = HexBytes(bytecode_runtime) try: for fun in contract.functions: @@ -140,12 +147,31 @@ def get_contract( return contract +def replace_library_placeholders( + artifacts: Dict[str, str], deployed_libraries: List[Dict[str, str]] +) -> Tuple[str, str]: + bytecode = artifacts["bytecode"] + bytecode_runtime = artifacts["bytecode_runtime"] + + for library in deployed_libraries: + placeholder = f"__${library['identifier'].hex()}$__" + address = Web3.to_checksum_address(library["address"]).lstrip("0x") + bytecode = bytecode.replace(placeholder, address) + bytecode_runtime = bytecode_runtime.replace(placeholder, address) + logger.info(f"ℹ️ Replaced {library['identifier'].hex()} in bytecode") + + return bytecode, bytecode_runtime + + +def compute_library_identifier(library_app: str, library_name: str) -> bytes: + return keccak( + f"{SOURCE_PATH}/{library_app}/{library_name}.sol:{library_name}".encode("utf-8") + )[:LIBRARY_PLACEHOLDER_PREFIX_BYTES] + + async def deploy( - contract_app: str, contract_name: str, *args, **kwargs + contract: Web3Contract, *args, caller_eoa: Optional[Account] = None, **kwargs ) -> Web3Contract: - logger.info(f"⏳ Deploying {contract_name}") - caller_eoa = kwargs.pop("caller_eoa", None) - contract = get_contract(contract_app, contract_name, caller_eoa=caller_eoa) max_fee = kwargs.pop("max_fee", None) value = kwargs.pop("value", 0) receipt, response, success, _ = await eth_send_transaction( @@ -166,6 +192,59 @@ async def deploy( ).contract_address else: starknet_address, evm_address = response + + return receipt, starknet_address, evm_address + + +async def deploy_library( + library_app: str, library_name: str, *args: Any, **kwargs: Any +) -> Dict[str, Any]: + logger.info(f"⏳ Deploying {library_name}") + + caller_eoa = kwargs.pop("caller_eoa", None) + library = get_contract(library_app, library_name, caller_eoa=caller_eoa) + + _, _, evm_address = await deploy(library, *args, **kwargs) + + library.address = Web3.to_checksum_address(f"0x{evm_address:040x}") + library_identifier = compute_library_identifier(library_app, library_name) + + logger.info(f"✅ Library {library_name} deployed at address {library.address}") + + return { + "address": library.address, + "identifier": library_identifier, + } + + +async def deploy_contract( + contract_app: str, + contract_name: str, + *args: Any, + **kwargs: Any, +) -> Web3Contract: + logger.info(f"⏳ Deploying {contract_name}") + associated_libraries = kwargs.pop("associated_libraries", []) + deployed_libraries = ( + await asyncio.gather( + *(deploy_library(app, name) for app, name in associated_libraries) + ) + if associated_libraries + else [] + ) + + caller_eoa = kwargs.pop("caller_eoa", None) + contract = get_contract( + contract_app, + contract_name, + deployed_libraries=deployed_libraries, + caller_eoa=caller_eoa, + ) + + _, starknet_address, evm_address = await deploy( + contract, *args, caller_eoa=caller_eoa, **kwargs + ) + contract.address = Web3.to_checksum_address(f"0x{evm_address:040x}") contract.starknet_address = starknet_address logger.info(f"✅ {contract_name} deployed at address {contract.address}") diff --git a/solidity_contracts/lib/base64-sol b/solidity_contracts/lib/base64-sol new file mode 160000 index 000000000..dcbf852ba --- /dev/null +++ b/solidity_contracts/lib/base64-sol @@ -0,0 +1 @@ +Subproject commit dcbf852ba545b3d15de0ac0ef88dce934c090c8e diff --git a/solidity_contracts/lib/openzeppelin b/solidity_contracts/lib/openzeppelin new file mode 160000 index 000000000..8e0296096 --- /dev/null +++ b/solidity_contracts/lib/openzeppelin @@ -0,0 +1 @@ +Subproject commit 8e0296096449d9b1cd7c5631e917330635244c37 diff --git a/solidity_contracts/lib/v3-core b/solidity_contracts/lib/v3-core new file mode 160000 index 000000000..e3589b192 --- /dev/null +++ b/solidity_contracts/lib/v3-core @@ -0,0 +1 @@ +Subproject commit e3589b192d0be27e100cd0daaf6c97204fdb1899 diff --git a/solidity_contracts/src/UniswapV3/HexStrings.sol b/solidity_contracts/src/UniswapV3/HexStrings.sol new file mode 100644 index 000000000..3ff683a29 --- /dev/null +++ b/solidity_contracts/src/UniswapV3/HexStrings.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.7.6; + +library HexStrings { + bytes16 internal constant ALPHABET = "0123456789abcdef"; + + /// @notice Converts a `uint256` to its ASCII `string` hexadecimal representation with fixed length. + /// @dev Credit to Open Zeppelin under MIT license https://github.com/OpenZeppelin/openzeppelin-contracts/blob/243adff49ce1700e0ecb99fe522fb16cff1d1ddc/contracts/utils/Strings.sol#L55 + 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) { + buffer[i] = ALPHABET[value & 0xf]; + value >>= 4; + } + require(value == 0, "Strings: hex length insufficient"); + return string(buffer); + } + + 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/solidity_contracts/src/UniswapV3/NFTDescriptor.sol b/solidity_contracts/src/UniswapV3/NFTDescriptor.sol new file mode 100644 index 000000000..f58628808 --- /dev/null +++ b/solidity_contracts/src/UniswapV3/NFTDescriptor.sol @@ -0,0 +1,462 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.0; +pragma abicoder v2; + +import "v3-core/contracts/interfaces/IUniswapV3Pool.sol"; +import "v3-core/contracts/libraries/TickMath.sol"; +import "v3-core/contracts/libraries/BitMath.sol"; +import "v3-core/contracts/libraries/FullMath.sol"; +import "openzeppelin/utils/Strings.sol"; +import "openzeppelin/math/SafeMath.sol"; +import "openzeppelin/math/SignedSafeMath.sol"; +import "base64-sol/base64.sol"; +import "./HexStrings.sol"; +import "./NFTSVG.sol"; + +library NFTDescriptor { + using TickMath for int24; + using Strings for uint256; + using SafeMath for uint256; + using SafeMath for uint160; + using SafeMath for uint8; + using SignedSafeMath for int256; + using HexStrings for uint256; + + uint256 constant sqrt10X128 = 1076067327063303206878105757264492625226; + + struct ConstructTokenURIParams { + uint256 tokenId; + address quoteTokenAddress; + address baseTokenAddress; + string quoteTokenSymbol; + string baseTokenSymbol; + uint8 quoteTokenDecimals; + uint8 baseTokenDecimals; + bool flipRatio; + int24 tickLower; + int24 tickUpper; + int24 tickCurrent; + int24 tickSpacing; + uint24 fee; + address poolAddress; + } + + function constructTokenURI(ConstructTokenURIParams memory params) external view returns (string memory) { + string memory name = + string(abi.encodePacked("Uniswap V3 ", params.quoteTokenSymbol, "/", params.baseTokenSymbol)); + string memory descriptionPartOne = generateDescriptionPartOne( + escapeQuotes(params.quoteTokenSymbol), + escapeQuotes(params.baseTokenSymbol), + addressToString(params.poolAddress) + ); + string memory descriptionPartTwo = generateDescriptionPartTwo( + params.tokenId.toString(), + escapeQuotes(params.baseTokenSymbol), + addressToString(params.quoteTokenAddress), + addressToString(params.baseTokenAddress), + feeToPercentString(params.fee) + ); + bytes memory tmp1 = bytes(generateSVGImage(params)); + string memory image = Base64.encode(tmp1); + + 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, + '"}' + ) + ) + ) + ) + ); + } + + function escapeQuotes(string memory symbol) internal pure returns (string memory) { + bytes memory symbolBytes = bytes(symbol); + uint8 quotesCount = 0; + for (uint8 i = 0; i < symbolBytes.length; i++) { + if (symbolBytes[i] == '"') { + quotesCount++; + } + } + if (quotesCount > 0) { + bytes memory escapedBytes = new bytes(symbolBytes.length + (quotesCount)); + uint256 index; + for (uint8 i = 0; i < symbolBytes.length; i++) { + if (symbolBytes[i] == '"') { + escapedBytes[index++] = "\\"; + } + escapedBytes[index++] = symbolBytes[i]; + } + return string(escapedBytes); + } + return symbol; + } + + function generateDescriptionPartOne( + string memory quoteTokenSymbol, + string memory baseTokenSymbol, + string memory poolAddress + ) private pure returns (string memory) { + return string( + abi.encodePacked( + "This NFT represents a liquidity position in a Uniswap V3 ", + quoteTokenSymbol, + "-", + baseTokenSymbol, + " pool. ", + "The owner of this NFT can modify or redeem the position.\\n", + "\\nPool Address: ", + poolAddress, + "\\n", + quoteTokenSymbol + ) + ); + } + + function generateDescriptionPartTwo( + string memory tokenId, + string memory baseTokenSymbol, + string memory quoteTokenAddress, + string memory baseTokenAddress, + string memory feeTier + ) private pure returns (string memory) { + return string( + abi.encodePacked( + " Address: ", + quoteTokenAddress, + "\\n", + baseTokenSymbol, + " Address: ", + baseTokenAddress, + "\\nFee Tier: ", + feeTier, + "\\nToken ID: ", + tokenId, + "\\n\\n", + unicode"⚠️ DISCLAIMER: Due diligence is imperative when assessing this NFT. Make sure token addresses match the expected tokens, as token symbols may be imitated." + ) + ); + } + + function generateName(ConstructTokenURIParams memory params, string memory feeTier) + private + pure + returns (string memory) + { + return string( + abi.encodePacked( + "Uniswap - ", + feeTier, + " - ", + escapeQuotes(params.quoteTokenSymbol), + "/", + escapeQuotes(params.baseTokenSymbol), + " - ", + tickToDecimalString( + !params.flipRatio ? params.tickLower : params.tickUpper, + params.tickSpacing, + params.baseTokenDecimals, + params.quoteTokenDecimals, + params.flipRatio + ), + "<>", + tickToDecimalString( + !params.flipRatio ? params.tickUpper : params.tickLower, + params.tickSpacing, + params.baseTokenDecimals, + params.quoteTokenDecimals, + params.flipRatio + ) + ) + ); + } + + struct DecimalStringParams { + // significant figures of decimal + uint256 sigfigs; + // length of decimal string + uint8 bufferLength; + // ending index for significant figures (function 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.add(1); zerosCursor++) { + 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(uint256(48).add(params.sigfigs % 10))); + params.sigfigs /= 10; + } + return string(buffer); + } + + function tickToDecimalString( + int24 tick, + int24 tickSpacing, + uint8 baseTokenDecimals, + uint8 quoteTokenDecimals, + 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.getSqrtRatioAtTick(tick); + if (flipRatio) { + sqrtRatioX96 = uint160(uint256(1 << 192).div(sqrtRatioX96)); + } + return fixedPointToDecimalString(sqrtRatioX96, baseTokenDecimals, quoteTokenDecimals); + } + } + + function sigfigsRounded(uint256 value, uint8 digits) private pure returns (uint256, bool) { + bool extraDigit; + if (digits > 5) { + value = value.div((10 ** (digits - 5))); + } + bool roundUp = value % 10 > 4; + value = value.div(10); + if (roundUp) { + value = value + 1; + } + // 99999 -> 100000 gives an extra sigfig + if (value == 100000) { + value /= 10; + extraDigit = true; + } + return (value, extraDigit); + } + + function adjustForDecimalPrecision(uint160 sqrtRatioX96, uint8 baseTokenDecimals, uint8 quoteTokenDecimals) + private + pure + returns (uint256 adjustedSqrtRatioX96) + { + uint256 difference = abs(int256(baseTokenDecimals).sub(int256(quoteTokenDecimals))); + if (difference > 0 && difference <= 18) { + if (baseTokenDecimals > quoteTokenDecimals) { + adjustedSqrtRatioX96 = sqrtRatioX96.mul(10 ** (difference.div(2))); + if (difference % 2 == 1) { + adjustedSqrtRatioX96 = FullMath.mulDiv(adjustedSqrtRatioX96, sqrt10X128, 1 << 128); + } + } else { + adjustedSqrtRatioX96 = sqrtRatioX96.div(10 ** (difference.div(2))); + if (difference % 2 == 1) { + adjustedSqrtRatioX96 = FullMath.mulDiv(adjustedSqrtRatioX96, 1 << 128, sqrt10X128); + } + } + } else { + adjustedSqrtRatioX96 = uint256(sqrtRatioX96); + } + } + + function abs(int256 x) private pure returns (uint256) { + return uint256(x >= 0 ? x : -x); + } + + // @notice Returns string that includes first 5 significant figures of a decimal number + // @param sqrtRatioX96 a sqrt price + function fixedPointToDecimalString(uint160 sqrtRatioX96, uint8 baseTokenDecimals, uint8 quoteTokenDecimals) + internal + pure + returns (string memory) + { + uint256 adjustedSqrtRatioX96 = adjustForDecimalPrecision(sqrtRatioX96, baseTokenDecimals, quoteTokenDecimals); + uint256 value = FullMath.mulDiv(adjustedSqrtRatioX96, adjustedSqrtRatioX96, 1 << 64); + + bool priceBelow1 = adjustedSqrtRatioX96 < 2 ** 96; + if (priceBelow1) { + // 10 ** 43 is precision needed to retrieve 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).add(uint8(43).sub(digits))); + params.zerosStartIndex = 2; + params.zerosEndIndex = uint8(uint256(43).sub(digits).add(1)); + params.sigfigIndex = uint8(params.bufferLength.sub(1)); + } else if (digits >= 9) { + // no decimal in price string + params.bufferLength = uint8(digits.sub(4)); + params.zerosStartIndex = 5; + params.zerosEndIndex = uint8(params.bufferLength.sub(1)); + params.sigfigIndex = 4; + } else { + // 5 sigfigs surround decimal + params.bufferLength = 6; + params.sigfigIndex = 5; + params.decimalIndex = uint8(digits.sub(5).add(1)); + } + params.sigfigs = sigfigs; + params.isLessThanOne = priceBelow1; + params.isPercent = false; + + return generateDecimalString(params); + } + + // @notice Returns string as decimal percentage of fee amount. + // @param fee fee amount + function feeToPercentString(uint24 fee) internal pure returns (string memory) { + if (fee == 0) { + return "0%"; + } + uint24 temp = fee; + uint256 digits; + uint8 numSigfigs; + 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) { + // if decimal > 1 (5th digit is the ones place) + uint256 decimalPlace = digits.sub(numSigfigs) >= 4 ? 0 : 1; + nZeros = digits.sub(5) < (numSigfigs.sub(1)) ? 0 : digits.sub(5).sub(numSigfigs.sub(1)); + params.zerosStartIndex = numSigfigs; + params.zerosEndIndex = uint8(params.zerosStartIndex.add(nZeros).sub(1)); + params.sigfigIndex = uint8(params.zerosStartIndex.sub(1).add(decimalPlace)); + params.bufferLength = uint8(nZeros.add(numSigfigs.add(1)).add(decimalPlace)); + } else { + // else if decimal < 1 + nZeros = uint256(5).sub(digits); + params.zerosStartIndex = 2; + params.zerosEndIndex = uint8(nZeros.add(params.zerosStartIndex).sub(1)); + params.bufferLength = uint8(nZeros.add(numSigfigs.add(2))); + params.sigfigIndex = uint8((params.bufferLength).sub(2)); + params.isLessThanOne = true; + } + params.sigfigs = uint256(fee).div(10 ** (digits.sub(numSigfigs))); + params.isPercent = true; + params.decimalIndex = digits > 4 ? uint8(digits.sub(4)) : 0; + + return generateDecimalString(params); + } + + function addressToString(address addr) internal pure returns (string memory) { + return (uint256(addr)).toHexString(20); + } + + function generateSVGImage(ConstructTokenURIParams memory params) internal pure returns (string memory svg) { + NFTSVG.SVGParams memory svgParams = NFTSVG.SVGParams({ + quoteToken: addressToString(params.quoteTokenAddress), + baseToken: addressToString(params.baseTokenAddress), + poolAddress: params.poolAddress, + quoteTokenSymbol: params.quoteTokenSymbol, + baseTokenSymbol: params.baseTokenSymbol, + 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: tokenToColorHex(uint256(params.quoteTokenAddress), 136), + color1: tokenToColorHex(uint256(params.baseTokenAddress), 136), + color2: tokenToColorHex(uint256(params.quoteTokenAddress), 0), + color3: tokenToColorHex(uint256(params.baseTokenAddress), 0), + x1: scale(getCircleCoord(uint256(params.quoteTokenAddress), 16, params.tokenId), 0, 255, 16, 274), + y1: scale(getCircleCoord(uint256(params.baseTokenAddress), 16, params.tokenId), 0, 255, 100, 484), + x2: scale(getCircleCoord(uint256(params.quoteTokenAddress), 32, params.tokenId), 0, 255, 16, 274), + y2: scale(getCircleCoord(uint256(params.baseTokenAddress), 32, params.tokenId), 0, 255, 100, 484), + x3: scale(getCircleCoord(uint256(params.quoteTokenAddress), 48, params.tokenId), 0, 255, 16, 274), + y3: scale(getCircleCoord(uint256(params.baseTokenAddress), 48, params.tokenId), 0, 255, 100, 484) + }); + + return NFTSVG.generateSVG(svgParams); + } + + 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.sub(inMn).mul(outMx.sub(outMn)).div(inMx.sub(inMn)).add(outMn)).toString(); + } + + function tokenToColorHex(uint256 token, uint256 offset) internal pure returns (string memory str) { + return string((token >> offset).toHexStringNoPrefix(3)); + } + + function getCircleCoord(uint256 tokenAddress, uint256 offset, uint256 tokenId) internal pure returns (uint256) { + return (sliceTokenHex(tokenAddress, offset) * tokenId) % 255; + } + + function sliceTokenHex(uint256 token, uint256 offset) internal pure returns (uint256) { + return uint256(uint8(token >> offset)); + } +} diff --git a/solidity_contracts/src/UniswapV3/NFTSVG.sol b/solidity_contracts/src/UniswapV3/NFTSVG.sol new file mode 100644 index 000000000..e5de224e5 --- /dev/null +++ b/solidity_contracts/src/UniswapV3/NFTSVG.sol @@ -0,0 +1,395 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.6; + +import "openzeppelin/utils/Strings.sol"; +import "v3-core/contracts/libraries/BitMath.sol"; +import "base64-sol/base64.sol"; + +/// @title NFTSVG +/// @notice Provides a function for generating an SVG associated with a Uniswap NFT +library NFTSVG { + using Strings for uint256; + + 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 quoteToken; + string baseToken; + address poolAddress; + string quoteTokenSymbol; + string baseTokenSymbol; + 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; + } + + function generateSVG(SVGParams memory params) internal pure returns (string memory svg) { + /* + address: "0xe8ab59d3bcde16a29912de83a90eb39628cfc163", + msg: "Forged in SVG for Uniswap in 2021 by 0xe8ab59d3bcde16a29912de83a90eb39628cfc163", + sig: "0x2df0e99d9cbfec33a705d83f75666d98b22dea7c1af412c584f7d626d83f02875993df740dc87563b9c73378f8462426da572d7989de88079a382ad96c57b68d1b", + version: "2" + */ + return string( + abi.encodePacked( + generateSVGDefs(params), + generateSVGBorderText( + params.quoteToken, params.baseToken, params.quoteTokenSymbol, params.baseTokenSymbol + ), + generateSVGCardMantle(params.quoteTokenSymbol, params.baseTokenSymbol, params.feeTier), + generageSvgCurve(params.tickLower, params.tickUpper, params.tickSpacing, params.overRange), + generateSVGPositionDataAndLocationCurve(params.tokenId.toString(), params.tickLower, params.tickUpper), + generateSVGRareSparkle(params.tokenId, params.poolAddress), + "" + ) + ); + } + + function generateSVGDefs(SVGParams memory params) private pure returns (string memory svg) { + svg = string( + abi.encodePacked( + '", + "", + '" + ) + ) + ), + '"/>" + ) + ) + ), + '"/>" + ) + ) + ), + '" />', + '" + ) + ) + ), + '" /> ', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ' ', + '', + '', + '' + ) + ); + } + + function generateSVGBorderText( + string memory quoteToken, + string memory baseToken, + string memory quoteTokenSymbol, + string memory baseTokenSymbol + ) private pure returns (string memory svg) { + svg = string( + abi.encodePacked( + '', + '', + baseToken, + unicode" • ", + baseTokenSymbol, + ' ', + ' ', + baseToken, + unicode" • ", + baseTokenSymbol, + ' ', + '', + quoteToken, + unicode" • ", + quoteTokenSymbol, + ' ', + quoteToken, + unicode" • ", + quoteTokenSymbol, + ' ' + ) + ); + } + + function generateSVGCardMantle(string memory quoteTokenSymbol, string memory baseTokenSymbol, string memory feeTier) + private + pure + returns (string memory svg) + { + svg = string( + abi.encodePacked( + ' ', + quoteTokenSymbol, + "/", + baseTokenSymbol, + '', + feeTier, + "", + '' + ) + ); + } + + 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) + ) + ); + } + + 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; + } + } + + 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 (overRange == 1 || overRange == -1) { + svg = string( + abi.encodePacked( + '' + ) + ); + } else { + svg = string( + abi.encodePacked( + '', + '' + ) + ); + } + } + + function generateSVGPositionDataAndLocationCurve(string memory tokenId, int24 tickLower, int24 tickUpper) + private + pure + returns (string memory svg) + { + string memory tickLowerStr = tickToString(tickLower); + string memory tickUpperStr = tickToString(tickUpper); + uint256 str1length = bytes(tokenId).length + 4; + uint256 str2length = bytes(tickLowerStr).length + 10; + uint256 str3length = bytes(tickUpperStr).length + 10; + (string memory xCoord, string memory yCoord) = rangeLocation(tickLower, tickUpper); + svg = string( + abi.encodePacked( + ' ', + '', + 'ID: ', + tokenId, + "", + ' ', + '', + 'Min Tick: ', + tickLowerStr, + "", + ' ', + '', + 'Max Tick: ', + tickUpperStr, + "" '', + '', + '', + '' + ) + ); + } + + 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(tick).toString())); + } + + 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"); + } + } + + function generateSVGRareSparkle(uint256 tokenId, address poolAddress) private pure returns (string memory svg) { + if (isRare(tokenId, poolAddress)) { + svg = string( + abi.encodePacked( + '', + '', + '' + ) + ); + } else { + svg = ""; + } + } + + function isRare(uint256 tokenId, address poolAddress) internal pure returns (bool) { + bytes32 h = keccak256(abi.encodePacked(tokenId, poolAddress)); + return uint256(h) < type(uint256).max / (1 + BitMath.mostSignificantBit(tokenId) * 2); + } +} diff --git a/solidity_contracts/src/UniswapV3/UniswapV3NFTManager.sol b/solidity_contracts/src/UniswapV3/UniswapV3NFTManager.sol new file mode 100644 index 000000000..1a3dd0a3e --- /dev/null +++ b/solidity_contracts/src/UniswapV3/UniswapV3NFTManager.sol @@ -0,0 +1,38 @@ +pragma solidity >=0.7.0; +pragma abicoder v2; + +import "openzeppelin/token/ERC721/ERC721.sol"; +import "./NFTDescriptor.sol"; + +contract UniswapV3NFTManager is ERC721 { + constructor() ERC721("UniswapV3 NFT Positions", "UNIV3") {} + + function tokenURI(uint256 tokenId) public view override returns (string memory) { + return _mockTokenUri(tokenId); + } + + function tokenURIExternal(uint256 tokenId) external returns (string memory) { + return _mockTokenUri(tokenId); + } + + function _mockTokenUri(uint256 tokenId) internal view returns (string memory) { + NFTDescriptor.ConstructTokenURIParams memory params = NFTDescriptor.ConstructTokenURIParams({ + tokenId: tokenId, + quoteTokenAddress: address(0xabcdef), + baseTokenAddress: address(0x123456), + quoteTokenSymbol: "ETH", + baseTokenSymbol: "USDC", + quoteTokenDecimals: 18, + baseTokenDecimals: 6, + flipRatio: false, + tickLower: -887272, + tickUpper: 887272, + tickCurrent: 387272, + tickSpacing: 1000, + fee: 3000, + poolAddress: address(0xc0de) + }); + + return NFTDescriptor.constructTokenURI(params); + } +} diff --git a/tests/end_to_end/UniswapV3/test_univ3_rendering.py b/tests/end_to_end/UniswapV3/test_univ3_rendering.py new file mode 100644 index 000000000..3f4b6fb06 --- /dev/null +++ b/tests/end_to_end/UniswapV3/test_univ3_rendering.py @@ -0,0 +1,19 @@ +import pytest +import pytest_asyncio + + +@pytest_asyncio.fixture(scope="module") +async def univ3_position(deploy_contract, owner): + return await deploy_contract( + "UniswapV3", + "UniswapV3NFTManager", + associated_libraries=[("UniswapV3", "NFTDescriptor")], + caller_eoa=owner.starknet_contract, + ) + + +@pytest.mark.asyncio(scope="session") +@pytest.mark.xfail(reason="Rendering the SVG takes too many steps") +class TestUniswapV3Rendering: + async def test_should_render_position(self, univ3_position): + await univ3_position.tokenURIExternal(1) diff --git a/tests/end_to_end/conftest.py b/tests/end_to_end/conftest.py index 9080246e5..8a84cb978 100644 --- a/tests/end_to_end/conftest.py +++ b/tests/end_to_end/conftest.py @@ -284,13 +284,13 @@ def deploy_contract(default_fee: int): Fixture to deploy and attach a modified web3.contract instance to a contract in kakarot. """ - from kakarot_scripts.utils.kakarot import deploy + from kakarot_scripts.utils.kakarot import deploy_contract async def _factory(contract_app, contract_name, *args, **kwargs): """ Create a web3.contract based on the basename of the target solidity file. """ - return await deploy( + return await deploy_contract( contract_app, contract_name, *args, **kwargs, max_fee=default_fee )