2n20 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: 2n20
3
+ Version: 0.1.0
4
+ Summary: Local trading-key setup and public connection checks for 2n20 creators
5
+ Author: 2n20
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://2n20.org
8
+ Project-URL: Documentation, https://2n20.org/docs/quickstart
9
+ Project-URL: Repository, https://github.com/2n20/2n20
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Utilities
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: annotated-types==0.8.0
19
+ Requires-Dist: bitarray==3.11.0
20
+ Requires-Dist: ckzg==2.1.8
21
+ Requires-Dist: cytoolz==1.1.0
22
+ Requires-Dist: eth-account==0.13.7
23
+ Requires-Dist: eth-hash==0.8.0
24
+ Requires-Dist: eth-keyfile==0.8.1
25
+ Requires-Dist: eth-keys==0.8.0
26
+ Requires-Dist: eth-rlp==3.0.0
27
+ Requires-Dist: eth-typing==6.0.0
28
+ Requires-Dist: eth-utils==6.0.0
29
+ Requires-Dist: eth_abi==6.0.0
30
+ Requires-Dist: hexbytes==2.0.0
31
+ Requires-Dist: parsimonious==0.10.0
32
+ Requires-Dist: pycryptodome==3.23.0
33
+ Requires-Dist: pydantic==2.13.5
34
+ Requires-Dist: pydantic_core==2.46.5
35
+ Requires-Dist: regex==2026.9.10
36
+ Requires-Dist: rlp==5.0.0
37
+ Requires-Dist: toolz==1.1.0
38
+ Requires-Dist: typing-inspection==0.4.4
39
+ Requires-Dist: typing_extensions==4.16.0
40
+ Dynamic: license-file
41
+
42
+ # 2n20 creator setup
43
+
44
+ The official 2n20 CLI prepares trading-key consent on the computer or server
45
+ where your existing strategy runs. It supplies public account configuration
46
+ and read-only checks. You supply and run your own strategy in any language.
47
+
48
+ Requires Python 3.10 or later on Linux, macOS or WSL. Run commands in your
49
+ strategy's working directory with its Python environment activated.
50
+
51
+ Copy the setup command from Step 2 of your vault page:
52
+
53
+ ```sh
54
+ 2n20 setup --vault 'https://2n20.org/vaults/<your-vault-address>?network=mainnet'
55
+ ```
56
+
57
+ The CLI verifies the chain, factory, deployed contract code, vault bindings,
58
+ trading-account bindings, consent capability and next approval nonce from
59
+ public contract reads. This release bundles the currently supported HyperEVM
60
+ mainnet deployment. Other networks and unsupported deployments fail closed.
61
+ Vault URLs can select only a vault address and network. They cannot override
62
+ RPC endpoints or supply an approval contract, trading account or nonce.
63
+
64
+ Setup creates a new directory with mode `700` under your current working
65
+ directory. Use `--output <new-directory>` to choose its name. The parent must
66
+ already exist. Setup never reuses an existing output directory.
67
+
68
+ - `strategy.key`: your new private trading key, mode `600`. Keep it only on
69
+ your strategy machine. Never paste, import, upload, commit or log this file.
70
+ - `consent.public.json`: a public signature, trading-key address, vault,
71
+ approval contract, nonce and expiry. Import or paste only this file into
72
+ Step 2 and approve it with your creator wallet.
73
+ - `setup.public.json`: public recovery metadata used by `renew`.
74
+ - `strategy.public.json`: public account settings for your existing strategy.
75
+
76
+ Keep the directory and key when a network read fails. If consent expires
77
+ (after one hour), or another approval changes its nonce, run on that same
78
+ strategy machine:
79
+
80
+ ```sh
81
+ 2n20 renew --directory ./<your-setup-directory>
82
+ ```
83
+
84
+ Renew retains the existing private key and writes a new uniquely named
85
+ `consent.<time>-<random>.public.json`. Import that new public file. If the key
86
+ has already been used, create a fresh setup in a new directory. Existing keys
87
+ and files are never replaced. The app and contract recheck nonce and expiry
88
+ when you approve.
89
+
90
+ After Step 2 confirms trading access, get the public settings for Step 3:
91
+
92
+ ```sh
93
+ 2n20 config --vault 'https://2n20.org/vaults/<your-vault-address>?network=mainnet'
94
+ 2n20 status --vault 'https://2n20.org/vaults/<your-vault-address>?network=mainnet'
95
+ ```
96
+
97
+ Use `tradingAccount` as your strategy's Hyperliquid account address. It is
98
+ separate from the `vault` holding shares and the `approvalContract` your
99
+ creator wallet calls. Load the private key locally using your strategy's own
100
+ key configuration. The CLI does not load your existing strategy's secrets.
101
+
102
+ Status checks the current public key approval, account binding and funding.
103
+ Missing or inconsistent public evidence never counts as trading readiness.
104
+ Exit code `0` means public trading access was verified, `2` means it is
105
+ pending or unverified, and `1` means verification could not complete. This
106
+ check does not prove your strategy is running. Run its own connection check
107
+ before starting it. These commands submit no transactions, move no collateral
108
+ and place no orders. Setup does not depend on temporary Hyperliquid balance or
109
+ funding API availability.
110
+
111
+ To check a public consent before importing it:
112
+
113
+ ```sh
114
+ 2n20 check-consent --vault 'https://2n20.org/vaults/<your-vault-address>?network=mainnet' --consent ./<your-setup-directory>/consent.public.json
115
+ ```
116
+
117
+ ## Reproducible development and release
118
+
119
+ `requirements-release.txt` contains the reviewed, fully pinned build and
120
+ runtime graph with registry SHA-256 hashes. `dependency-review.json` records
121
+ its registry publication dates. All third-party release files were at least
122
+ 14 days old when reviewed on October 3, 2026. The wheel also pins the complete
123
+ runtime graph to retain these reviewed versions for ordinary installs.
124
+ The only underlying runtime library is `eth-account` and its dependencies;
125
+ Hyperliquid's SDK is not required.
126
+
127
+ ```sh
128
+ python3 -m venv .venv
129
+ .venv/bin/python -m pip install --require-hashes -r requirements-release.txt
130
+ PYTHONPATH=src .venv/bin/python -m unittest discover -s tests -v
131
+ SOURCE_DATE_EPOCH=1790985600 .venv/bin/python scripts/build_release.py
132
+ ```
133
+
134
+ The release script calls standard `python -m build --no-isolation`, verifies
135
+ archive paths, and normalizes the source archive ownership, timestamps and
136
+ gzip header. Keep `SOURCE_DATE_EPOCH` fixed for a release. Repeated builds from
137
+ the same source and reviewed environment produce identical wheel and source
138
+ archive bytes. Use `--outdir <directory>` to compare independent builds.
139
+
140
+ Release trust anchors are copied from the repository's reviewed active
141
+ `deployments/development.json`, never fetched as authority from a website.
142
+ A supported deployment change requires reviewing and releasing the package.
143
+ Publishing uses GitHub Actions OIDC Trusted Publishing with dedicated PyPI
144
+ and TestPyPI environments, not stored API tokens. Maintainer release steps
145
+ and publisher identities are in the repository operations runbook.
@@ -0,0 +1,16 @@
1
+ 2n20-0.1.0.dist-info/licenses/LICENSE,sha256=uRP5-lYlB7ex3zJRPeYNhQ0q2rbG6ysuAEpJdTu0JOc,1061
2
+ twon20/__init__.py,sha256=4Avfdk1iaw85amXWoXNppBKrBGi69HEGRbo5W8phEpw,90
3
+ twon20/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
4
+ twon20/cli.py,sha256=5EndsssG24K2ICIyoph278bWEoYt0JEWFVfbySLUBh8,6729
5
+ twon20/consent.py,sha256=9Xk7OrxT4V2We6OeaMOex9uLAv6DPT5dKeLdqD8w72c,7656
6
+ twon20/deployments.json,sha256=u2rfHhnbA8ZKsIc9pE-X3GvPtwJh6vgZFzzinyOGp2k,2559
7
+ twon20/discovery.py,sha256=KarkteZ3NwigI58Uj3V4prNfDfeDhpBjVGdzTdRfo4Y,12865
8
+ twon20/errors.py,sha256=IWoo_iRU1-kWPp0vaEQkNfYXGhrMe0dP6l_Vpqz060A,246
9
+ twon20/files.py,sha256=YA-bdQLX0fX-oPEJcCyo3M3Wk7DTh-tOn3zcKGB6-5I,6004
10
+ twon20/rpc.py,sha256=zd4jRA0gASzGZnVXQkKh_p71smu-MFVGjk0IQu179m0,4218
11
+ twon20/status.py,sha256=ygf0K1xx_oZm3S1bi-c3BYdVbkorfge4kc1yD2LYgvI,6148
12
+ 2n20-0.1.0.dist-info/METADATA,sha256=T8JZeV1vhAVgJomMEyYx2wPV82f9pfa8GeWJDtm_fgo,6535
13
+ 2n20-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ 2n20-0.1.0.dist-info/entry_points.txt,sha256=twr9p6Zgg_8IStBm_Ve9prlgjkx1Ooo4KJtrwsRWlR4,41
15
+ 2n20-0.1.0.dist-info/top_level.txt,sha256=2TZ_7c_6HEB-6yjuIEJUWhdZTWRZgHMsuGimxfHfRd0,7
16
+ 2n20-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ 2n20 = twon20.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 2n20
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 @@
1
+ twon20
twon20/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """2n20 creator setup. No transaction or order submission APIs."""
2
+
3
+ __version__ = "0.1.0"
twon20/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
twon20/cli.py ADDED
@@ -0,0 +1,116 @@
1
+ """The maintained 2n20 creator setup command."""
2
+
3
+ import argparse
4
+ import json
5
+ import secrets
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .consent import prepare, validate
12
+ from .discovery import ZERO, discover
13
+ from .errors import SetupError
14
+ from .files import load_key, load_setup, new_output, open_output, read_public, retain_key
15
+ from .rpc import Rpc
16
+ from .status import status
17
+
18
+
19
+ def parser() -> argparse.ArgumentParser:
20
+ root = argparse.ArgumentParser(prog="2n20", description="Prepare local trading-key consent and verify public vault configuration. No transactions or trades are submitted.")
21
+ root.add_argument("--version", action="version", version=f"2n20 {__version__}")
22
+ commands = root.add_subparsers(dest="command", required=True)
23
+ setup = commands.add_parser("setup", help="Create a fresh private key locally and its public consent file")
24
+ setup.add_argument("--vault", required=True, help="The https://2n20.org/vaults/... link copied from Step 2")
25
+ setup.add_argument("--output", type=Path, help="A new private directory under an existing parent; defaults to a unique directory here")
26
+ setup.add_argument("--json", action="store_true", help="Print public result metadata as JSON")
27
+ renew = commands.add_parser("renew", help="Renew public consent using the key retained in your setup directory")
28
+ renew.add_argument("--directory", required=True, type=Path, help="The directory previously created by 2n20 setup")
29
+ renew.add_argument("--json", action="store_true")
30
+ for command, help_text in (("status", "Verify public trading access without placing an order"), ("config", "Print verified public configuration for your existing strategy"), ("check-consent", "Check a public consent file against fresh contract evidence")):
31
+ subparser = commands.add_parser(command, help=help_text)
32
+ subparser.add_argument("--vault", required=True)
33
+ if command == "check-consent":
34
+ subparser.add_argument("--consent", required=True, type=Path, help="Only the consent.public.json file")
35
+ subparser.add_argument("--json", action="store_true", help="Print public JSON (config always prints JSON)")
36
+ return root
37
+
38
+
39
+ def setup(args) -> dict:
40
+ found = discover(args.vault)
41
+ rpc = Rpc(found.network)
42
+ output = new_output(found, args.output)
43
+ try:
44
+ wallet = retain_key(output, found)
45
+ consent = prepare(found, wallet, rpc)
46
+ public_path = output.json("consent.public.json", consent)
47
+ output.json("strategy.public.json", found.public())
48
+ return {"directory": str(output.path), "publicConsentFile": str(public_path), "tradingKey": wallet.address,
49
+ "expiresAt": int(consent["deadline"]), "nextAction": "Import only consent.public.json into Step 2 and approve with your creator wallet.",
50
+ "privateFile": "strategy.key stays on this machine. Never import, paste or upload it."}
51
+ except (SetupError, OSError, ValueError):
52
+ raise SetupError("SETUP_INCOMPLETE", f"Setup did not complete. Keep the directory {output.path}; any created private key was retained unchanged. Run 2n20 renew --directory '{output.path}' to retry consent generation. No transaction was submitted.") from None
53
+ finally:
54
+ output.close()
55
+
56
+
57
+ def renew(args) -> dict:
58
+ output = open_output(args.directory)
59
+ try:
60
+ metadata = load_setup(output)
61
+ found = discover(metadata["vaultLink"])
62
+ wallet = load_key(output, metadata)
63
+ consent = prepare(found, wallet, Rpc(found.network))
64
+ name = f"consent.{int(time.time())}-{secrets.token_hex(4)}.public.json"
65
+ public_path = output.json(name, consent)
66
+ return {"directory": str(output.path), "publicConsentFile": str(public_path), "tradingKey": wallet.address,
67
+ "expiresAt": int(consent["deadline"]), "nextAction": "Import this new public file into Step 2. Older consent may be expired or stale.",
68
+ "privateFile": "The existing strategy.key was not changed. Keep it on this machine."}
69
+ finally:
70
+ output.close()
71
+
72
+
73
+ def run(args) -> tuple[dict, int]:
74
+ if args.command == "setup":
75
+ return setup(args), 0
76
+ if args.command == "renew":
77
+ return renew(args), 0
78
+ found = discover(args.vault)
79
+ rpc = Rpc(found.network)
80
+ if args.command == "config":
81
+ if found.trading_account == ZERO:
82
+ raise SetupError("TRADING_ACCOUNT_PENDING", "Wait for vault funding to create the trading account, then run config again.")
83
+ return found.public() | {"nextAction": "Configure your existing strategy with tradingAccount as its account address and your local strategy.key as its signing key. Run your strategy's own connection check."}, 0
84
+ if args.command == "check-consent":
85
+ consent = validate(read_public(args.consent), found, rpc)
86
+ return {"valid": True, "vault": found.vault, "approvalContract": found.approval_contract,
87
+ "tradingKey": consent["key"], "nextApprovalNonce": consent["nonce"], "expiresAt": int(consent["deadline"]),
88
+ "nextAction": "Import this public consent into Step 2. The app and contract verify the nonce and expiry again before approval."}, 0
89
+ report = status(found, rpc)
90
+ return report, 0 if report["tradingAccessReady"] else 2
91
+
92
+
93
+ def main(argv=None) -> int:
94
+ args = parser().parse_args(argv)
95
+ try:
96
+ result, exit_code = run(args)
97
+ if args.json or args.command in ("config", "status"):
98
+ print(json.dumps(result, indent=2))
99
+ elif args.command in ("setup", "renew"):
100
+ print(f"Public consent file: {result['publicConsentFile']}")
101
+ print(result["privateFile"])
102
+ print(result["nextAction"])
103
+ print("Consent expires in one hour. If it expires or the nonce changes, run 2n20 renew --directory <your-setup-directory> here and import the new public file.")
104
+ else:
105
+ print("Public consent signature, vault, approval contract, nonce and expiry verified.")
106
+ print(result["nextAction"])
107
+ return exit_code
108
+ except SetupError as error:
109
+ print(f"{error.code}: {error.message}", file=sys.stderr)
110
+ return 1
111
+ except (OSError, ValueError, TypeError, KeyError):
112
+ print("SETUP_UNAVAILABLE: Setup could not complete. Keep any existing setup directory and key; no private material was displayed or overwritten. Check local permissions and retry. No transaction was submitted.", file=sys.stderr)
113
+ return 1
114
+ except KeyboardInterrupt:
115
+ print("INTERRUPTED: Keep any created setup directory. Use 2n20 renew --directory <directory> to retry; existing keys were not overwritten.", file=sys.stderr)
116
+ return 130
twon20/consent.py ADDED
@@ -0,0 +1,99 @@
1
+ """Exact TradingKeyConsent EIP-712 format shared with the app and contract."""
2
+
3
+ import re
4
+ import time
5
+
6
+ from eth_account import Account
7
+ from eth_account.messages import encode_typed_data
8
+ from eth_utils import keccak
9
+ from eth_keys.exceptions import BadSignature
10
+
11
+ from .discovery import Discovery, UINT64_MAX, ZERO, address, code_bytes, verify_current
12
+ from .errors import SetupError
13
+ from .rpc import Rpc, parse_json
14
+
15
+ SCHEMA = "2n20-trading-key-consent-v1"
16
+ FIELDS = frozenset({"schema", "chainId", "venue", "vault", "key", "nonce", "deadline", "signature"})
17
+
18
+
19
+ def typed_data(found: Discovery, key: str, nonce: int, deadline: int) -> dict:
20
+ return {"domain": {"name": "2n20 SDK venue", "version": "1", "chainId": found.anchor["chainId"], "verifyingContract": found.approval_contract},
21
+ "primaryType": "TradingKeyConsent",
22
+ "types": {"EIP712Domain": [{"name": "name", "type": "string"}, {"name": "version", "type": "string"}, {"name": "chainId", "type": "uint256"}, {"name": "verifyingContract", "type": "address"}],
23
+ "TradingKeyConsent": [{"name": "vault", "type": "address"}, {"name": "key", "type": "address"}, {"name": "nonce", "type": "uint64"}, {"name": "deadline", "type": "uint64"}]},
24
+ "message": {"vault": found.vault, "key": key, "nonce": nonce, "deadline": deadline}}
25
+
26
+
27
+ def digest(found: Discovery, key: str, nonce: int, deadline: int) -> bytes:
28
+ message = encode_typed_data(full_message=typed_data(found, key, nonce, deadline))
29
+ return keccak(b"\x19" + message.version + message.header + message.body)
30
+
31
+
32
+ def verify_digest(found: Discovery, key: str, nonce: int, deadline: int, rpc: Rpc):
33
+ returned = rpc.words(found.approval_contract, "consentDigest(address,address,uint64,uint64)",
34
+ [found.vault, key, nonce, deadline], found.fixed, 1)[0]
35
+ if returned != int.from_bytes(digest(found, key, nonce, deadline), "big"):
36
+ raise SetupError("CONSENT_FORMAT_MISMATCH", "The approval contract does not recognize this release's consent format.")
37
+
38
+
39
+ def prepare(found: Discovery, wallet, rpc: Rpc, now: int | None = None) -> dict:
40
+ now = int(time.time()) if now is None else now
41
+ key = address(wallet.address)
42
+ if key in {ZERO, found.vault, found.owner, found.trading_account, found.requested_key}:
43
+ raise SetupError("KEY_NOT_FRESH", "The trading key is not a fresh independent account. Its private file remains unchanged.")
44
+ if code_bytes(rpc.call("eth_getCode", [key, found.fixed])) or rpc.words(found.approval_contract, "usedTradingKeys(address)", [key], found.fixed, 1) != [0]:
45
+ raise SetupError("KEY_ALREADY_USED", "This trading key has already been used. Keep it private and run setup in a new directory for a fresh key.")
46
+ nonce, deadline = found.nonce + 1, now + 3600
47
+ if deadline <= found.timestamp or deadline > found.timestamp + 86400:
48
+ raise SetupError("CLOCK_MISMATCH", "Check the machine clock before preparing consent.")
49
+ verify_digest(found, key, nonce, deadline, rpc)
50
+ signature = wallet.sign_message(encode_typed_data(full_message=typed_data(found, key, nonce, deadline))).signature.hex()
51
+ value = {"schema": SCHEMA, "chainId": found.anchor["chainId"], "venue": found.approval_contract,
52
+ "vault": found.vault, "key": wallet.address, "nonce": str(nonce), "deadline": str(deadline),
53
+ "signature": "0x" + signature.removeprefix("0x")}
54
+ current = verify_current(found, rpc)
55
+ if code_bytes(rpc.call("eth_getCode", [key, current])) or rpc.words(found.approval_contract, "usedTradingKeys(address)", [key], current, 1) != [0]:
56
+ raise SetupError("KEY_ALREADY_USED", "The trading key was used during verification. Keep the existing key private and create a fresh setup in a new directory.")
57
+ return value
58
+
59
+
60
+ def validate(text: str, found: Discovery, rpc: Rpc, now: int | None = None) -> dict:
61
+ now = int(time.time()) if now is None else now
62
+ if len(text.encode("utf-8")) > 2000:
63
+ raise SetupError("INVALID_CONSENT", "Use only the small public consent JSON file, never strategy.key.")
64
+ try:
65
+ value = parse_json(text)
66
+ except ValueError:
67
+ raise SetupError("INVALID_CONSENT", "Select the public consent JSON file, never strategy.key.") from None
68
+ if not isinstance(value, dict) or set(value) != FIELDS or value.get("schema") != SCHEMA or type(value.get("chainId")) is not int or value["chainId"] != found.anchor["chainId"]:
69
+ raise SetupError("INVALID_CONSENT", "This public consent file does not match the supported schema and network.")
70
+ if address(value["vault"]) != found.vault or address(value["venue"]) != found.approval_contract:
71
+ raise SetupError("CONSENT_BINDING_MISMATCH", "This public consent is for a different vault or approval contract.")
72
+ key = address(value["key"])
73
+ if key in {found.vault, found.owner, found.trading_account, found.requested_key}:
74
+ raise SetupError("KEY_NOT_FRESH", "Consent must use a fresh independent trading key.")
75
+ if not isinstance(value["nonce"], str) or not re.fullmatch(r"[1-9][0-9]{0,19}", value["nonce"]) or int(value["nonce"]) != found.nonce + 1:
76
+ raise SetupError("STALE_NONCE", "The approval nonce changed. Run 2n20 renew --directory <your-setup-directory> on the strategy machine and import the new public file.")
77
+ if not isinstance(value["deadline"], str) or not re.fullmatch(r"[1-9][0-9]{0,19}", value["deadline"]) or int(value["deadline"]) > UINT64_MAX:
78
+ raise SetupError("INVALID_EXPIRY", "This consent has an invalid expiry. Renew it on the strategy machine.")
79
+ deadline = int(value["deadline"])
80
+ if deadline <= max(now, found.timestamp) or deadline > min(now, found.timestamp) + 86400:
81
+ raise SetupError("EXPIRED_CONSENT", "Consent expired or exceeds 24 hours. Run 2n20 renew --directory <your-setup-directory> on the strategy machine and import the new public file.")
82
+ if not isinstance(value["signature"], str) or not re.fullmatch(r"0x[0-9a-fA-F]{130}", value["signature"]):
83
+ raise SetupError("INVALID_SIGNATURE", "This consent has an invalid public signature.")
84
+ signature_bytes = bytes.fromhex(value["signature"][2:])
85
+ if signature_bytes[64] not in (27, 28) or not 0 < int.from_bytes(signature_bytes[32:64], "big") <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0:
86
+ raise SetupError("INVALID_SIGNATURE", "The signature must use the canonical contract ECDSA format.")
87
+ try:
88
+ recovered = Account.recover_message(encode_typed_data(full_message=typed_data(found, key, found.nonce + 1, deadline)), signature=value["signature"])
89
+ except (ValueError, TypeError, OverflowError, BadSignature):
90
+ raise SetupError("INVALID_SIGNATURE", "The consent signature could not be verified.") from None
91
+ if address(recovered) != key:
92
+ raise SetupError("INVALID_SIGNATURE", "The signature does not belong to this trading key.")
93
+ if code_bytes(rpc.call("eth_getCode", [key, found.fixed])) or rpc.words(found.approval_contract, "usedTradingKeys(address)", [key], found.fixed, 1) != [0]:
94
+ raise SetupError("KEY_ALREADY_USED", "This trading key cannot be approved again. Create a fresh setup in a new directory.")
95
+ verify_digest(found, key, found.nonce + 1, deadline, rpc)
96
+ current = verify_current(found, rpc)
97
+ if code_bytes(rpc.call("eth_getCode", [key, current])) or rpc.words(found.approval_contract, "usedTradingKeys(address)", [key], current, 1) != [0]:
98
+ raise SetupError("KEY_ALREADY_USED", "The trading key was used during verification. Keep the existing key private and create a fresh setup in a new directory.")
99
+ return value
@@ -0,0 +1,50 @@
1
+ {
2
+ "generation": "7ed54c06-cda8-8dd2-9c18-e38e3bbaf4db",
3
+ "chainId": 999,
4
+ "genesisHash": "0xda486bf0d500d665b3ffd1e39ae55a99fd6e3a1f0115ea6b01abf080567a83c8",
5
+ "deploymentBlock": "47447750",
6
+ "deploymentHash": "0x7d7ef5a210384e9fa5b2826163edcb8879d8bc66582da8553d3c226a0a3508be",
7
+ "abiHash": "ee13cbb6c8012d933b340681d26831cc6bf11c56726f6b2c32f497af278f9c9c",
8
+ "codeHashes": {
9
+ "LocalVenue": "0xb852373f0fbf062a41b8793d96daa76e50240a8b7c1b5f00e8cb329da47ec336",
10
+ "MockUSDC": "0xa119f674d44072e257f162aee49c7aba9b7a04fe2e8c84fcefddb366ab6d40cd",
11
+ "PlatformController": "0x48a6c47e84648ac4fd936b4930eb3f540e1d06b59e1685975f6b8d45933e98be",
12
+ "ProtectedCollector": "0xb85e5d26df0d3e74743a359ceb2668f41dc4982b9ac60c1536fa91633fc34468",
13
+ "ProtectedTreasury": "0x3b5ec14524ddfb87277ea7839230d5822ab1c62ed8ff9146526aacc779598b87",
14
+ "Safe": "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470",
15
+ "Vault": "0xcafbf2c5df3b85e47773cd4ee93821ad6d76fac4792c6ee51a089cb4b4c55ecd",
16
+ "VaultFactory": "0x8988a5c5815f908fbf98720513534d39af3fb0b9baabf531fa65cc3f835f58c3",
17
+ "VaultFeeModule": "0xb4842c40b1a8f0a5226375e9c729cbe8ead04434e73c6c58ce5024f48ae85a0e"
18
+ },
19
+ "contracts": {
20
+ "LocalVenue": "0xFB1613C44Ad39500254e0441ca47F7E679862782",
21
+ "MockUSDC": "0xb88339CB7199b77E23DB6E890353E22632Ba630f",
22
+ "PlatformController": "0x74bC4F5F60D2AaC4b17EFC2c0E65a9B307A693DE",
23
+ "ProtectedCollector": "0xF4015395a834d517753a965C38220C729174b990",
24
+ "ProtectedTreasury": "0x85012d3c96D257706F82B2683Af0A2c44fe8c386",
25
+ "Safe": "0x6D046A9f585541755081f0D424e97A924d8B95c8",
26
+ "Vault": "0xbB672914C1FA44708A71461d7647a7E8824aD8aa",
27
+ "VaultFactory": "0xA7014e4821cb9331D7CBc8e9b49c7F33632f61cf",
28
+ "VaultFeeModule": "0x2377b7ec9432830C2D23B4C003fea91c2E602c57"
29
+ },
30
+ "venue": {
31
+ "kind": "hypercore",
32
+ "address": "0xFB1613C44Ad39500254e0441ca47F7E679862782",
33
+ "model": "sdk",
34
+ "operator": "0x6D046A9f585541755081f0D424e97A924d8B95c8"
35
+ },
36
+ "profile": "development",
37
+ "artifacts": {
38
+ "LocalVenue": "DevelopmentSdkVenue",
39
+ "PlatformController": "DevelopmentPlatformController",
40
+ "ProtectedTreasury": "DevelopmentTreasury",
41
+ "Vault": "DevelopmentSdkVault",
42
+ "VaultFactory": "DevelopmentVaultFactory"
43
+ },
44
+ "owner": {
45
+ "kind": "eoa",
46
+ "address": "0x6D046A9f585541755081f0D424e97A924d8B95c8"
47
+ },
48
+ "classification": "development-only: EOA-owned controller, fixed-destination recovery, actor-restricted",
49
+ "registry": ".agents/hypercore-deployments.json#999:development-family-v6"
50
+ }
twon20/discovery.py ADDED
@@ -0,0 +1,201 @@
1
+ """Discovery uses bundled release trust anchors and canonical public EVM reads.
2
+
3
+ A vault URL selects only its address and a supported network. It cannot supply
4
+ an RPC, approval contract, deployment, nonce or trading account.
5
+ """
6
+
7
+ import json
8
+ import re
9
+ import time
10
+ from dataclasses import dataclass
11
+ from importlib.resources import files
12
+ from urllib.parse import parse_qs, urlsplit
13
+
14
+ from eth_utils import keccak
15
+
16
+ from .errors import SetupError
17
+ from .rpc import Rpc, integer
18
+
19
+ ZERO = "0x" + "0" * 40
20
+ HASH = re.compile(r"0x[0-9a-fA-F]{64}\Z")
21
+ ADDRESS = re.compile(r"0x[0-9a-fA-F]{40}\Z")
22
+ UINT64_MAX = 2 ** 64 - 1
23
+
24
+
25
+ def address(value, allow_zero=False) -> str:
26
+ if not isinstance(value, str) or not ADDRESS.fullmatch(value) or not allow_zero and value.lower() == ZERO:
27
+ raise SetupError("INVALID_ADDRESS", "Use a valid nonzero public Ethereum address.")
28
+ return value.lower()
29
+
30
+
31
+ def decoded_address(value: int, allow_zero=False) -> str:
32
+ if not 0 <= value < 2 ** 160:
33
+ raise SetupError("INVALID_CONTRACT_READ", "A contract returned an invalid address.")
34
+ return address(f"0x{value:040x}", allow_zero)
35
+
36
+
37
+ def vault_link(value: str) -> tuple[str, str, str]:
38
+ try:
39
+ parsed = urlsplit(value)
40
+ query = parse_qs(parsed.query, keep_blank_values=True, strict_parsing=True) if parsed.query else {}
41
+ except ValueError:
42
+ raise SetupError("INVALID_VAULT_LINK", "Copy the vault link from https://2n20.org.") from None
43
+ if parsed.scheme != "https" or parsed.netloc != "2n20.org" or parsed.fragment or set(query) - {"network"}:
44
+ raise SetupError("INVALID_VAULT_LINK", "Copy the vault link from https://2n20.org. The link may contain only the network selector.")
45
+ if "network" in query and len(query["network"]) != 1:
46
+ raise SetupError("INVALID_VAULT_LINK", "Use one network selector in the vault link.")
47
+ match = re.fullmatch(r"/vaults/(0x[0-9a-fA-F]{40})/?", parsed.path)
48
+ if not match:
49
+ raise SetupError("INVALID_VAULT_LINK", "Copy the full vault page link from https://2n20.org.")
50
+ network = query.get("network", ["mainnet"])[0]
51
+ if network != "mainnet":
52
+ raise SetupError("UNSUPPORTED_NETWORK", "This release supports verified HyperEVM mainnet deployments only. Do not change networks to bypass this check.")
53
+ vault = address(match[1])
54
+ return vault, network, f"https://2n20.org/vaults/{vault}?network={network}"
55
+
56
+
57
+ def trust_anchor(network: str) -> dict:
58
+ if network != "mainnet":
59
+ raise SetupError("UNSUPPORTED_NETWORK", "No reviewed deployment is bundled for this network.")
60
+ return json.loads(files("twon20").joinpath("deployments.json").read_text(encoding="utf-8"))
61
+
62
+
63
+ def same_hash(value, expected: str) -> bool:
64
+ return isinstance(value, str) and bool(HASH.fullmatch(value)) and value.lower() == expected.lower()
65
+
66
+
67
+ def block_identity(value: dict, now: int | None = None) -> tuple[int, str, int]:
68
+ if not isinstance(value, dict) or not isinstance(value.get("hash"), str) or not HASH.fullmatch(value["hash"]):
69
+ raise SetupError("INVALID_RPC_EVIDENCE", "The RPC did not provide a canonical block identity.")
70
+ number, timestamp = integer(value.get("number")), integer(value.get("timestamp"))
71
+ if now is not None and (timestamp > now + 5 or now - timestamp > 300):
72
+ raise SetupError("STALE_CHAIN", "The public chain snapshot is stale or your clock is incorrect. Check your clock and try again.")
73
+ return number, value["hash"].lower(), timestamp
74
+
75
+
76
+ def code_bytes(value) -> bytes:
77
+ if not isinstance(value, str) or not re.fullmatch(r"0x(?:[0-9a-fA-F]{2})*", value):
78
+ raise SetupError("INVALID_CONTRACT_READ", "The RPC returned invalid contract code.")
79
+ return bytes.fromhex(value[2:])
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class Discovery:
84
+ vault: str
85
+ network: str
86
+ link: str
87
+ anchor: dict
88
+ owner: str
89
+ approval_contract: str
90
+ trading_account: str
91
+ requested_key: str
92
+ revocation_pending: bool
93
+ nonce: int
94
+ block_number: int
95
+ block_hash: str
96
+ timestamp: int
97
+ book: tuple
98
+ observation: tuple
99
+ key_status: tuple
100
+ lifecycle: int
101
+ pending_requests: int
102
+
103
+ @property
104
+ def fixed(self) -> dict:
105
+ return {"blockHash": self.block_hash, "requireCanonical": True}
106
+
107
+ def public(self) -> dict:
108
+ return {"schema": "2n20-strategy-config-v1", "network": self.network, "chainId": self.anchor["chainId"],
109
+ "vaultLink": self.link, "vault": self.vault, "approvalContract": self.approval_contract,
110
+ "tradingAccount": self.trading_account, "creatorWallet": self.owner,
111
+ "hyperliquidInfoUrl": "https://api.hyperliquid.xyz/info",
112
+ "hyperliquidExchangeUrl": "https://api.hyperliquid.xyz/exchange",
113
+ "requestedTradingKey": self.requested_key, "nextApprovalNonce": str(self.nonce + 1),
114
+ "blockNumber": str(self.block_number), "blockHash": self.block_hash,
115
+ "strategyKeyFile": "strategy.key", "strategySupplied": False}
116
+
117
+
118
+ def discover(link: str, rpc: Rpc | None = None, now: int | None = None, anchor: dict | None = None) -> Discovery:
119
+ vault, network, canonical_link = vault_link(link)
120
+ anchor = anchor if anchor is not None else trust_anchor(network)
121
+ rpc = rpc if rpc is not None else Rpc(network)
122
+ chain_id = integer(rpc.call("eth_chainId", []))
123
+ if chain_id != anchor["chainId"] or chain_id != 999:
124
+ raise SetupError("WRONG_CHAIN", "The public RPC is on the wrong chain. No key was created.")
125
+ deployment_block = rpc.call("eth_getBlockByNumber", [hex(int(anchor["deploymentBlock"])), False])
126
+ if not isinstance(deployment_block, dict) or not same_hash(deployment_block.get("hash"), anchor["genesisHash"]) or integer(deployment_block.get("number")) != int(anchor["deploymentBlock"]):
127
+ raise SetupError("UNVERIFIED_DEPLOYMENT", "The deployment block does not match this release's reviewed trust anchor.")
128
+ receipt = rpc.call("eth_getTransactionReceipt", [anchor["deploymentHash"]])
129
+ if not isinstance(receipt, dict) or not same_hash(receipt.get("transactionHash"), anchor["deploymentHash"]) or not same_hash(receipt.get("blockHash"), anchor["genesisHash"]) or integer(receipt.get("status")) != 1 or integer(receipt.get("blockNumber")) != int(anchor["deploymentBlock"]) or address(receipt.get("contractAddress")) != address(anchor["contracts"]["VaultFactory"]):
130
+ raise SetupError("UNVERIFIED_DEPLOYMENT", "The factory deployment receipt does not match the reviewed deployment.")
131
+ current = rpc.call("eth_getBlockByNumber", ["latest", False])
132
+ number, block_hash, timestamp = block_identity(current, int(time.time()) if now is None else now)
133
+ fixed = {"blockHash": block_hash, "requireCanonical": True}
134
+ contracts = anchor["contracts"]
135
+ factory, controller, venue, implementation, asset = [address(contracts[name]) for name in ("VaultFactory", "PlatformController", "LocalVenue", "Vault", "MockUSDC")]
136
+ if anchor.get("venue", {}).get("model") != "sdk" or address(anchor["venue"]["address"]) != venue:
137
+ raise SetupError("UNSUPPORTED_DEPLOYMENT", "This release supports the reviewed SDK approval contracts only.")
138
+ for name in ("VaultFactory", "PlatformController", "LocalVenue", "Vault", "MockUSDC"):
139
+ code = code_bytes(rpc.call("eth_getCode", [contracts[name], fixed]))
140
+ if not code or "0x" + keccak(code).hex() != anchor["codeHashes"][name].lower():
141
+ raise SetupError("CONTRACT_CODE_MISMATCH", "Contract code does not match the reviewed release. No setup data was accepted.")
142
+ read = lambda to, signature, args=(), count=1: rpc.words(to, signature, list(args), fixed, count)
143
+ identity = lambda to, signature, args=(): decoded_address(read(to, signature, args)[0])
144
+ if identity(factory, "settings()") != controller or identity(controller, "factory()") != factory or identity(factory, "venue()") != venue or identity(factory, "asset()") != asset or identity(factory, "defaultImplementation()") != implementation:
145
+ raise SetupError("FACTORY_BINDING_MISMATCH", "The reviewed factory and controller bindings do not match.")
146
+ if read(controller, "isVault(address)", [vault]) != [1]:
147
+ raise SetupError("UNREGISTERED_VAULT", "This vault does not belong to the reviewed 2n20 factory.")
148
+ clone = bytes.fromhex("363d3d373d3d3d363d73" + implementation[2:] + "5af43d82803e903d91602b57fd5bf3")
149
+ if code_bytes(rpc.call("eth_getCode", [vault, fixed])) != clone:
150
+ raise SetupError("UNSUPPORTED_VAULT", "This vault does not use the reviewed vault implementation.")
151
+ if identity(vault, "venue()") != venue or identity(vault, "asset()") != asset or identity(venue, "asset()") != asset or identity(venue, "operator()") != address(anchor["venue"]["operator"]):
152
+ raise SetupError("VAULT_BINDING_MISMATCH", "The vault, approval contract and deployment bindings do not match.")
153
+ if read(venue, "tradingKeyVersion()") != [2]:
154
+ raise SetupError("UNSUPPORTED_CAPABILITY", "This approval contract does not support the required consent format.")
155
+ owner = identity(vault, "owner()")
156
+ core = decoded_address(read(venue, "coreAccountOf(address)", [vault])[0], True)
157
+ book = tuple(read(venue, "book(address)", [vault], 4))
158
+ observation = tuple(read(venue, "observationState(address)", [vault], 4))
159
+ key = read(venue, "tradingKeys(address)", [vault], 3)
160
+ key_status = tuple(read(venue, "tradingKeyStatus(address)", [vault], 4))
161
+ lifecycle = read(vault, "lifecycle()")[0]
162
+ pending_requests = read(vault, "pendingRequestCount()")[0]
163
+ if decoded_address(book[0], True) != core or book[1] not in (0, 1, 2, 3) or book[2] != observation[0] or any(item > UINT64_MAX for item in (book[2], observation[0], observation[1], observation[2], key[2], key_status[1])) or key[1] not in (0, 1) or lifecycle not in (0, 1, 2):
164
+ raise SetupError("ACCOUNT_BINDING_MISMATCH", "The public trading-account state is inconsistent.")
165
+ for position in (0, 2, 3):
166
+ decoded_address(key_status[position], True)
167
+ requested = decoded_address(key[0], True)
168
+ if key[2] == UINT64_MAX:
169
+ raise SetupError("NONCE_EXHAUSTED", "This vault cannot request another trading key.")
170
+ if core != ZERO:
171
+ if not code_bytes(rpc.call("eth_getCode", [core, fixed])) or identity(core, "vault()") != vault or identity(core, "venue()") != venue or identity(core, "asset()") != asset:
172
+ raise SetupError("ACCOUNT_BINDING_MISMATCH", "The trading account is not bound to this vault and approval contract.")
173
+ found = Discovery(vault, network, canonical_link, anchor, owner, venue, core, requested, bool(key[1]), key[2], number, block_hash, timestamp, book, observation, key_status, lifecycle, pending_requests)
174
+ verify_current(found, rpc, now)
175
+ return found
176
+
177
+
178
+ def verify_current(found: Discovery, rpc: Rpc, now: int | None = None):
179
+ """Reject reorganizations and state changes before using public evidence."""
180
+ if integer(rpc.call("eth_chainId", [])) != found.anchor["chainId"]:
181
+ raise SetupError("CHAIN_CHANGED", "The chain changed during discovery. Try again.")
182
+ canonical = rpc.call("eth_getBlockByNumber", [hex(found.block_number), False])
183
+ if block_identity(canonical)[:2] != (found.block_number, found.block_hash):
184
+ raise SetupError("CHAIN_CHANGED", "The chain changed during discovery. Try again.")
185
+ head_number, head_hash, _ = block_identity(rpc.call("eth_getBlockByNumber", ["latest", False]), int(time.time()) if now is None else now)
186
+ if head_number < found.block_number:
187
+ raise SetupError("CHAIN_CHANGED", "The chain head moved backwards. Try again.")
188
+ fixed = {"blockHash": head_hash, "requireCanonical": True}
189
+ checks = [(found.vault, "venue()", [], [int(found.approval_contract, 16)]),
190
+ (found.vault, "owner()", [], [int(found.owner, 16)]),
191
+ (found.approval_contract, "tradingKeys(address)", [found.vault], [int(found.requested_key, 16), int(found.revocation_pending), found.nonce]),
192
+ (found.approval_contract, "book(address)", [found.vault], list(found.book)),
193
+ (found.approval_contract, "tradingKeyStatus(address)", [found.vault], list(found.key_status)),
194
+ (found.vault, "lifecycle()", [], [found.lifecycle]),
195
+ (found.vault, "pendingRequestCount()", [], [found.pending_requests])]
196
+ for target, signature, args, expected in checks:
197
+ if rpc.words(target, signature, args, fixed, len(expected)) != expected:
198
+ raise SetupError("STATE_CHANGED", "The vault state or approval nonce changed during verification. Try again before importing consent.")
199
+ if block_identity(rpc.call("eth_getBlockByNumber", [hex(head_number), False]))[:2] != (head_number, head_hash):
200
+ raise SetupError("CHAIN_CHANGED", "The chain changed during verification. Try again.")
201
+ return fixed
twon20/errors.py ADDED
@@ -0,0 +1,7 @@
1
+ class SetupError(Exception):
2
+ """A safe public error. Never attach response bodies or key material."""
3
+
4
+ def __init__(self, code: str, message: str):
5
+ super().__init__(message)
6
+ self.code = code
7
+ self.message = message
twon20/files.py ADDED
@@ -0,0 +1,133 @@
1
+ """Private files are created exclusively and accessed through directory handles."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import secrets
7
+ import stat
8
+ from pathlib import Path
9
+
10
+ from eth_account import Account
11
+
12
+ from .discovery import Discovery, address
13
+ from .errors import SetupError
14
+ from .rpc import parse_json
15
+
16
+
17
+ def secure_directory(path: Path) -> int:
18
+ if not all(hasattr(os, flag) for flag in ("O_NOFOLLOW", "O_DIRECTORY")) or not hasattr(os, "getuid"):
19
+ raise SetupError("UNSUPPORTED_FILESYSTEM", "Run setup on Linux, macOS or WSL with private file permissions.")
20
+ path = path.expanduser().absolute()
21
+ descriptor = os.open(path.anchor, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
22
+ try:
23
+ for part in path.parts[1:]:
24
+ following = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=descriptor)
25
+ os.close(descriptor)
26
+ descriptor = following
27
+ return descriptor
28
+ except BaseException:
29
+ os.close(descriptor)
30
+ raise
31
+
32
+
33
+ class Output:
34
+ def __init__(self, path: Path, descriptor: int):
35
+ self.path = path
36
+ self.descriptor = descriptor
37
+
38
+ def close(self):
39
+ os.close(self.descriptor)
40
+
41
+ def write(self, name: str, text: str):
42
+ descriptor = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=self.descriptor)
43
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
44
+ handle.write(text)
45
+ handle.flush()
46
+ os.fsync(handle.fileno())
47
+ os.fsync(self.descriptor)
48
+
49
+ def json(self, name: str, value: dict):
50
+ self.write(name, json.dumps(value, indent=2) + "\n")
51
+ return self.path / name
52
+
53
+ def read(self, name: str, limit: int) -> str:
54
+ descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=self.descriptor)
55
+ with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
56
+ info = os.fstat(handle.fileno())
57
+ if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
58
+ raise SetupError("UNSAFE_FILE_PERMISSIONS", "The setup file must be a regular file owned by you with mode 600. Do not share its private material.")
59
+ text = handle.read(limit + 1)
60
+ if len(text) > limit:
61
+ raise SetupError("INVALID_SETUP_FILE", "The setup file has an unexpected format. No private material was displayed.")
62
+ return text
63
+
64
+
65
+ def open_output(path: Path) -> Output:
66
+ path = path.expanduser().absolute()
67
+ descriptor = secure_directory(path)
68
+ info = os.fstat(descriptor)
69
+ if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700:
70
+ os.close(descriptor)
71
+ raise SetupError("UNSAFE_DIRECTORY_PERMISSIONS", "The setup directory must be owned by you with mode 700.")
72
+ return Output(path, descriptor)
73
+
74
+
75
+ def new_output(found: Discovery, path: Path | None = None) -> Output:
76
+ path = (path if path is not None else Path.cwd() / f"2n20-{found.vault[2:10]}-{secrets.token_hex(6)}").expanduser().absolute()
77
+ parent = secure_directory(path.parent)
78
+ try:
79
+ os.mkdir(path.name, mode=0o700, dir_fd=parent)
80
+ os.fsync(parent)
81
+ except FileExistsError:
82
+ raise SetupError("OUTPUT_EXISTS", "The output directory already exists. No key was read or overwritten. Use a new directory, or renew your existing setup with 2n20 renew --directory <directory>.") from None
83
+ finally:
84
+ os.close(parent)
85
+ return open_output(path)
86
+
87
+
88
+ def retain_key(output: Output, found: Discovery):
89
+ wallet = Account.create()
90
+ output.json("setup.public.json", {"schema": "2n20-local-setup-v1", "vaultLink": found.link, "tradingKey": wallet.address})
91
+ output.write("strategy.key", wallet.key.hex().removeprefix("0x") + "\n")
92
+ return wallet
93
+
94
+
95
+ def load_setup(output: Output):
96
+ try:
97
+ value = parse_json(output.read("setup.public.json", 2000))
98
+ except ValueError:
99
+ raise SetupError("INVALID_SETUP_FILE", "This directory does not contain a valid 2n20 setup record.") from None
100
+ if not isinstance(value, dict) or set(value) != {"schema", "vaultLink", "tradingKey"} or value["schema"] != "2n20-local-setup-v1" or not isinstance(value["vaultLink"], str):
101
+ raise SetupError("INVALID_SETUP_FILE", "This directory does not contain a valid 2n20 setup record.")
102
+ return value
103
+
104
+
105
+ def load_key(output: Output, metadata: dict):
106
+ text = output.read("strategy.key", 128).strip()
107
+ if not re.fullmatch(r"[0-9a-fA-F]{64}", text):
108
+ raise SetupError("INVALID_PRIVATE_FILE", "The local key file has an unexpected format. No private material was displayed or changed.")
109
+ try:
110
+ wallet = Account.from_key(text)
111
+ except ValueError:
112
+ raise SetupError("INVALID_PRIVATE_FILE", "The local key file cannot be used. No private material was displayed or changed.") from None
113
+ if address(wallet.address) != address(metadata["tradingKey"]):
114
+ raise SetupError("KEY_RECORD_MISMATCH", "The private file does not match its public setup record. No files were changed.")
115
+ return wallet
116
+
117
+
118
+ def read_public(path: Path) -> str:
119
+ path = path.expanduser().absolute()
120
+ if not path.name.endswith(".public.json"):
121
+ raise SetupError("PUBLIC_FILE_ONLY", "Select a consent.public.json file. Never provide strategy.key, environment files or wallet backups.")
122
+ parent = secure_directory(path.parent)
123
+ try:
124
+ descriptor = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent)
125
+ with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
126
+ if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
127
+ raise SetupError("PUBLIC_FILE_ONLY", "Select a regular public consent JSON file.")
128
+ text = handle.read(2001)
129
+ if len(text) > 2000:
130
+ raise SetupError("INVALID_CONSENT", "The public consent file exceeds the supported size.")
131
+ return text
132
+ finally:
133
+ os.close(parent)
twon20/rpc.py ADDED
@@ -0,0 +1,93 @@
1
+ """Bounded, read-only JSON transport with fixed service endpoints."""
2
+
3
+ import json
4
+ import re
5
+ import urllib.error
6
+ import urllib.request
7
+
8
+ from eth_utils import keccak
9
+
10
+ from .errors import SetupError
11
+
12
+ RPC_URLS = {"mainnet": "https://rpc.hyperliquid.xyz/evm"}
13
+ INFO_URLS = {"mainnet": "https://api.hyperliquid.xyz/info"}
14
+ MAX_BYTES = 1_048_576
15
+
16
+
17
+ def unique_object(pairs):
18
+ result = {}
19
+ for key, value in pairs:
20
+ if key in result:
21
+ raise ValueError("Duplicate JSON field")
22
+ result[key] = value
23
+ return result
24
+
25
+
26
+ def parse_json(raw):
27
+ return json.loads(raw, object_pairs_hook=unique_object)
28
+
29
+
30
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
31
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
32
+ raise SetupError("REDIRECT_REJECTED", "A public service redirected the request. No setup data was accepted.")
33
+
34
+
35
+ def request_json(url: str, payload: dict):
36
+ request = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"),
37
+ headers={"Content-Type": "application/json", "User-Agent": "2n20-creator-cli/0.1.0"}, method="POST")
38
+ try:
39
+ with urllib.request.build_opener(NoRedirect).open(request, timeout=15) as response:
40
+ raw = response.read(MAX_BYTES + 1)
41
+ if len(raw) > MAX_BYTES:
42
+ raise SetupError("RESPONSE_TOO_LARGE", "The public service returned too much data. Try again later.")
43
+ return parse_json(raw)
44
+ except SetupError:
45
+ raise
46
+ except (urllib.error.URLError, TimeoutError, OSError, ValueError):
47
+ raise SetupError("PUBLIC_READ_UNAVAILABLE", "Public network evidence is unavailable. Try again later; no transaction was submitted.") from None
48
+
49
+
50
+ class Rpc:
51
+ METHODS = frozenset({"eth_chainId", "eth_getBlockByNumber", "eth_getTransactionReceipt", "eth_getCode", "eth_call"})
52
+
53
+ def __init__(self, network: str):
54
+ self.url = RPC_URLS[network]
55
+ self._id = 0
56
+
57
+ def call(self, method: str, params: list):
58
+ if method not in self.METHODS:
59
+ raise SetupError("READ_ONLY", "Only public read operations are supported.")
60
+ self._id += 1
61
+ response = request_json(self.url, {"jsonrpc": "2.0", "id": self._id, "method": method, "params": params})
62
+ if not isinstance(response, dict) or type(response.get("id")) is not int or response.get("id") != self._id or response.get("jsonrpc") != "2.0" or "error" in response or "result" not in response:
63
+ raise SetupError("RPC_UNAVAILABLE", "A required public contract read failed. Try again later; no transaction was submitted.")
64
+ return response["result"]
65
+
66
+ def words(self, to: str, signature: str, arguments: list, block: dict, count: int) -> list[int]:
67
+ data = "0x" + keccak(text=signature)[:4].hex() + "".join(word(argument) for argument in arguments)
68
+ result = self.call("eth_call", [{"to": to, "data": data}, block])
69
+ if not isinstance(result, str) or not re.fullmatch(r"0x[0-9a-fA-F]{" + str(count * 64) + r"}", result):
70
+ raise SetupError("CAPABILITY_UNAVAILABLE", "The deployment did not return the supported contract capabilities.")
71
+ try:
72
+ return [int(result[offset:offset + 64], 16) for offset in range(2, len(result), 64)]
73
+ except ValueError:
74
+ raise SetupError("INVALID_CONTRACT_READ", "The public contract returned invalid evidence.") from None
75
+
76
+
77
+ def word(value) -> str:
78
+ number = int(value, 16) if isinstance(value, str) else value
79
+ if isinstance(number, bool) or not isinstance(number, int) or number < 0 or number >= 2 ** 256:
80
+ raise SetupError("INVALID_INPUT", "Invalid public contract argument.")
81
+ return f"{number:064x}"
82
+
83
+
84
+ def integer(value) -> int:
85
+ if not isinstance(value, str) or not re.fullmatch(r"0x[0-9a-fA-F]{1,64}", value):
86
+ raise SetupError("INVALID_RPC_EVIDENCE", "The RPC returned invalid chain evidence.")
87
+ try:
88
+ result = int(value, 16)
89
+ except ValueError:
90
+ raise SetupError("INVALID_RPC_EVIDENCE", "The RPC returned invalid chain evidence.") from None
91
+ if result < 0:
92
+ raise SetupError("INVALID_RPC_EVIDENCE", "The RPC returned invalid chain evidence.")
93
+ return result
twon20/status.py ADDED
@@ -0,0 +1,93 @@
1
+ """Public account access checks. This does not exercise a strategy or submit orders."""
2
+
3
+ import time
4
+ from decimal import Decimal, InvalidOperation
5
+
6
+ from .discovery import Discovery, ZERO, address, decoded_address, verify_current
7
+ from .errors import SetupError
8
+ from .rpc import INFO_URLS, Rpc, request_json
9
+
10
+
11
+ def decimal(value) -> Decimal:
12
+ if not isinstance(value, str) or len(value) > 100:
13
+ raise SetupError("INVALID_CORE_EVIDENCE", "Hyperliquid returned invalid public funding evidence.")
14
+ try:
15
+ result = Decimal(value)
16
+ except InvalidOperation:
17
+ raise SetupError("INVALID_CORE_EVIDENCE", "Hyperliquid returned invalid public funding evidence.") from None
18
+ if not result.is_finite() or result < 0:
19
+ raise SetupError("INVALID_CORE_EVIDENCE", "Hyperliquid returned invalid public funding evidence.")
20
+ return result
21
+
22
+
23
+ def status(found: Discovery, rpc: Rpc, now: int | None = None, info=None) -> dict:
24
+ now = int(time.time()) if now is None else now
25
+ info = info if info is not None else lambda payload: request_json(INFO_URLS[found.network], payload)
26
+ result = found.public() | {"tradingAccessReady": False, "strategyConnectionTested": False,
27
+ "checkedAt": now, "evidence": "unavailable", "reasons": []}
28
+ reasons = result["reasons"]
29
+ if found.trading_account == ZERO:
30
+ reasons.append("The trading account has not been created yet. Wait for vault funding on Step 2.")
31
+ return result
32
+ if found.requested_key == ZERO:
33
+ reasons.append("Import public consent into Step 2 and approve it with the creator wallet.")
34
+ return result
35
+ if found.lifecycle != 0 or found.book[1] != 0 or found.pending_requests or found.revocation_pending:
36
+ reasons.append("The vault has a pending operation, withdrawal, revocation or closed lifecycle.")
37
+ if decoded_address(found.key_status[0], True) != found.requested_key or decoded_address(found.key_status[2], True) != found.owner or found.key_status[1] <= now:
38
+ reasons.append("The approval contract has not confirmed current trading access.")
39
+ try:
40
+ role = info({"type": "userRole", "user": found.requested_key})
41
+ agents = info({"type": "extraAgents", "user": found.trading_account})
42
+ mode = info({"type": "userAbstraction", "user": found.trading_account})
43
+ spot = info({"type": "spotClearinghouseState", "user": found.trading_account})
44
+ perp = info({"type": "clearinghouseState", "user": found.trading_account, "dex": ""})
45
+ if not isinstance(role, dict) or role.get("role") != "agent" or not isinstance(role.get("data"), dict) or address(role["data"].get("user")) != found.trading_account:
46
+ reasons.append("Hyperliquid has not confirmed this key's trading-account binding.")
47
+ if not isinstance(agents, list) or len(agents) > 100:
48
+ raise SetupError("INVALID_CORE_EVIDENCE", "Hyperliquid agent evidence is unavailable.")
49
+ seen = set()
50
+ matched = False
51
+ for agent in agents:
52
+ if not isinstance(agent, dict):
53
+ raise SetupError("INVALID_CORE_EVIDENCE", "Hyperliquid agent evidence is unavailable.")
54
+ account = address(agent.get("address"))
55
+ expiry = agent.get("validUntil")
56
+ if account in seen or type(expiry) is not int or not isinstance(agent.get("name"), str):
57
+ raise SetupError("INVALID_CORE_EVIDENCE", "Hyperliquid agent evidence is inconsistent.")
58
+ seen.add(account)
59
+ if account == found.requested_key and expiry > now * 1000:
60
+ matched = True
61
+ if not matched:
62
+ reasons.append("Hyperliquid has not confirmed an unexpired approval for this trading key.")
63
+ if mode != "disabled":
64
+ reasons.append("The trading account is not in the supported standard account mode.")
65
+ if not isinstance(spot, dict) or not isinstance(spot.get("balances"), list) or not isinstance(perp, dict) or not isinstance(perp.get("marginSummary"), dict) or type(perp.get("time")) is not int or not now * 1000 - 15_000 <= perp["time"] <= now * 1000 + 5000:
66
+ raise SetupError("INVALID_CORE_EVIDENCE", "Fresh Hyperliquid balance evidence is unavailable.")
67
+ usdc = Decimal(0)
68
+ seen_tokens = set()
69
+ for balance in spot["balances"]:
70
+ if not isinstance(balance, dict) or type(balance.get("token")) is not int or balance["token"] in seen_tokens:
71
+ raise SetupError("INVALID_CORE_EVIDENCE", "Hyperliquid spot evidence is inconsistent.")
72
+ seen_tokens.add(balance["token"])
73
+ total, hold = decimal(balance.get("total")), decimal(balance.get("hold"))
74
+ if hold > total:
75
+ raise SetupError("INVALID_CORE_EVIDENCE", "Hyperliquid spot evidence is inconsistent.")
76
+ if balance["token"] == 0:
77
+ if balance.get("coin") != "USDC":
78
+ raise SetupError("INVALID_CORE_EVIDENCE", "Hyperliquid USDC evidence is inconsistent.")
79
+ usdc += total - hold
80
+ usdc += decimal(perp["marginSummary"].get("accountValue"))
81
+ if usdc <= 0:
82
+ reasons.append("No available USDC backing was verified in the supported trading account.")
83
+ if role != info({"type": "userRole", "user": found.requested_key}) or agents != info({"type": "extraAgents", "user": found.trading_account}) or mode != info({"type": "userAbstraction", "user": found.trading_account}):
84
+ raise SetupError("CORE_STATE_CHANGED", "Hyperliquid key evidence changed during verification. Check again.")
85
+ verify_current(found, rpc)
86
+ if int(time.time()) - now > 45:
87
+ raise SetupError("STALE_CORE_EVIDENCE", "Connection evidence expired during verification. Check again.")
88
+ result["evidence"] = "verified-public-reads"
89
+ result["availableUsdc"] = str(usdc)
90
+ result["tradingAccessReady"] = not reasons
91
+ except (SetupError, KeyError, TypeError, ValueError):
92
+ reasons.append("Public Hyperliquid evidence is unavailable or inconsistent. Readiness is unverified; check again later.")
93
+ return result