dexsnake 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. dexsnake-0.1.0/LICENSE +21 -0
  2. dexsnake-0.1.0/PKG-INFO +52 -0
  3. dexsnake-0.1.0/README.rst +36 -0
  4. dexsnake-0.1.0/dexsnake/__init__.py +3 -0
  5. dexsnake-0.1.0/dexsnake/abi/ERC20Token.json +295 -0
  6. dexsnake-0.1.0/dexsnake/abi/__init__.py +0 -0
  7. dexsnake-0.1.0/dexsnake/amounts.py +23 -0
  8. dexsnake-0.1.0/dexsnake/erc20_token.py +184 -0
  9. dexsnake-0.1.0/dexsnake/transactions.py +31 -0
  10. dexsnake-0.1.0/dexsnake/uniswap_v2/__init__.py +3 -0
  11. dexsnake-0.1.0/dexsnake/uniswap_v2/abi/UniswapV2Factory.json +181 -0
  12. dexsnake-0.1.0/dexsnake/uniswap_v2/abi/UniswapV2Pair.json +707 -0
  13. dexsnake-0.1.0/dexsnake/uniswap_v2/abi/UniswapV2Router02.json +953 -0
  14. dexsnake-0.1.0/dexsnake/uniswap_v2/abi/__init__.py +0 -0
  15. dexsnake-0.1.0/dexsnake/uniswap_v2/config.py +38 -0
  16. dexsnake-0.1.0/dexsnake/uniswap_v2/factory.py +46 -0
  17. dexsnake-0.1.0/dexsnake/uniswap_v2/pair.py +92 -0
  18. dexsnake-0.1.0/dexsnake/uniswap_v2/router.py +150 -0
  19. dexsnake-0.1.0/dexsnake/uniswap_v3/__init__.py +3 -0
  20. dexsnake-0.1.0/dexsnake/uniswap_v3/abi/UniswapV3Factory.json +236 -0
  21. dexsnake-0.1.0/dexsnake/uniswap_v3/abi/UniswapV3Pool.json +988 -0
  22. dexsnake-0.1.0/dexsnake/uniswap_v3/abi/UniswapV3SwapRouter02.json +1063 -0
  23. dexsnake-0.1.0/dexsnake/uniswap_v3/abi/__init__.py +1 -0
  24. dexsnake-0.1.0/dexsnake/uniswap_v3/config.py +50 -0
  25. dexsnake-0.1.0/dexsnake/uniswap_v3/factory.py +49 -0
  26. dexsnake-0.1.0/dexsnake/uniswap_v3/pool.py +92 -0
  27. dexsnake-0.1.0/dexsnake/uniswap_v3/router.py +167 -0
  28. dexsnake-0.1.0/dexsnake.egg-info/PKG-INFO +52 -0
  29. dexsnake-0.1.0/dexsnake.egg-info/SOURCES.txt +38 -0
  30. dexsnake-0.1.0/dexsnake.egg-info/dependency_links.txt +1 -0
  31. dexsnake-0.1.0/dexsnake.egg-info/requires.txt +1 -0
  32. dexsnake-0.1.0/dexsnake.egg-info/top_level.txt +1 -0
  33. dexsnake-0.1.0/pyproject.toml +35 -0
  34. dexsnake-0.1.0/setup.cfg +4 -0
  35. dexsnake-0.1.0/tests/test_erc20_amounts.py +75 -0
  36. dexsnake-0.1.0/tests/test_fork.py +274 -0
  37. dexsnake-0.1.0/tests/test_pool_prices.py +65 -0
  38. dexsnake-0.1.0/tests/test_router_amounts.py +149 -0
  39. dexsnake-0.1.0/tests/test_transaction_broadcast.py +157 -0
  40. dexsnake-0.1.0/tests/test_v3_deadline.py +67 -0
