From a41fe3b3e2f4d77a81028777e37813775b824e55 Mon Sep 17 00:00:00 2001 From: Mathieu <60658558+enitrat@users.noreply.github.com> Date: Thu, 25 Jul 2024 22:41:26 +0200 Subject: [PATCH] tests: add univ3 svg generation test (#1292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Time spent on this PR: 1d Adds Mock UniswapV3 contracts that allow querying a dynamically generated on-chain SVG representing a position. The values are mocked, and due to some issues, the base64 encoding of the image crashes with a stack underflow error (tbd). However, the encoding of the rest takes ~27M steps Also added a way to do library linking in the python kakarot scripts ## Pull request type Please check the type of change your PR introduces: - [ ] Bugfix - [ ] Feature - [ ] Code style update (formatting, renaming) - [ ] Refactoring (no functional changes, no api changes) - [ ] Build related changes - [ ] Documentation content changes - [ ] Other (please describe): ## What is the current behavior? Resolves # ## What is the new behavior? - - - - - - This change is [Reviewable](https://reviewable.io/reviews/kkrt-labs/kakarot/1292) --- .gitmodules | 10 +- kakarot_scripts/constants.py | 4 +- kakarot_scripts/utils/kakarot.py | 110 ++++- kakarot_scripts/utils/l1.py | 4 +- solidity_contracts/lib/base64-sol | 1 + solidity_contracts/lib/openzeppelin | 1 + solidity_contracts/lib/v3-core | 1 + .../libraries/UniswapV2Library.sol | 2 +- .../src/UniswapV3/HexStrings.sol | 29 ++ .../src/UniswapV3/NFTDescriptor.sol | 462 ++++++++++++++++++ solidity_contracts/src/UniswapV3/NFTSVG.sol | 395 +++++++++++++++ .../src/UniswapV3/UniswapV3NFTManager.sol | 38 ++ .../PlainOpcodes/test_plain_opcodes.py | 14 +- .../UniswapV2/test_uniswap_v2_factory.py | 2 +- .../UniswapV3/test_univ3_rendering.py | 20 + tests/end_to_end/test_kakarot.py | 8 +- tests/src/kakarot/test_kakarot.py | 12 +- tests/src/utils/test_utils.py | 4 +- 18 files changed, 1082 insertions(+), 35 deletions(-) create mode 160000 solidity_contracts/lib/base64-sol create mode 160000 solidity_contracts/lib/openzeppelin create mode 160000 solidity_contracts/lib/v3-core create mode 100644 solidity_contracts/src/UniswapV3/HexStrings.sol create mode 100644 solidity_contracts/src/UniswapV3/NFTDescriptor.sol create mode 100644 solidity_contracts/src/UniswapV3/NFTSVG.sol create mode 100644 solidity_contracts/src/UniswapV3/UniswapV3NFTManager.sol create mode 100644 tests/end_to_end/UniswapV3/test_univ3_rendering.py 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 4223d3c60..3547a6703 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", diff --git a/kakarot_scripts/utils/kakarot.py b/kakarot_scripts/utils/kakarot.py index 6756b3170..b2179fc54 100644 --- a/kakarot_scripts/utils/kakarot.py +++ b/kakarot_scripts/utils/kakarot.py @@ -3,9 +3,10 @@ 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 async_lru import alru_cache from eth_abi import decode from eth_abi.exceptions import InsufficientDataBytes from eth_account import Account as EvmAccount @@ -72,6 +73,7 @@ def get_solidity_artifacts( except (NameError, FileNotFoundError): foundry_file = toml.loads(Path("foundry.toml").read_text()) + src_path = Path(foundry_file["profile"]["default"]["src"]) all_compilation_outputs = [ json.load(open(file)) for file in Path(foundry_file["profile"]["default"]["out"]).glob( @@ -82,9 +84,7 @@ def get_solidity_artifacts( target_compilation_output = all_compilation_outputs[0] else: target_solidity_file_path = list( - (Path(foundry_file["profile"]["default"]["src"]) / contract_app).glob( - f"**/{contract_name}.sol" - ) + (src_path / contract_app).glob(f"**/{contract_name}.sol") ) if len(target_solidity_file_path) != 1: raise ValueError( @@ -105,31 +105,57 @@ def get_solidity_artifacts( f"found {len(target_compilation_output)} outputs:\n{target_compilation_output}" ) target_compilation_output = target_compilation_output[0] + + def process_link_references( + link_references: Dict[str, Dict[str, Any]] + ) -> Dict[str, Dict[str, Any]]: + return { + Path(file_path) + .relative_to(src_path) + .parts[0]: { + library_name: references + for library_name, references in libraries.items() + } + for file_path, libraries in link_references.items() + } + return { - "bytecode": target_compilation_output["bytecode"]["object"], - "bytecode_runtime": target_compilation_output["deployedBytecode"]["object"], + "bytecode": { + "object": target_compilation_output["bytecode"]["object"], + "linkReferences": process_link_references( + target_compilation_output["bytecode"].get("linkReferences", {}) + ), + }, + "bytecode_runtime": { + "object": target_compilation_output["deployedBytecode"]["object"], + "linkReferences": process_link_references( + target_compilation_output["deployedBytecode"].get("linkReferences", {}) + ), + }, "abi": target_compilation_output["abi"], + "name": contract_name, } -def get_contract( +async def get_contract( contract_app: str, contract_name: str, address=None, caller_eoa: Optional[Account] = None, ) -> Web3Contract: - artifacts = get_solidity_artifacts(contract_app, contract_name) + bytecode, bytecode_runtime = await link_libraries(artifacts) + 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 +166,74 @@ def get_contract( return contract +@alru_cache() +async def get_or_deploy_library(library_app: str, library_name: str) -> str: + """ + Deploy a solidity library if not already deployed and return its address. + + Args: + ---- + library_app (str): The application name of the library. + library_name (str): The name of the library. + + Returns: + ------- + str: The deployed library address as a hexstring with the '0x' prefix. + + """ + library_contract = await deploy(library_app, library_name) + logger.info(f"ℹ️ Deployed {library_name} at address {library_contract.address}") + return library_contract.address + + +async def link_libraries(artifacts: Dict[str, Any]) -> Tuple[str, str]: + """ + Process an artifacts bytecode by linking libraries with their deployed addresses. + + Args: + ---- + artifacts (Dict[str, Any]): The contract artifacts containing bytecode and link references. + + Returns: + ------- + Tuple[str, str]: The processed bytecode and runtime bytecode. + + """ + + async def process_bytecode(bytecode_type: str) -> str: + bytecode_obj = artifacts[bytecode_type] + current_bytecode = bytecode_obj["object"][2:] + link_references = bytecode_obj.get("linkReferences", {}) + + for library_app, libraries in link_references.items(): + for library_name, references in libraries.items(): + library_address = await get_or_deploy_library(library_app, library_name) + + for ref in references: + start, length = ref["start"] * 2, ref["length"] * 2 + placeholder = current_bytecode[start : start + length] + current_bytecode = current_bytecode.replace( + placeholder, library_address[2:].lower() + ) + + logger.info( + f"ℹ️ Replaced {library_name} in {bytecode_type} with address 0x{library_address}" + ) + + return current_bytecode + + bytecode = await process_bytecode("bytecode") + bytecode_runtime = await process_bytecode("bytecode_runtime") + + return bytecode, bytecode_runtime + + async def deploy( contract_app: str, contract_name: str, *args, **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) + contract = await 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( diff --git a/kakarot_scripts/utils/l1.py b/kakarot_scripts/utils/l1.py index 6aed79011..ea54fbea8 100644 --- a/kakarot_scripts/utils/l1.py +++ b/kakarot_scripts/utils/l1.py @@ -103,10 +103,10 @@ def get_l1_contract( L1_RPC_PROVIDER.eth.contract( address=to_checksum_address(address) if address is not None else address, abi=artifacts["abi"], - bytecode=artifacts["bytecode"], + bytecode=artifacts["bytecode"]["object"], ), ) - contract.bytecode_runtime = HexBytes(artifacts["bytecode_runtime"]) + contract.bytecode_runtime = HexBytes(artifacts["bytecode_runtime"]["object"]) try: for fun in contract.functions: 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/UniswapV2Router/libraries/UniswapV2Library.sol b/solidity_contracts/src/UniswapV2Router/libraries/UniswapV2Library.sol index 744fb6de1..b8b813c19 100644 --- a/solidity_contracts/src/UniswapV2Router/libraries/UniswapV2Library.sol +++ b/solidity_contracts/src/UniswapV2Router/libraries/UniswapV2Library.sol @@ -33,7 +33,7 @@ library UniswapV2Library { hex"ff", factory, keccak256(abi.encodePacked(token0, token1)), - hex"666a5b78ea0b660c426b08cb5b7427447e909408067de1a5519c772ee9a3c032" // init code hash + hex"0f5b822a8dffa6ce589a2c240d78a6a2b38a51835a97ceab40c1f301e46ba30b" // init code hash ) ) ) 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/PlainOpcodes/test_plain_opcodes.py b/tests/end_to_end/PlainOpcodes/test_plain_opcodes.py index bc78500a4..5b019df2b 100644 --- a/tests/end_to_end/PlainOpcodes/test_plain_opcodes.py +++ b/tests/end_to_end/PlainOpcodes/test_plain_opcodes.py @@ -157,7 +157,7 @@ async def test_should_create_counters( events = plain_opcodes.events.parse_events(receipt) assert len(events["CreateAddress"]) == count for create_event in events["CreateAddress"]: - deployed_counter = get_contract( + deployed_counter = await get_contract( "PlainOpcodes", "Counter", address=create_event["_address"] ) assert await deployed_counter.count() == 0 @@ -187,7 +187,7 @@ async def test_should_create_counter_and_call_in_the_same_tx( receipt = (await plain_opcodes.createCounterAndCall())["receipt"] events = plain_opcodes.events.parse_events(receipt) address = events["CreateAddress"][0]["_address"] - counter = get_contract("PlainOpcodes", "Counter", address=address) + counter = await get_contract("PlainOpcodes", "Counter", address=address) assert await counter.count() == 0 async def test_should_create_counter_and_invoke_in_the_same_tx( @@ -196,14 +196,14 @@ async def test_should_create_counter_and_invoke_in_the_same_tx( receipt = (await plain_opcodes.createCounterAndInvoke())["receipt"] events = plain_opcodes.events.parse_events(receipt) address = events["CreateAddress"][0]["_address"] - counter = get_contract("PlainOpcodes", "Counter", address=address) + counter = await get_contract("PlainOpcodes", "Counter", address=address) assert await counter.count() == 1 class TestCreate2: async def test_should_collision_after_selfdestruct_different_tx( self, plain_opcodes, owner ): - contract_with_selfdestruct = get_contract( + contract_with_selfdestruct = await get_contract( "PlainOpcodes", "ContractWithSelfdestructMethod" ) salt = 12345 @@ -216,7 +216,7 @@ async def test_should_collision_after_selfdestruct_different_tx( )["receipt"] events = plain_opcodes.events.parse_events(receipt) assert len(events["Create2Address"]) == 1 - contract_with_selfdestruct = get_contract( + contract_with_selfdestruct = await get_contract( "PlainOpcodes", "ContractWithSelfdestructMethod", address=events["Create2Address"][0]["_address"], @@ -258,7 +258,7 @@ async def test_should_deploy_bytecode_at_address( events = plain_opcodes.events.parse_events(receipt) assert len(events["Create2Address"]) == 1 - deployed_counter = get_contract( + deployed_counter = await get_contract( "PlainOpcodes", "Counter", address=events["Create2Address"][0]["_address"], @@ -297,7 +297,7 @@ async def test_should_revert_via_call(self, plain_opcodes, owner): ) )["receipt"] - reverting_contract = get_contract( + reverting_contract = await get_contract( "PlainOpcodes", "ContractRevertsOnMethodCall" ) diff --git a/tests/end_to_end/UniswapV2/test_uniswap_v2_factory.py b/tests/end_to_end/UniswapV2/test_uniswap_v2_factory.py index eadb49a84..6bd61ac43 100644 --- a/tests/end_to_end/UniswapV2/test_uniswap_v2_factory.py +++ b/tests/end_to_end/UniswapV2/test_uniswap_v2_factory.py @@ -51,7 +51,7 @@ async def test_should_create_pair_only_once(self, factory, owner): assert await factory.allPairs(0) == pair_evm_address assert await factory.allPairsLength() == 1 - pair = get_contract( + pair = await get_contract( "UniswapV2", "UniswapV2Pair", address=pair_evm_address, 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..3075df7cf --- /dev/null +++ b/tests/end_to_end/UniswapV3/test_univ3_rendering.py @@ -0,0 +1,20 @@ +import pytest +import pytest_asyncio + +from kakarot_scripts.utils.kakarot import deploy + + +@pytest_asyncio.fixture(scope="module") +async def univ3_position(owner): + return await deploy( + "UniswapV3", + "UniswapV3NFTManager", + 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/test_kakarot.py b/tests/end_to_end/test_kakarot.py index 295f9bdfc..d3daf7e90 100644 --- a/tests/end_to_end/test_kakarot.py +++ b/tests/end_to_end/test_kakarot.py @@ -174,7 +174,9 @@ class TestWriteAccountBytecode: async def test_should_set_account_bytecode(self, new_eoa): counter_artifacts = get_solidity_artifacts("PlainOpcodes", "Counter") eoa = await new_eoa() - bytecode = list(bytes.fromhex(counter_artifacts["bytecode"][2:])) + bytecode = list( + bytes.fromhex(counter_artifacts["bytecode"]["object"][2:]) + ) await invoke( "kakarot", "write_account_bytecode", int(eoa.address, 16), bytecode @@ -192,7 +194,9 @@ async def test_should_set_account_bytecode(self, new_eoa): async def test_should_fail_not_owner(self, new_eoa, other): counter_artifacts = get_solidity_artifacts("PlainOpcodes", "Counter") eoa = await new_eoa() - bytecode = list(bytes.fromhex(counter_artifacts["bytecode"][2:])) + bytecode = list( + bytes.fromhex(counter_artifacts["bytecode"]["object"][2:]) + ) tx_hash = await invoke( "kakarot", diff --git a/tests/src/kakarot/test_kakarot.py b/tests/src/kakarot/test_kakarot.py index 3c902063a..5487952e4 100644 --- a/tests/src/kakarot/test_kakarot.py +++ b/tests/src/kakarot/test_kakarot.py @@ -297,8 +297,8 @@ class TestEthCall: "IAccount.is_valid_jumpdest", lambda addr, data: [1], ) - def test_erc20_transfer(self, get_contract): - erc20 = get_contract("Solmate", "ERC20") + async def test_erc20_transfer(self, get_contract): + erc20 = await get_contract("Solmate", "ERC20") amount = int(1e18) initial_state = { CONTRACT_ADDRESS: { @@ -321,8 +321,8 @@ def test_erc20_transfer(self, get_contract): "IAccount.is_valid_jumpdest", lambda addr, data: [1], ) - def test_erc721_transfer(self, get_contract): - erc721 = get_contract("Solmate", "ERC721") + async def test_erc721_transfer(self, get_contract): + erc721 = await get_contract("Solmate", "ERC721") token_id = 1337 initial_state = { CONTRACT_ADDRESS: { @@ -414,8 +414,8 @@ class TestLoopProfiling: @pytest.mark.NoCI @pytest.mark.parametrize("steps", [10, 50, 100, 200]) @SyscallHandler.patch("IAccount.is_valid_jumpdest", lambda addr, data: [1]) - def test_loop_profiling(self, get_contract, steps): - plain_opcodes = get_contract("PlainOpcodes", "PlainOpcodes") + async def test_loop_profiling(self, get_contract, steps): + plain_opcodes = await get_contract("PlainOpcodes", "PlainOpcodes") initial_state = { CONTRACT_ADDRESS: { "code": list(plain_opcodes.bytecode_runtime), diff --git a/tests/src/utils/test_utils.py b/tests/src/utils/test_utils.py index 871a114db..d1bb4f3a3 100644 --- a/tests/src/utils/test_utils.py +++ b/tests/src/utils/test_utils.py @@ -155,8 +155,8 @@ def test_should_unpack_felt_array_to_bytes32_array(cairo_run, data, expected): class TestInitializeJumpdests: @pytest.mark.slow - def test_should_return_same_as_execution_specs(self, cairo_run): - bytecode = get_contract("PlainOpcodes", "Counter").bytecode_runtime + async def test_should_return_same_as_execution_specs(self, cairo_run): + bytecode = (await get_contract("PlainOpcodes", "Counter")).bytecode_runtime output = cairo_run("test__initialize_jumpdests", bytecode=bytecode) assert set(output) == get_valid_jump_destinations(bytecode)