diff --git a/LICENSE b/LICENSE index 3c2fa0b..7c8fc22 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Elnaril +Copyright (c) 2024 Elnaril Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 42ddfdf..03dc226 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ --- ## Release Notes +### v1.1.0 + - Add support for the TRANSFER function + - Add support for decoding the "revert on fail" flag and prepare for encoding on UR functions that support it. + - Add support for encoding the `execute()` function without deadline ### v1.0.1 - Fix issue #35 - fails to decode input data when there is too many commands ### v1.0.0 @@ -27,12 +31,6 @@ - Remove useless parameter `payer_is_sender` from `v*_swap_exact_in_from_balance()` methods - Update Router ABI - Add uint48 and uint160 in ABI builder -### V0.9.1 - - Fix lint error - - Change `v*_swap_exact_in_from_balance()` `payer_is_sender` default to False. This parameter will be removed in the next version. -### V0.9.0 - - Add support for UNWRAP_WETH encoding - - Add `v2_swap_exact_in_from_balance()` and `v3_swap_exact_in_from_balance()`: 2 convenient methods which are used when the exact in_amount is not known when the transaction is built, typically chained after a `V*_SWAP_EXACT_IN`. --- @@ -52,7 +50,7 @@ on Ethereum Mainnet). It is based on, and is intended to be used with [web3.py]( | 0x01 | V3_SWAP_EXACT_OUT | ✅ | ✅ | 0x02 - 0x03 | | ❌ | ❌ | 0x04 | SWEEP | ✅ | ✅ -| 0x05 | TRANSFER | ❌ | ❌ +| 0x05 | TRANSFER | ✅ | ✅ | 0x06 | PAY_PORTION | ✅ | ✅ | 0x07 | placeholder | N/A | N/A | 0x08 | V2_SWAP_EXACT_IN | ✅ | ✅ @@ -109,7 +107,10 @@ Example of decoded input returned by `decode.function_input()`: { # and its parameters 'recipient': '0x0000000000000000000000000000000000000002', # code indicating the recipient of this command is the router 'amountMin': 4500000000000000000 # the amount in WEI to wrap - } + }, + { + 'revert_on_fail': True # flag indicating if the transaction must revert when this command fails + }, ), ( , # the function corresponding to the second command @@ -121,7 +122,10 @@ Example of decoded input returned by `decode.function_input()`: b'\x00\x01\xf4\xa0\xb8i\x91\xc6!\x8b6\xc1\xd1\x9dJ.' # can be decoded with the method decode.v3_path() b'\x9e\xb0\xce6\x06\xebH', 'payerIsSender': False # a bool indicating if the input tokens come from the sender or are already in the UR - } + }, + { + 'revert_on_fail': True # flag indicating if the transaction must revert when this command fails + }, ) ], 'deadline': 1678441619 # The deadline after which the transaction is not valid any more. @@ -360,5 +364,27 @@ transaction["data"] = encoded_data # you can now sign and send the transaction to the UR ``` +### Other chainable functions +(See integration tests for full examples) + +#### PAY_PORTION +Example where a recipient is paid 1% of the USDC amount: +```python +.pay_portion(FunctionRecipient.CUSTOM, usdc_address, 100, recipient_address) + +``` +#### SWEEP +Example where the sender gets back all remaining USDC: +```python +.sweep(FunctionRecipient.SENDER, usdc_address, 0) +``` + +#### TRANSFER +Example where an USDC amount is sent to a recipient: +```python +.transfer(FunctionRecipient.CUSTOM, usdc_address, usdc_amount, recipient_address) +``` + + ## Tutorials and Recipes: See the [SDK Wiki](https://github.com/Elnaril/uniswap-universal-router-decoder/wiki). diff --git a/coverage.json b/coverage.json index e1ab85a..a992758 100644 --- a/coverage.json +++ b/coverage.json @@ -1 +1 @@ -{"meta": {"version": "7.3.2", "timestamp": "2023-10-26T12:01:24.826201", "branch_coverage": false, "show_contexts": false}, "files": {"uniswap_universal_router_decoder/__init__.py": {"executed_lines": [1, 2, 5], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "uniswap_universal_router_decoder/_abi_builder.py": {"executed_lines": [1, 8, 10, 14, 21, 23, 26, 27, 28, 29, 30, 32, 33, 34, 35, 36, 38, 39, 42, 43, 44, 45, 48, 51, 52, 53, 55, 56, 57, 59, 60, 61, 63, 65, 66, 67, 69, 70, 71, 73, 74, 75, 77, 78, 79, 81, 82, 84, 85, 86, 88, 89, 90, 92, 93, 94, 97, 98, 99, 111, 113, 114, 115, 116, 117, 119, 120, 121, 122, 123, 125, 126, 127, 128, 129, 130, 131, 132, 134, 135, 136, 137, 139, 140, 141, 142, 143, 145, 146, 147, 148, 150, 151, 152, 153, 154, 156, 157, 158, 159, 160, 162, 163, 164, 165, 167, 168, 169, 170], "summary": {"covered_lines": 108, "num_statements": 108, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "uniswap_universal_router_decoder/_constants.py": {"executed_lines": [1, 8, 13, 16, 17, 18, 20], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "uniswap_universal_router_decoder/_decoder.py": {"executed_lines": [1, 8, 9, 17, 18, 19, 26, 27, 28, 31, 32, 33, 34, 35, 37, 44, 45, 46, 47, 48, 50, 51, 52, 53, 54, 55, 56, 57, 58, 60, 69, 70, 71, 72, 73, 75, 76, 78, 79, 88, 89, 90, 91, 92, 93, 94, 101, 103, 104, 106], "summary": {"covered_lines": 49, "num_statements": 49, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "uniswap_universal_router_decoder/_encoder.py": {"executed_lines": [1, 8, 10, 11, 22, 23, 24, 25, 26, 27, 28, 34, 35, 40, 47, 48, 49, 50, 51, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 67, 68, 69, 71, 75, 78, 79, 80, 81, 82, 83, 84, 86, 87, 90, 95, 96, 97, 99, 104, 105, 106, 107, 108, 109, 111, 112, 113, 114, 116, 117, 118, 119, 120, 122, 135, 136, 137, 138, 140, 141, 142, 143, 144, 146, 159, 160, 161, 162, 164, 171, 172, 173, 174, 175, 177, 197, 198, 199, 210, 212, 230, 239, 246, 247, 248, 249, 250, 252, 272, 273, 274, 285, 287, 294, 295, 296, 297, 298, 299, 301, 322, 323, 324, 335, 337, 356, 365, 372, 373, 374, 375, 376, 377, 379, 400, 401, 402, 413, 415, 419, 424, 425, 426, 427, 428, 430, 442, 443, 451, 453, 454, 455, 456, 457, 459, 474, 475, 476, 485, 487, 488, 489, 490, 491, 493, 509, 514, 516, 517, 518, 527, 529, 538, 543], "summary": {"covered_lines": 166, "num_statements": 166, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "uniswap_universal_router_decoder/_enums.py": {"executed_lines": [1, 10, 12, 13, 16, 18, 19, 20, 21, 22, 23, 24, 25, 26, 29, 30, 31, 32, 35, 37, 38, 39], "summary": {"covered_lines": 21, "num_statements": 21, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "uniswap_universal_router_decoder/router_codec.py": {"executed_lines": [1, 8, 9, 16, 20, 21, 26, 27, 28, 29, 32, 33, 34, 37, 38, 39, 40, 41, 42, 43, 45, 46, 47, 49, 50, 54, 56, 57, 61, 63, 64, 68, 70, 71, 101, 107, 112, 113, 114, 115], "summary": {"covered_lines": 39, "num_statements": 39, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}}, "totals": {"covered_lines": 392, "num_statements": 392, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}} \ No newline at end of file +{"meta": {"format": 2, "version": "7.4.1", "timestamp": "2024-02-06T09:54:56.307613", "branch_coverage": false, "show_contexts": false}, "files": {"uniswap_universal_router_decoder/__init__.py": {"executed_lines": [1, 2, 5], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "uniswap_universal_router_decoder/_abi_builder.py": {"executed_lines": [1, 8, 10, 14, 21, 23, 26, 27, 28, 29, 30, 32, 33, 34, 35, 36, 38, 39, 42, 43, 44, 45, 48, 51, 52, 53, 55, 56, 57, 59, 60, 61, 63, 65, 66, 67, 69, 70, 71, 73, 74, 75, 77, 78, 79, 81, 82, 84, 85, 86, 88, 89, 90, 92, 93, 94, 97, 98, 99, 112, 114, 115, 116, 117, 118, 120, 121, 122, 123, 124, 126, 127, 128, 129, 130, 131, 132, 133, 135, 136, 137, 138, 140, 141, 142, 143, 144, 146, 147, 148, 149, 151, 152, 153, 154, 155, 157, 158, 159, 160, 161, 163, 164, 165, 166, 168, 169, 170, 171, 173, 174, 175, 176], "summary": {"covered_lines": 112, "num_statements": 112, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "uniswap_universal_router_decoder/_constants.py": {"executed_lines": [1, 8, 13, 16, 17, 18, 19, 20, 22], "summary": {"covered_lines": 8, "num_statements": 8, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "uniswap_universal_router_decoder/_decoder.py": {"executed_lines": [1, 8, 9, 17, 18, 19, 26, 27, 28, 34, 35, 36, 37, 38, 40, 47, 48, 49, 50, 51, 53, 54, 55, 56, 57, 58, 59, 62, 63, 64, 65, 67, 76, 77, 78, 79, 80, 82, 83, 85, 86, 95, 96, 97, 98, 99, 100, 101, 108, 110, 111, 113], "summary": {"covered_lines": 51, "num_statements": 51, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "uniswap_universal_router_decoder/_encoder.py": {"executed_lines": [1, 8, 10, 21, 22, 23, 24, 25, 26, 27, 33, 34, 41, 48, 51, 52, 53, 54, 55, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 71, 72, 73, 75, 79, 82, 83, 84, 85, 86, 87, 88, 90, 91, 94, 99, 100, 101, 103, 108, 109, 110, 112, 113, 114, 115, 117, 118, 119, 120, 122, 123, 124, 125, 126, 128, 142, 143, 144, 145, 147, 148, 149, 150, 151, 153, 167, 168, 169, 170, 172, 179, 180, 181, 182, 183, 185, 206, 207, 208, 219, 221, 240, 249, 256, 257, 258, 259, 260, 262, 283, 284, 285, 296, 298, 305, 306, 307, 308, 309, 310, 312, 334, 335, 336, 347, 349, 369, 378, 385, 386, 387, 388, 389, 390, 392, 414, 415, 416, 427, 429, 433, 438, 439, 440, 441, 442, 444, 457, 458, 466, 468, 469, 470, 471, 472, 474, 490, 491, 492, 501, 503, 504, 505, 506, 507, 509, 526, 531, 533, 534, 535, 544, 546, 547, 548, 549, 550, 552, 570, 571, 572, 581, 583, 590, 591, 592, 594, 595], "summary": {"covered_lines": 180, "num_statements": 180, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "uniswap_universal_router_decoder/_enums.py": {"executed_lines": [1, 10, 12, 13, 16, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 30, 31, 38, 39, 40, 43, 45, 46, 47, 48, 49], "summary": {"covered_lines": 24, "num_statements": 24, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "uniswap_universal_router_decoder/router_codec.py": {"executed_lines": [1, 8, 9, 16, 20, 21, 26, 27, 28, 29, 32, 33, 34, 37, 38, 39, 40, 41, 42, 43, 45, 46, 47, 49, 50, 54, 56, 57, 61, 63, 64, 68, 70, 71, 101, 107, 112, 113, 114, 115], "summary": {"covered_lines": 39, "num_statements": 39, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}}, "totals": {"covered_lines": 417, "num_statements": 417, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}} \ No newline at end of file diff --git a/integration_tests/test_transfers.py b/integration_tests/test_transfers.py new file mode 100644 index 0000000..565aed1 --- /dev/null +++ b/integration_tests/test_transfers.py @@ -0,0 +1,206 @@ +import os +import subprocess +import time + +from eth_utils import keccak +from web3 import ( + Account, + Web3, +) +from web3.types import Wei + +from uniswap_universal_router_decoder import ( + FunctionRecipient, + RouterCodec, +) + + +web3_provider = os.environ['WEB3_HTTP_PROVIDER_URL_ETHEREUM_MAINNET'] +w3 = Web3(Web3.HTTPProvider("http://127.0.0.1:8545")) +chain_id = 1337 +block_number = 19167456 +gas_limit = 800_000 + +sender = Account.from_key(keccak(text="moo")) +assert sender.address == "0xcd7328a5D376D5530f054EAF0B9D235a4Fd36059" +init_amount = 100 * 10**18 + +recipients = tuple(Account.from_key(keccak(text=sound)) for sound in ("baa", "maa", "wehee", "oink", "gaggle", "kut")) + +erc20_abi = '[{"constant":true,"inputs":[],"name":"name","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_spender","type":"address"},{"name":"_value","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"totalSupply","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_from","type":"address"},{"name":"_to","type":"address"},{"name":"_value","type":"uint256"}],"name":"transferFrom","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"decimals","outputs":[{"name":"","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"_owner","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"symbol","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_to","type":"address"},{"name":"_value","type":"uint256"}],"name":"transfer","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"_owner","type":"address"},{"name":"_spender","type":"address"}],"name":"allowance","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"anonymous":false,"inputs":[{"indexed":true,"name":"_from","type":"address"},{"indexed":true,"name":"_to","type":"address"},{"indexed":false,"name":"_value","type":"uint256"}],"name":"Transfer","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"_owner","type":"address"},{"indexed":true,"name":"_spender","type":"address"},{"indexed":false,"name":"_value","type":"uint256"}],"name":"Approval","type":"event"}]' # noqa +weth_abi = '[{"constant":true,"inputs":[],"name":"name","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"guy","type":"address"},{"name":"wad","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"totalSupply","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"src","type":"address"},{"name":"dst","type":"address"},{"name":"wad","type":"uint256"}],"name":"transferFrom","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"wad","type":"uint256"}],"name":"withdraw","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"decimals","outputs":[{"name":"","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"symbol","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"dst","type":"address"},{"name":"wad","type":"uint256"}],"name":"transfer","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[],"name":"deposit","outputs":[],"payable":true,"stateMutability":"payable","type":"function"},{"constant":true,"inputs":[{"name":"","type":"address"},{"name":"","type":"address"}],"name":"allowance","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"payable":true,"stateMutability":"payable","type":"fallback"},{"anonymous":false,"inputs":[{"indexed":true,"name":"src","type":"address"},{"indexed":true,"name":"guy","type":"address"},{"indexed":false,"name":"wad","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"src","type":"address"},{"indexed":true,"name":"dst","type":"address"},{"indexed":false,"name":"wad","type":"uint256"}],"name":"Transfer","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"dst","type":"address"},{"indexed":false,"name":"wad","type":"uint256"}],"name":"Deposit","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"src","type":"address"},{"indexed":false,"name":"wad","type":"uint256"}],"name":"Withdrawal","type":"event"}]' # noqa + +weth_address = Web3.to_checksum_address("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") +weth_contract = w3.eth.contract(address=weth_address, abi=weth_abi) + +usdc_address = Web3.to_checksum_address("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") +usdc_contract = w3.eth.contract(address=usdc_address, abi=erc20_abi) + +ur_address = Web3.to_checksum_address("0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD") + +codec = RouterCodec() + + +def launch_ganache(): + ganache_process = subprocess.Popen( + f"""ganache + --logging.quiet='true' + --fork.url='{web3_provider}' + --fork.blockNumber='{block_number}' + --miner.defaultGasPrice='30000000000' + --wallet.accounts='{sender.key.hex()}','{init_amount}' + """.replace("\n", " "), + shell=True, + ) + time.sleep(3) + parent_id = ganache_process.pid + return parent_id + + +def kill_processes(parent_id): + processes = [str(parent_id), ] + pgrep_process = subprocess.run( + f"pgrep -P {parent_id}", shell=True, text=True, capture_output=True + ).stdout.strip("\n") + children_ids = pgrep_process.split("\n") if len(pgrep_process) > 0 else [] + processes.extend(children_ids) + subprocess.run(f"kill {' '.join(processes)}", shell=True, text=True, capture_output=True) + + +def check_initialization(): + assert w3.eth.chain_id == chain_id # 1337 + assert w3.eth.block_number == block_number + 1 + assert w3.eth.get_balance(sender.address) == init_amount + for acc in recipients + (sender, ): + assert usdc_contract.functions.balanceOf(acc.address).call() == 0 + print(" => Initialization: OK") + + +def send_transaction(value, encoded_data): + trx_params = { + "from": sender.address, + "to": ur_address, + "gas": gas_limit, + "maxPriorityFeePerGas": w3.eth.max_priority_fee, + "maxFeePerGas": w3.eth.gas_price + w3.eth.max_priority_fee, + "type": '0x2', + "chainId": chain_id, + "value": value, + "nonce": w3.eth.get_transaction_count(sender.address), + "data": encoded_data, + } + raw_transaction = w3.eth.account.sign_transaction(trx_params, sender.key).rawTransaction + trx_hash = w3.eth.send_raw_transaction(raw_transaction) + return trx_hash + + +def buy_and_transfer(): + """ + Sender is going to use eth to: + - send 100 USDC to 4 recipients and the sender (total: 500 usdc) + - send 0.1 eth to the 2 other recipients (total: 0.2 eth) + """ + usdc_amount_per_recipient = Wei(100 * 10**6) + eth_amount_per_recipient = Wei(int(0.1 * 10**18)) + amount_in_max = Wei(int(0.22 * 10**18)) # for weth -> usdc swap + amount_out = Wei(5 * usdc_amount_per_recipient) + value = amount_in_max + 2 * eth_amount_per_recipient # eth sent to the UR + + v3_path = [weth_address, 500, usdc_address] + eth_address = Web3.to_checksum_address("0x0000000000000000000000000000000000000000") + + encoded_input = ( + codec + .encode + .chain() + # weth conversion and swap + .wrap_eth(FunctionRecipient.ROUTER, amount_in_max) + .v3_swap_exact_out(FunctionRecipient.ROUTER, amount_out, amount_in_max, v3_path, payer_is_sender=False) + # usdc transfer + .transfer(FunctionRecipient.SENDER, usdc_address, usdc_amount_per_recipient) # transfer usdc to sender + .transfer(FunctionRecipient.CUSTOM, usdc_address, usdc_amount_per_recipient, recipients[0].address) # transfer usdc to 1st recipient # noqa + .transfer(FunctionRecipient.CUSTOM, usdc_address, usdc_amount_per_recipient, recipients[1].address) # transfer usdc to 2nd recipient # noqa + .transfer(FunctionRecipient.CUSTOM, usdc_address, usdc_amount_per_recipient, recipients[2].address) # transfer usdc to 3rd recipient # noqa + .transfer(FunctionRecipient.CUSTOM, usdc_address, usdc_amount_per_recipient, recipients[3].address) # transfer usdc to 4th recipient # noqa + # eth transfer + .transfer(FunctionRecipient.CUSTOM, eth_address, eth_amount_per_recipient, recipients[4].address) # transfer eth to 5th recipient # noqa + .transfer(FunctionRecipient.CUSTOM, eth_address, eth_amount_per_recipient, recipients[5].address) # transfer eth to 6th recipient # noqa + .unwrap_weth(FunctionRecipient.SENDER, 0) # unwrap and send back all remaining eth to sender + .build() + ) + + trx_hash = send_transaction(value, encoded_input) + + receipt = w3.eth.wait_for_transaction_receipt(trx_hash) + assert receipt["status"] == 1, f'receipt["status"] is actually {receipt["status"]}' # trx status + + assert usdc_contract.functions.balanceOf(sender.address).call() == usdc_amount_per_recipient + for i in range(4): + assert usdc_contract.functions.balanceOf(recipients[i].address).call() == usdc_amount_per_recipient + + for i in range(4, 6): + assert w3.eth.get_balance(recipients[i].address) == eth_amount_per_recipient + + sender_eth_balance = w3.eth.get_balance(sender.address) + print("Sender ETH balance", sender_eth_balance / 10**18) + assert sender_eth_balance == 99573567178309372475, f"Actual ETH balance is {sender_eth_balance}" + + print(" => BUY & TRANSFER USDC/ETH: OK") + + +def simple_transfers(): + """ + 48 ETH transfers in one transaction + """ + value = Wei(10**18) + total_value = 48 * value + eth_address = Web3.to_checksum_address("0x0000000000000000000000000000000000000000") + + chain = codec.encode.chain() + for _ in range(8): + for j in range(6): + chain = chain.transfer(FunctionRecipient.CUSTOM, eth_address, value, recipients[j].address) + + encoded_input = chain.build() + + trx_hash = send_transaction(total_value, encoded_input) + receipt = w3.eth.wait_for_transaction_receipt(trx_hash) + assert receipt["status"] == 1, f'receipt["status"] is actually {receipt["status"]}' # trx status + + for i in range(6): + print(recipients[i].address, w3.eth.get_balance(recipients[i].address)) + assert w3.eth.get_balance(recipients[i].address) >= 8 * 10**18 + + gas_used = receipt["gasUsed"] + print("Gas used (total/per transfer):", gas_used, gas_used // 48) + assert receipt["gasUsed"] == 557986 < 48 * 21000 + + print(" => SIMPLE ETH MASS TRANSFERS: OK") + + +def launch_integration_tests(): + print("------------------------------------------") + print("| Launching integration tests |") + print("------------------------------------------") + check_initialization() + buy_and_transfer() + simple_transfers() + + +def print_success_message(): + print("------------------------------------------") + print("| Integration tests are successful !! :) |") + print("------------------------------------------") + + +def main(): + ganache_pid = launch_ganache() + try: + launch_integration_tests() + print_success_message() + finally: + kill_processes(ganache_pid) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 429719f..1698918 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "uniswap-universal-router-decoder" -version = "1.0.1" +version = "1.1.0.dev0" authors = [ { name="Elnaril", email="elnaril_dev@caramail.com" }, ] diff --git a/tests/test_decoder.py b/tests/test_decoder.py index 3b3ce7c..6d96b9c 100644 --- a/tests/test_decoder.py +++ b/tests/test_decoder.py @@ -71,6 +71,7 @@ def test_decode_transaction(trx_hash, w3, rpc_endpoint, expected_fct_names): for i, expected_name in enumerate(expected_fct_names): if expected_name: assert expected_name == command_inputs[i][0].fn_name + assert command_inputs[i][2]['revert_on_fail'] is True else: assert isinstance(command_inputs[i], str) int(command_inputs[i], 16) # check the str is actually a hex diff --git a/tests/test_encoder.py b/tests/test_encoder.py index ee0d683..0d5ba35 100644 --- a/tests/test_encoder.py +++ b/tests/test_encoder.py @@ -200,16 +200,21 @@ def test_chain_permit2_permit(codec): @pytest.mark.parametrize( - "router_functions, expected_command", + "router_function, revert_on_fail, expected_command", ( - ((), ""), - ((_RouterFunction.PERMIT2_PERMIT, ), "0a"), - ((_RouterFunction.PERMIT2_PERMIT, _RouterFunction.V2_SWAP_EXACT_IN), "0a08"), - ((_RouterFunction.WRAP_ETH, _RouterFunction.PERMIT2_PERMIT, _RouterFunction.V2_SWAP_EXACT_IN), "0b0a08"), + # Todo: use functions that actually support the NO_REVERT_FLAG, like SUDOSWAP or another NFT function + (_RouterFunction.V3_SWAP_EXACT_IN, True, 0x00), + (_RouterFunction.V3_SWAP_EXACT_IN, False, 0x80), + (_RouterFunction.V2_SWAP_EXACT_IN, True, 0x08), + (_RouterFunction.V2_SWAP_EXACT_IN, False, 0x88), + (_RouterFunction.PERMIT2_PERMIT, True, 0x0a), + (_RouterFunction.PERMIT2_PERMIT, False, 0x8a), + (_RouterFunction.WRAP_ETH, True, 0x0b), + (_RouterFunction.WRAP_ETH, False, 0x8b), ) ) -def test_to_command(router_functions, expected_command, codec): - assert codec.encode.chain()._to_command(*router_functions).hex() == expected_command +def test_get_command(router_function, revert_on_fail, expected_command, codec): + assert codec.encode.chain()._get_command(router_function, revert_on_fail) == expected_command def test_chain_v2_swap_exact_in_with_permit(codec): @@ -290,3 +295,8 @@ def test_pay_portion_argument_validity(function_recipient, token_address, bips, codec.encode.chain().pay_portion(function_recipient, token_address, bips, custom_recipient).build(1698245843) # noqa else: _ = codec.encode.chain().pay_portion(function_recipient, token_address, bips, custom_recipient).build(1698245843) # noqa + + +def test_transfer(codec): + encoded_input = codec.encode.chain().transfer(FunctionRecipient.CUSTOM, Web3.to_checksum_address("0x0000000000000000000000000000000000000000"), 4 * 10**15, Web3.to_checksum_address("0xaDFec019eE085a93A9e947CF3ECC5f29a36EfAc0")).build() # noqa + assert encoded_input == HexStr("0x24856bc300000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000105000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000adfec019ee085a93a9e947cf3ecc5f29a36efac0000000000000000000000000000000000000000000000000000e35fa931a0000") # noqa diff --git a/uniswap_universal_router_decoder/_abi_builder.py b/uniswap_universal_router_decoder/_abi_builder.py index 54ac2f2..dc5bce6 100644 --- a/uniswap_universal_router_decoder/_abi_builder.py +++ b/uniswap_universal_router_decoder/_abi_builder.py @@ -107,6 +107,7 @@ def build_abi_map(self) -> _ABIMap: _RouterFunction.UNWRAP_WETH: self._add_mapping(self._build_unwrap_weth), _RouterFunction.SWEEP: self._add_mapping(self._build_sweep), _RouterFunction.PAY_PORTION: self._add_mapping(self._build_pay_portion), + _RouterFunction.TRANSFER: self._add_mapping(self._build_transfer) } return abi_map @@ -168,3 +169,8 @@ def _build_sweep() -> _FunctionABI: def _build_pay_portion() -> _FunctionABI: builder = _FunctionABIBuilder("PAY_PORTION") return builder.add_address("token").add_address("recipient").add_int("bips").build() + + @staticmethod + def _build_transfer() -> _FunctionABI: + builder = _FunctionABIBuilder("TRANSFER") + return builder.add_address("token").add_address("recipient").add_uint256("value").build() diff --git a/uniswap_universal_router_decoder/_constants.py b/uniswap_universal_router_decoder/_constants.py index bffd78b..ab0d862 100644 --- a/uniswap_universal_router_decoder/_constants.py +++ b/uniswap_universal_router_decoder/_constants.py @@ -15,6 +15,8 @@ _execution_function_input_types = ["bytes", "bytes[]", "int"] _execution_function_selector = HexStr("0x3593564c") +_execution_without_deadline_function_input_types = ["bytes", "bytes[]"] +_execution_without_deadline_function_selector = HexStr("0x24856bc3") _router_abi = '[{"inputs":[{"components":[{"internalType":"address","name":"permit2","type":"address"},{"internalType":"address","name":"weth9","type":"address"},{"internalType":"address","name":"seaportV1_5","type":"address"},{"internalType":"address","name":"seaportV1_4","type":"address"},{"internalType":"address","name":"openseaConduit","type":"address"},{"internalType":"address","name":"nftxZap","type":"address"},{"internalType":"address","name":"x2y2","type":"address"},{"internalType":"address","name":"foundation","type":"address"},{"internalType":"address","name":"sudoswap","type":"address"},{"internalType":"address","name":"elementMarket","type":"address"},{"internalType":"address","name":"nft20Zap","type":"address"},{"internalType":"address","name":"cryptopunks","type":"address"},{"internalType":"address","name":"looksRareV2","type":"address"},{"internalType":"address","name":"routerRewardsDistributor","type":"address"},{"internalType":"address","name":"looksRareRewardsDistributor","type":"address"},{"internalType":"address","name":"looksRareToken","type":"address"},{"internalType":"address","name":"v2Factory","type":"address"},{"internalType":"address","name":"v3Factory","type":"address"},{"internalType":"bytes32","name":"pairInitCodeHash","type":"bytes32"},{"internalType":"bytes32","name":"poolInitCodeHash","type":"bytes32"}],"internalType":"struct RouterParameters","name":"params","type":"tuple"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[],"name":"BalanceTooLow","type":"error"},{"inputs":[],"name":"BuyPunkFailed","type":"error"},{"inputs":[],"name":"ContractLocked","type":"error"},{"inputs":[],"name":"ETHNotAccepted","type":"error"},{"inputs":[{"internalType":"uint256","name":"commandIndex","type":"uint256"},{"internalType":"bytes","name":"message","type":"bytes"}],"name":"ExecutionFailed","type":"error"},{"inputs":[],"name":"FromAddressIsNotOwner","type":"error"},{"inputs":[],"name":"InsufficientETH","type":"error"},{"inputs":[],"name":"InsufficientToken","type":"error"},{"inputs":[],"name":"InvalidBips","type":"error"},{"inputs":[{"internalType":"uint256","name":"commandType","type":"uint256"}],"name":"InvalidCommandType","type":"error"},{"inputs":[],"name":"InvalidOwnerERC1155","type":"error"},{"inputs":[],"name":"InvalidOwnerERC721","type":"error"},{"inputs":[],"name":"InvalidPath","type":"error"},{"inputs":[],"name":"InvalidReserves","type":"error"},{"inputs":[],"name":"InvalidSpender","type":"error"},{"inputs":[],"name":"LengthMismatch","type":"error"},{"inputs":[],"name":"SliceOutOfBounds","type":"error"},{"inputs":[],"name":"TransactionDeadlinePassed","type":"error"},{"inputs":[],"name":"UnableToClaim","type":"error"},{"inputs":[],"name":"UnsafeCast","type":"error"},{"inputs":[],"name":"V2InvalidPath","type":"error"},{"inputs":[],"name":"V2TooLittleReceived","type":"error"},{"inputs":[],"name":"V2TooMuchRequested","type":"error"},{"inputs":[],"name":"V3InvalidAmountOut","type":"error"},{"inputs":[],"name":"V3InvalidCaller","type":"error"},{"inputs":[],"name":"V3InvalidSwap","type":"error"},{"inputs":[],"name":"V3TooLittleReceived","type":"error"},{"inputs":[],"name":"V3TooMuchRequested","type":"error"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"RewardsSent","type":"event"},{"inputs":[{"internalType":"bytes","name":"looksRareClaim","type":"bytes"}],"name":"collectRewards","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes","name":"commands","type":"bytes"},{"internalType":"bytes[]","name":"inputs","type":"bytes[]"}],"name":"execute","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"bytes","name":"commands","type":"bytes"},{"internalType":"bytes[]","name":"inputs","type":"bytes[]"},{"internalType":"uint256","name":"deadline","type":"uint256"}],"name":"execute","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"},{"internalType":"address","name":"","type":"address"},{"internalType":"uint256[]","name":"","type":"uint256[]"},{"internalType":"uint256[]","name":"","type":"uint256[]"},{"internalType":"bytes","name":"","type":"bytes"}],"name":"onERC1155BatchReceived","outputs":[{"internalType":"bytes4","name":"","type":"bytes4"}],"stateMutability":"pure","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"},{"internalType":"address","name":"","type":"address"},{"internalType":"uint256","name":"","type":"uint256"},{"internalType":"uint256","name":"","type":"uint256"},{"internalType":"bytes","name":"","type":"bytes"}],"name":"onERC1155Received","outputs":[{"internalType":"bytes4","name":"","type":"bytes4"}],"stateMutability":"pure","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"},{"internalType":"address","name":"","type":"address"},{"internalType":"uint256","name":"","type":"uint256"},{"internalType":"bytes","name":"","type":"bytes"}],"name":"onERC721Received","outputs":[{"internalType":"bytes4","name":"","type":"bytes4"}],"stateMutability":"pure","type":"function"},{"inputs":[{"internalType":"bytes4","name":"interfaceId","type":"bytes4"}],"name":"supportsInterface","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"pure","type":"function"},{"inputs":[{"internalType":"int256","name":"amount0Delta","type":"int256"},{"internalType":"int256","name":"amount1Delta","type":"int256"},{"internalType":"bytes","name":"data","type":"bytes"}],"name":"uniswapV3SwapCallback","outputs":[],"stateMutability":"nonpayable","type":"function"},{"stateMutability":"payable","type":"receive"}]' # noqa _structured_data_permit: Dict[str, Any] = { diff --git a/uniswap_universal_router_decoder/_decoder.py b/uniswap_universal_router_decoder/_decoder.py index 27bb93a..c6efa42 100644 --- a/uniswap_universal_router_decoder/_decoder.py +++ b/uniswap_universal_router_decoder/_decoder.py @@ -25,7 +25,10 @@ from uniswap_universal_router_decoder._abi_builder import _ABIMap from uniswap_universal_router_decoder._constants import _router_abi -from uniswap_universal_router_decoder._enums import _RouterFunction +from uniswap_universal_router_decoder._enums import ( + _RouterConstant, + _RouterFunction, +) class _Decoder: @@ -47,11 +50,15 @@ def function_input(self, input_data: Union[HexStr, HexBytes]) -> Tuple[ContractF decoded_command_input = [] for i, b in enumerate(command): # iterating over bytes produces integers + command_function = b & _RouterConstant.COMMAND_TYPE_MASK.value try: - abi_mapping = self._abi_map[_RouterFunction(b)] + abi_mapping = self._abi_map[_RouterFunction(command_function)] data = abi_mapping.selector + command_input[i] sub_contract = self._w3.eth.contract(abi=abi_mapping.fct_abi.get_full_abi()) - decoded_command_input.append(sub_contract.decode_function_input(data)) + revert_on_fail = not bool(b & _RouterConstant.FLAG_ALLOW_REVERT.value) + decoded_command_input.append( + sub_contract.decode_function_input(data) + ({"revert_on_fail": revert_on_fail}, ) + ) except (ValueError, KeyError): decoded_command_input.append(command_input[i].hex()) decoded_input["inputs"] = decoded_command_input diff --git a/uniswap_universal_router_decoder/_encoder.py b/uniswap_universal_router_decoder/_encoder.py index 0d5c283..cd0971b 100644 --- a/uniswap_universal_router_decoder/_encoder.py +++ b/uniswap_universal_router_decoder/_encoder.py @@ -7,7 +7,6 @@ """ from __future__ import annotations -from datetime import datetime from typing import ( Any, cast, @@ -35,6 +34,8 @@ from uniswap_universal_router_decoder._constants import ( _execution_function_input_types, _execution_function_selector, + _execution_without_deadline_function_input_types, + _execution_without_deadline_function_selector, _router_abi, ) from uniswap_universal_router_decoder._enums import ( @@ -44,6 +45,9 @@ ) +NO_REVERT_FLAG = _RouterConstant.FLAG_ALLOW_REVERT.value + + class _Encoder: def __init__(self, w3: Web3, abi_map: _ABIMap) -> None: self._w3 = w3 @@ -80,7 +84,7 @@ def __init__(self, w3: Web3, abi_map: _ABIMap): self._w3 = w3 self._router_contract = self._w3.eth.contract(abi=_router_abi) self._abi_map = abi_map - self.commands: List[_RouterFunction] = [] + self.commands: bytearray = bytearray() self.arguments: List[bytes] = [] @staticmethod @@ -102,17 +106,19 @@ def _get_recipient( ) @staticmethod - def _to_command(*router_functions: _RouterFunction) -> bytes: - command = b"" - for r_fct in router_functions: - command += Web3.to_bytes(r_fct.value) - return command + def _get_command(router_function: _RouterFunction, revert_on_fail: bool) -> int: + return int(router_function.value if revert_on_fail else router_function.value | NO_REVERT_FLAG) @staticmethod def _encode_execution_function(arguments: Tuple[bytes, List[bytes], int]) -> HexStr: encoded_data = encode(_execution_function_input_types, arguments) return Web3.to_hex(Web3.to_bytes(hexstr=_execution_function_selector) + encoded_data) + @staticmethod + def _encode_execution_without_deadline_function(arguments: Tuple[bytes, List[bytes]]) -> HexStr: + encoded_data = encode(_execution_without_deadline_function_input_types, arguments) + return Web3.to_hex(Web3.to_bytes(hexstr=_execution_without_deadline_function_selector) + encoded_data) + def _encode_wrap_eth_sub_contract(self, recipient: ChecksumAddress, amount_min: Wei) -> HexStr: abi_mapping = self._abi_map[_RouterFunction.WRAP_ETH] sub_contract = self._w3.eth.contract(abi=abi_mapping.fct_abi.get_full_abi()) @@ -130,10 +136,11 @@ def wrap_eth( :param function_recipient: A FunctionRecipient which defines the recipient of this function output. :param amount: The amount of sent ETH in WEI. :param custom_recipient: If function_recipient is CUSTOM, must be the actual recipient, otherwise None. + :return: The chain link corresponding to this function call. """ recipient = self._get_recipient(function_recipient, custom_recipient) - self.commands.append(_RouterFunction.WRAP_ETH) + self.commands.append(_RouterFunction.WRAP_ETH.value) self.arguments.append(Web3.to_bytes(hexstr=self._encode_wrap_eth_sub_contract(recipient, amount))) return self @@ -154,10 +161,11 @@ def unwrap_weth( :param function_recipient: A FunctionRecipient which defines the recipient of this function output. :param amount: The amount of sent WETH in WEI. :param custom_recipient: If function_recipient is CUSTOM, must be the actual recipient, otherwise None. + :return: The chain link corresponding to this function call. """ recipient = self._get_recipient(function_recipient, custom_recipient) - self.commands.append(_RouterFunction.UNWRAP_WETH) + self.commands.append(_RouterFunction.UNWRAP_WETH.value) self.arguments.append(Web3.to_bytes(hexstr=self._encode_unwrap_weth_sub_contract(recipient, amount))) return self @@ -192,10 +200,11 @@ def v2_swap_exact_in( :param path: The V2 path: a list of 2 or 3 tokens where the first is token_in and the last is token_out :param custom_recipient: If function_recipient is CUSTOM, must be the actual recipient, otherwise None. :param payer_is_sender: True if the in tokens come from the sender, False if they already are in the router + :return: The chain link corresponding to this function call. """ recipient = self._get_recipient(function_recipient, custom_recipient) - self.commands.append(_RouterFunction.V2_SWAP_EXACT_IN) + self.commands.append(_RouterFunction.V2_SWAP_EXACT_IN.value) self.arguments.append( Web3.to_bytes( hexstr=self._encode_v2_swap_exact_in_sub_contract( @@ -225,6 +234,7 @@ def v2_swap_exact_in_from_balance( :param amount_out_min: The minimum accepted bought token (token_out) :param path: The V2 path: a list of 2 or 3 tokens where the first is token_in and the last is token_out :param custom_recipient: If function_recipient is CUSTOM, must be the actual recipient, otherwise None. + :return: The chain link corresponding to this function call. """ return self.v2_swap_exact_in( @@ -267,10 +277,11 @@ def v2_swap_exact_out( :param path: The V2 path: a list of 2 or 3 tokens where the first is token_in and the last is token_out :param custom_recipient: If function_recipient is CUSTOM, must be the actual recipient, otherwise None. :param payer_is_sender: True if the in tokens come from the sender, False if they already are in the router + :return: The chain link corresponding to this function call. """ recipient = self._get_recipient(function_recipient, custom_recipient) - self.commands.append(_RouterFunction.V2_SWAP_EXACT_OUT) + self.commands.append(_RouterFunction.V2_SWAP_EXACT_OUT.value) self.arguments.append( Web3.to_bytes( hexstr=self._encode_v2_swap_exact_out_sub_contract( @@ -317,10 +328,11 @@ def v3_swap_exact_in( with the pool fee between each token in basis points (ex: 3000 for 0.3%) :param custom_recipient: If function_recipient is CUSTOM, must be the actual recipient, otherwise None. :param payer_is_sender: True if the in tokens come from the sender, False if they already are in the router + :return: The chain link corresponding to this function call. """ recipient = self._get_recipient(function_recipient, custom_recipient) - self.commands.append(_RouterFunction.V3_SWAP_EXACT_IN) + self.commands.append(_RouterFunction.V3_SWAP_EXACT_IN.value) self.arguments.append( Web3.to_bytes( hexstr=self._encode_v3_swap_exact_in_sub_contract( @@ -351,6 +363,7 @@ def v3_swap_exact_in_from_balance( :param path: The V3 path: a list of tokens where the first is the token_in, the last one is the token_out, and with the pool fee between each token in basis points (ex: 3000 for 0.3%) :param custom_recipient: If function_recipient is CUSTOM, must be the actual recipient, otherwise None. + :return: The chain link corresponding to this function call. """ return self.v3_swap_exact_in( @@ -395,10 +408,11 @@ def v3_swap_exact_out( with the pool fee between each token in basis points (ex: 3000 for 0.3%) :param custom_recipient: If function_recipient is CUSTOM, must be the actual recipient, otherwise None. :param payer_is_sender: True if the in tokens come from the sender, False if they already are in the router + :return: The chain link corresponding to this function call. """ recipient = self._get_recipient(function_recipient, custom_recipient) - self.commands.append(_RouterFunction.V3_SWAP_EXACT_OUT) + self.commands.append(_RouterFunction.V3_SWAP_EXACT_OUT.value) self.arguments.append( Web3.to_bytes( hexstr=self._encode_v3_swap_exact_out_sub_contract( @@ -437,9 +451,10 @@ def permit2_permit( :param permit_single: The 1st element returned by create_permit2_signable_message() :param signed_permit_single: The 2nd element returned by create_permit2_signable_message(), once signed. + :return: The chain link corresponding to this function call. """ - self.commands.append(_RouterFunction.PERMIT2_PERMIT) + self.commands.append(_RouterFunction.PERMIT2_PERMIT.value) self.arguments.append( Web3.to_bytes( hexstr=self._encode_permit2_permit_sub_contract( @@ -469,10 +484,11 @@ def sweep( :param token_address: The address of the token to sweep or "0x0000000000000000000000000000000000000000" for ETH. :param amount_min: The minimum desired amount :param custom_recipient: If function_recipient is CUSTOM, must be the actual recipient, otherwise None. + :return: The chain link corresponding to this function call. """ recipient = self._get_recipient(function_recipient, custom_recipient) - self.commands.append(_RouterFunction.SWEEP) + self.commands.append(_RouterFunction.SWEEP.value) self.arguments.append( Web3.to_bytes( hexstr=self._encode_sweep_sub_contract( @@ -504,6 +520,7 @@ def pay_portion( :param token_address: The address of token to pay or "0x0000000000000000000000000000000000000000" for ETH. :param bips: integer between 0 and 10_000 :param custom_recipient: If function_recipient is CUSTOM, must be the actual recipient, otherwise None. + :return: The chain link corresponding to this function call. """ if ( @@ -514,7 +531,7 @@ def pay_portion( raise ValueError(f"Invalid argument: bips must be an int between 0 and 10_000. Received {bips}") recipient = self._get_recipient(function_recipient, custom_recipient) - self.commands.append(_RouterFunction.PAY_PORTION) + self.commands.append(_RouterFunction.PAY_PORTION.value) self.arguments.append( Web3.to_bytes( hexstr=self._encode_pay_portion_sub_contract( @@ -526,18 +543,53 @@ def pay_portion( ) return self + def _encode_transfer_sub_contract(self, token: ChecksumAddress, recipient: ChecksumAddress, value: int) -> HexStr: + abi_mapping = self._abi_map[_RouterFunction.TRANSFER] + sub_contract = self._w3.eth.contract(abi=abi_mapping.fct_abi.get_full_abi()) + contract_function: ContractFunction = sub_contract.functions.TRANSFER(token, recipient, value) + return remove_0x_prefix(encode_abi(self._w3, contract_function.abi, [token, recipient, value])) + + def transfer( + self, + function_recipient: FunctionRecipient, + token_address: ChecksumAddress, + value: Wei, + custom_recipient: Optional[ChecksumAddress] = None) -> _ChainedFunctionBuilder: + """ + Encode the call to the function TRANSFER which transfers a part of the router's ERC20 or ETH to an address. + Transferred amount = balance * bips / 10_000 + + :param function_recipient: A FunctionRecipient which defines the recipient of this function output. + :param token_address: The address of token to pay or "0x0000000000000000000000000000000000000000" for ETH. + :param value: The amount to transfer (in Wei) + :param custom_recipient: If function_recipient is CUSTOM, must be the actual recipient, otherwise None. + + :return: The chain link corresponding to this function call. + """ + + recipient = self._get_recipient(function_recipient, custom_recipient) + self.commands.append(_RouterFunction.TRANSFER.value) + self.arguments.append( + Web3.to_bytes( + hexstr=self._encode_transfer_sub_contract( + token_address, + recipient, + value, + ) + ) + ) + return self + def build(self, deadline: Optional[int] = None) -> HexStr: """ Build the encoded input for all the chained commands, ready to be sent to the UR - Currently default deadline is now + 180s - Todo: Support UR execution function without deadline - :param deadline: The unix timestamp after which the transaction won't be valid any more. Default to now + 180s. + :param deadline: The optional unix timestamp after which the transaction won't be valid any more. :return: The encoded data to add to the UR transaction dictionary parameters. """ - execute_input = ( - self._to_command(*self.commands), - self.arguments, - deadline or int(datetime.now().timestamp() + 180) # Todo: support UR execution function without deadline - ) - return self._encode_execution_function(execute_input) + if deadline: + execute_input = (bytes(self.commands), self.arguments, deadline) + return self._encode_execution_function(execute_input) + else: + execute_without_deadline_input = (bytes(self.commands), self.arguments) + return self._encode_execution_without_deadline_function(execute_without_deadline_input) diff --git a/uniswap_universal_router_decoder/_enums.py b/uniswap_universal_router_decoder/_enums.py index 4d7abc0..866fb14 100644 --- a/uniswap_universal_router_decoder/_enums.py +++ b/uniswap_universal_router_decoder/_enums.py @@ -18,6 +18,7 @@ class _RouterFunction(Enum): V3_SWAP_EXACT_IN = 0 V3_SWAP_EXACT_OUT = 1 SWEEP = 4 + TRANSFER = 5 PAY_PORTION = 6 V2_SWAP_EXACT_IN = 8 V2_SWAP_EXACT_OUT = 9 @@ -44,3 +45,5 @@ class _RouterConstant(Enum): MSG_SENDER = Web3.to_checksum_address("0x0000000000000000000000000000000000000001") ADDRESS_THIS = Web3.to_checksum_address("0x0000000000000000000000000000000000000002") ROUTER_BALANCE = Wei(2**255) + FLAG_ALLOW_REVERT = 0x80 + COMMAND_TYPE_MASK = 0x3f