dexsnake-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Leevi Kerkelä
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: dexsnake
3
+ Version: 0.1.0
4
+ Summary: A Python package for trading on decentralized exchanges like Uniswap.
5
+ Author-email: Leevi Kerkelä <leevi.kerkela@protonmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/kerkelae/dexsnake
8
+ Project-URL: Documentation, https://dexsnake.readthedocs.io/
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/x-rst
13
+ License-File: LICENSE
14
+ Requires-Dist: web3<8,>=7
15
+ Dynamic: license-file
16
+
17
+ Dexsnake
18
+ ========
19
+
20
+ Dexsnake is a lightweight Python package that makes it easy to make trades on
21
+ decentralized exchanges (DEXs) like `Uniswap <https://uniswap.org/>`_. The package is
22
+ designed for Python developers, quants, data scientists, algorithmic traders, and others
23
+ who are used to working in Python but may not have in-depth knowledge about blockchains
24
+ and decentralized apps. The code is organized to closely follow the structure of the
25
+ smart contracts, ensuring that by using Dexsnake, users also gain an understanding of
26
+ the underlying protocols.
27
+
28
+ Installation
29
+ ############
30
+
31
+ .. note::
32
+
33
+ Dexsnake is under active development. Its API may change before version 1.0.
34
+ Review transaction parameters carefully before trading with real funds.
35
+
36
+ The most recent release can be installed with `pip <https://pip.pypa.io/>`_:
37
+
38
+ .. code-block::
39
+
40
+ pip install dexsnake
41
+
42
+ The development version can be installed from GitHub:
43
+
44
+ .. code-block::
45
+
46
+ pip install git+https://github.com/kerkelae/dexsnake.git
47
+
48
+ Contributing
49
+ ############
50
+
51
+ See the `Contributing guide <https://dexsnake.readthedocs.io/en/latest/contributing.html>`_
52
+ for development setup and tests.
@@ -0,0 +1,36 @@
1
+ Dexsnake
2
+ ========
3
+
4
+ Dexsnake is a lightweight Python package that makes it easy to make trades on
5
+ decentralized exchanges (DEXs) like `Uniswap <https://uniswap.org/>`_. The package is
6
+ designed for Python developers, quants, data scientists, algorithmic traders, and others
7
+ who are used to working in Python but may not have in-depth knowledge about blockchains
8
+ and decentralized apps. The code is organized to closely follow the structure of the
9
+ smart contracts, ensuring that by using Dexsnake, users also gain an understanding of
10
+ the underlying protocols.
11
+
12
+ Installation
13
+ ############
14
+
15
+ .. note::
16
+
17
+ Dexsnake is under active development. Its API may change before version 1.0.
18
+ Review transaction parameters carefully before trading with real funds.
19
+
20
+ The most recent release can be installed with `pip <https://pip.pypa.io/>`_:
21
+
22
+ .. code-block::
23
+
24
+ pip install dexsnake
25
+
26
+ The development version can be installed from GitHub:
27
+
28
+ .. code-block::
29
+
30
+ pip install git+https://github.com/kerkelae/dexsnake.git
31
+
32
+ Contributing
33
+ ############
34
+
35
+ See the `Contributing guide <https://dexsnake.readthedocs.io/en/latest/contributing.html>`_
36
+ for development setup and tests.
@@ -0,0 +1,3 @@
1
+ from . import uniswap_v2 as uniswap_v2
2
+ from . import uniswap_v3 as uniswap_v3
3
+ from .erc20_token import ERC20Token as ERC20Token
@@ -0,0 +1,295 @@
1
+ [
2
+ {
3
+ "constant": true,
4
+ "inputs": [],
5
+ "name": "name",
6
+ "outputs": [
7
+ {
8
+ "name": "",
9
+ "type": "string"
10
+ }
11
+ ],
12
+ "payable": false,
13
+ "stateMutability": "view",
14
+ "type": "function"
15
+ },
16
+ {
17
+ "constant": false,
18
+ "inputs": [
19
+ {
20
+ "name": "_spender",
21
+ "type": "address"
22
+ },
23
+ {
24
+ "name": "_value",
25
+ "type": "uint256"
26
+ }
27
+ ],
28
+ "name": "approve",
29
+ "outputs": [
30
+ {
31
+ "name": "success",
32
+ "type": "bool"
33
+ }
34
+ ],
35
+ "payable": false,
36
+ "stateMutability": "nonpayable",
37
+ "type": "function"
38
+ },
39
+ {
40
+ "constant": true,
41
+ "inputs": [],
42
+ "name": "totalSupply",
43
+ "outputs": [
44
+ {
45
+ "name": "",
46
+ "type": "uint256"
47
+ }
48
+ ],
49
+ "payable": false,
50
+ "stateMutability": "view",
51
+ "type": "function"
52
+ },
53
+ {
54
+ "constant": false,
55
+ "inputs": [
56
+ {
57
+ "name": "_from",
58
+ "type": "address"
59
+ },
60
+ {
61
+ "name": "_to",
62
+ "type": "address"
63
+ },
64
+ {
65
+ "name": "_value",
66
+ "type": "uint256"
67
+ }
68
+ ],
69
+ "name": "transferFrom",
70
+ "outputs": [
71
+ {
72
+ "name": "success",
73
+ "type": "bool"
74
+ }
75
+ ],
76
+ "payable": false,
77
+ "stateMutability": "nonpayable",
78
+ "type": "function"
79
+ },
80
+ {
81
+ "constant": true,
82
+ "inputs": [],
83
+ "name": "decimals",
84
+ "outputs": [
85
+ {
86
+ "name": "",
87
+ "type": "uint8"
88
+ }
89
+ ],
90
+ "payable": false,
91
+ "stateMutability": "view",
92
+ "type": "function"
93
+ },
94
+ {
95
+ "constant": false,
96
+ "inputs": [
97
+ {
98
+ "name": "_value",
99
+ "type": "uint256"
100
+ }
101
+ ],
102
+ "name": "burn",
103
+ "outputs": [
104
+ {
105
+ "name": "success",
106
+ "type": "bool"
107
+ }
108
+ ],
109
+ "payable": false,
110
+ "stateMutability": "nonpayable",
111
+ "type": "function"
112
+ },
113
+ {
114
+ "constant": true,
115
+ "inputs": [
116
+ {
117
+ "name": "",
118
+ "type": "address"
119
+ }
120
+ ],
121
+ "name": "balanceOf",
122
+ "outputs": [
123
+ {
124
+ "name": "",
125
+ "type": "uint256"
126
+ }
127
+ ],
128
+ "payable": false,
129
+ "stateMutability": "view",
130
+ "type": "function"
131
+ },
132
+ {
133
+ "constant": false,
134
+ "inputs": [
135
+ {
136
+ "name": "_from",
137
+ "type": "address"
138
+ },
139
+ {
140
+ "name": "_value",
141
+ "type": "uint256"
142
+ }
143
+ ],
144
+ "name": "burnFrom",
145
+ "outputs": [
146
+ {
147
+ "name": "success",
148
+ "type": "bool"
149
+ }
150
+ ],
151
+ "payable": false,
152
+ "stateMutability": "nonpayable",
153
+ "type": "function"
154
+ },
155
+ {
156
+ "constant": true,
157
+ "inputs": [],
158
+ "name": "symbol",
159
+ "outputs": [
160
+ {
161
+ "name": "",
162
+ "type": "string"
163
+ }
164
+ ],
165
+ "payable": false,
166
+ "stateMutability": "view",
167
+ "type": "function"
168
+ },
169
+ {
170
+ "constant": false,
171
+ "inputs": [
172
+ {
173
+ "name": "_to",
174
+ "type": "address"
175
+ },
176
+ {
177
+ "name": "_value",
178
+ "type": "uint256"
179
+ }
180
+ ],
181
+ "name": "transfer",
182
+ "outputs": [],
183
+ "payable": false,
184
+ "stateMutability": "nonpayable",
185
+ "type": "function"
186
+ },
187
+ {
188
+ "constant": false,
189
+ "inputs": [
190
+ {
191
+ "name": "_spender",
192
+ "type": "address"
193
+ },
194
+ {
195
+ "name": "_value",
196
+ "type": "uint256"
197
+ },
198
+ {
199
+ "name": "_extraData",
200
+ "type": "bytes"
201
+ }
202
+ ],
203
+ "name": "approveAndCall",
204
+ "outputs": [
205
+ {
206
+ "name": "success",
207
+ "type": "bool"
208
+ }
209
+ ],
210
+ "payable": false,
211
+ "stateMutability": "nonpayable",
212
+ "type": "function"
213
+ },
214
+ {
215
+ "constant": true,
216
+ "inputs": [
217
+ {
218
+ "name": "",
219
+ "type": "address"
220
+ },
221
+ {
222
+ "name": "",
223
+ "type": "address"
224
+ }
225
+ ],
226
+ "name": "allowance",
227
+ "outputs": [
228
+ {
229
+ "name": "",
230
+ "type": "uint256"
231
+ }
232
+ ],
233
+ "payable": false,
234
+ "stateMutability": "view",
235
+ "type": "function"
236
+ },
237
+ {
238
+ "inputs": [
239
+ {
240
+ "name": "initialSupply",
241
+ "type": "uint256"
242
+ },
243
+ {
244
+ "name": "tokenName",
245
+ "type": "string"
246
+ },
247
+ {
248
+ "name": "tokenSymbol",
249
+ "type": "string"
250
+ }
251
+ ],
252
+ "payable": false,
253
+ "stateMutability": "nonpayable",
254
+ "type": "constructor"
255
+ },
256
+ {
257
+ "anonymous": false,
258
+ "inputs": [
259
+ {
260
+ "indexed": true,
261
+ "name": "from",
262
+ "type": "address"
263
+ },
264
+ {
265
+ "indexed": true,
266
+ "name": "to",
267
+ "type": "address"
268
+ },
269
+ {
270
+ "indexed": false,
271
+ "name": "value",
272
+ "type": "uint256"
273
+ }
274
+ ],
275
+ "name": "Transfer",
276
+ "type": "event"
277
+ },
278
+ {
279
+ "anonymous": false,
280
+ "inputs": [
281
+ {
282
+ "indexed": true,
283
+ "name": "from",
284
+ "type": "address"
285
+ },
286
+ {
287
+ "indexed": false,
288
+ "name": "value",
289
+ "type": "uint256"
290
+ }
291
+ ],
292
+ "name": "Burn",
293
+ "type": "event"
294
+ }
295
+ ]
File without changes
@@ -0,0 +1,23 @@
1
+ from decimal import Decimal
2
+ from typing import Union
3
+
4
+
5
+ def _from_base_units(units: int, decimals: int) -> Decimal:
6
+ return Decimal(
7
+ f"{units}e-{decimals}"
8
+ ) # to avoid rounding large amounts to the active Decimal precision
9
+
10
+
11
+ def _to_base_units(value: Union[Decimal, int], decimals: int) -> int:
12
+ if isinstance(value, bool) or not isinstance(value, (Decimal, int)):
13
+ raise TypeError("Token amount must be a Decimal or integer")
14
+ value = Decimal(value)
15
+ if not value.is_finite() or value < 0:
16
+ raise ValueError("Token amount must be finite and non-negative")
17
+ numerator, denominator = value.as_integer_ratio()
18
+ units, remainder = divmod(numerator * 10**decimals, denominator)
19
+ if remainder:
20
+ raise ValueError(f"Token amount exceeds {decimals} decimal places")
21
+ if units >= 2**256:
22
+ raise ValueError("Token amount exceeds uint256 range")
23
+ return units
@@ -0,0 +1,184 @@
1
+ import json
2
+ import os
3
+ from decimal import Decimal
4
+ from typing import Optional
5
+
6
+ from web3 import Web3
7
+ from web3.contract import Contract
8
+ from web3.types import TxReceipt
9
+
10
+ from .amounts import _from_base_units, _to_base_units
11
+ from .transactions import _send_transaction
12
+
13
+
14
+ class ERC20Token:
15
+ def __init__(self, web3: Web3, address: str):
16
+ """
17
+ Initializes a new instance of the ``ERC20Token`` class.
18
+
19
+ For details about the ERC20 standard, see https://eips.ethereum.org/EIPS/eip-20.
20
+
21
+ :param web3: A ``Web3`` instance connected to a blockchain node.
22
+ :type web3: ``Web3``
23
+ :param address: The address of the ERC20 token contract.
24
+ :type address: str
25
+ """
26
+ self.web3: Web3 = web3
27
+ address_checksum = self.web3.to_checksum_address(address)
28
+ self.address: str = address_checksum
29
+ abi_path = os.path.join(os.path.dirname(__file__), "abi", "ERC20Token.json")
30
+ with open(abi_path, "r") as file:
31
+ self.contract: Contract = self.web3.eth.contract(
32
+ address=address_checksum, abi=json.load(file)
33
+ )
34
+ self._name: Optional[str] = None
35
+ self._symbol: Optional[str] = None
36
+ self._decimals: Optional[int] = None
37
+
38
+ def allowance(self, owner: str, spender: str) -> Decimal:
39
+ """
40
+ Returns the amount which ``spender`` is allowed to withdraw from ``owner``.
41
+
42
+ :param owner: The address of the token owner.
43
+ :type owner: str
44
+ :param spender: The address of the spender.
45
+ :type spender: str
46
+
47
+ :return: The remaining allowance of tokens.
48
+ :rtype: ``Decimal``
49
+ """
50
+ return _from_base_units(
51
+ self.contract.functions.allowance(
52
+ self.web3.to_checksum_address(owner),
53
+ self.web3.to_checksum_address(spender),
54
+ ).call(),
55
+ self.decimals,
56
+ )
57
+
58
+ def approve(
59
+ self,
60
+ spender: str,
61
+ value: Decimal,
62
+ private_key: str,
63
+ gas: Optional[int] = None,
64
+ gas_price: Optional[int] = None,
65
+ ) -> TxReceipt:
66
+ """
67
+ Approves the specified address to spend the specified amount of tokens on behalf
68
+ of the caller.
69
+
70
+ :param spender: The address to approve.
71
+ :type spender: str
72
+ :param value: The amount of tokens to approve.
73
+ :type value: ``Decimal``
74
+ :param private_key: The private key of the account.
75
+ :type private_key: str
76
+ :param gas: The gas limit for the transaction. If not provided, it will be
77
+ estimated automatically.
78
+ :type gas: int, optional
79
+ :param gas_price: The gas price for the transaction in wei. If not provided, the
80
+ current network gas price will be used.
81
+ :type gas_price: int, optional
82
+
83
+ :return: The transaction receipt.
84
+ :rtype: TxReceipt
85
+ """
86
+ function = self.contract.functions.approve(
87
+ self.web3.to_checksum_address(spender),
88
+ _to_base_units(value, self.decimals),
89
+ )
90
+ return _send_transaction(self.web3, function, private_key, gas, gas_price)
91
+
92
+ def balance_of(self, account: str) -> Decimal:
93
+ """
94
+ Returns the balance of the specified account.
95
+
96
+ :param account: The address of the account.
97
+ :type account: str
98
+
99
+ :return: The balance of the account.
100
+ :rtype: ``Decimal``
101
+ """
102
+ return _from_base_units(
103
+ self.contract.functions.balanceOf(
104
+ self.web3.to_checksum_address(account)
105
+ ).call(),
106
+ self.decimals,
107
+ )
108
+
109
+ @property
110
+ def decimals(self) -> int:
111
+ """
112
+ Returns the number of decimals the token uses.
113
+
114
+ :return: The number of decimals.
115
+ """
116
+ if self._decimals is None:
117
+ self._decimals = self.contract.functions.decimals().call()
118
+ return self._decimals
119
+
120
+ @property
121
+ def name(self) -> str:
122
+ """
123
+ Returns the name of the token.
124
+
125
+ :return: The token name.
126
+ """
127
+ if self._name is None:
128
+ self._name = self.contract.functions.name().call()
129
+ return self._name
130
+
131
+ @property
132
+ def symbol(self) -> str:
133
+ """
134
+ Returns the symbol of the token.
135
+
136
+ :return: The token symbol.
137
+ """
138
+ if self._symbol is None:
139
+ self._symbol = self.contract.functions.symbol().call()
140
+ return self._symbol
141
+
142
+ def total_supply(self) -> Decimal:
143
+ """
144
+ Returns the total supply of the token.
145
+
146
+ :return: Total token supply.
147
+ :rtype: ``Decimal``
148
+ """
149
+ return _from_base_units(
150
+ self.contract.functions.totalSupply().call(), self.decimals
151
+ )
152
+
153
+ def transfer(
154
+ self,
155
+ to: str,
156
+ value: Decimal,
157
+ private_key: str,
158
+ gas: Optional[int] = None,
159
+ gas_price: Optional[int] = None,
160
+ ) -> TxReceipt:
161
+ """
162
+ Transfers tokens to the specified address.
163
+
164
+ :param to: The address to transfer tokens to.
165
+ :type to: str
166
+ :param value: The amount of tokens to transfer.
167
+ :type value: ``Decimal``
168
+ :param private_key: The private key of the account.
169
+ :type private_key: str
170
+ :param gas: The gas limit for the transaction. If not provided, it will be
171
+ estimated automatically.
172
+ :type gas: int, optional
173
+ :param gas_price: The gas price for the transaction in wei. If not provided, the
174
+ current network gas price will be used.
175
+ :type gas_price: int, optional
176
+
177
+ :return: The transaction receipt.
178
+ :rtype: TxReceipt
179
+ """
180
+ function = self.contract.functions.transfer(
181
+ self.web3.to_checksum_address(to),
182
+ _to_base_units(value, self.decimals),
183
+ )
184
+ return _send_transaction(self.web3, function, private_key, gas, gas_price)
@@ -0,0 +1,31 @@
1
+ from typing import Optional
2
+
3
+ from web3 import Web3
4
+ from web3.contract.contract import ContractFunction
5
+ from web3.types import TxReceipt
6
+
7
+
8
+ def _send_transaction(
9
+ web3: Web3,
10
+ function: ContractFunction,
11
+ private_key: str,
12
+ gas: Optional[int],
13
+ gas_price: Optional[int],
14
+ ) -> TxReceipt:
15
+ account = web3.eth.account.from_key(private_key)
16
+ if gas_price is None:
17
+ gas_price = web3.eth.gas_price
18
+ tx_params = {
19
+ "from": account.address,
20
+ "nonce": web3.eth.get_transaction_count(account.address, "pending"),
21
+ "gasPrice": gas_price,
22
+ }
23
+ if gas is not None:
24
+ tx_params["gas"] = gas
25
+ tx = function.build_transaction(tx_params)
26
+ signed_tx = account.sign_transaction(tx)
27
+ tx_hash = web3.eth.send_raw_transaction(signed_tx.raw_transaction)
28
+ receipt = web3.eth.wait_for_transaction_receipt(tx_hash)
29
+ if receipt["status"] == 0:
30
+ raise RuntimeError(f"Transaction {Web3.to_hex(tx_hash)} failed")
31
+ return receipt
@@ -0,0 +1,3 @@
1
+ from .factory import UniswapV2Factory as UniswapV2Factory
2
+ from .pair import UniswapV2Pair as UniswapV2Pair
3
+ from .router import UniswapV2Router as UniswapV2Router