diff --git a/.gitignore b/.gitignore index 91f46e3f18..bf3f09d7d7 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ e2e/servers/gin/server e2e/servers/nethttp/nethttp e2e/servers/mcp-go/mcp-go e2e/servers/mcp-go/mcp-server +e2e/servers/nethttp/nethttp e2e/legacy/servers/gin/gin # Example build artifacts diff --git a/e2e/README.md b/e2e/README.md index 9a54295429..27bbc55d63 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -48,11 +48,11 @@ pnpm test ``` Launches an interactive CLI where you can select: -- **Facilitators** - Payment verification/settlement services (Go, TypeScript) +- **Facilitators** - Payment verification/settlement services (Go, TypeScript, Python) - **Servers** - Protected endpoints requiring payment (Express, Gin, Hono, Next.js, FastAPI, Flask, etc.) - **Clients** - Payment-capable HTTP clients (axios, fetch, httpx, requests, etc.) - **Extensions** - Additional features like Bazaar discovery -- **Protocols** - EVM, SVM, and/or Aptos networks +- **Protocols** - EVM, SVM, Aptos, Stellar, and/or TVM networks Every valid combination of your selections will be tested. For example, selecting 2 facilitators, 3 servers, and 2 clients will generate and run all compatible test scenarios. @@ -115,18 +115,24 @@ CLIENT_EVM_PRIVATE_KEY=0x... # EVM private key for client payments CLIENT_SVM_PRIVATE_KEY=... # Solana private key for client payments CLIENT_APTOS_PRIVATE_KEY=... # Aptos private key for client payments (hex string) CLIENT_STELLAR_PRIVATE_KEY=... # Stellar private key for client payments +CLIENT_TVM_PRIVATE_KEY=... # TVM private key for client payments # Server payment addresses SERVER_EVM_ADDRESS=0x... # Where servers receive EVM payments SERVER_SVM_ADDRESS=... # Where servers receive Solana payments SERVER_APTOS_ADDRESS=0x... # Where servers receive Aptos payments SERVER_STELLAR_ADDRESS=... # Where servers receive Stellar payments +SERVER_TVM_ADDRESS=... # Where servers receive TVM payments # Facilitator wallets (⚠️ TEST WALLETS ONLY — used to fund/drain client between tests) FACILITATOR_EVM_PRIVATE_KEY=0x... # EVM private key for facilitator FACILITATOR_SVM_PRIVATE_KEY=... # Solana private key for facilitator FACILITATOR_APTOS_PRIVATE_KEY=... # Aptos private key for facilitator (hex string) FACILITATOR_STELLAR_PRIVATE_KEY=... # Stellar private key for facilitator +FACILITATOR_TVM_PRIVATE_KEY=... # TVM private key for facilitator + +# TVM support +TONCENTER_API_KEY=... # Recommended for TVM client/facilitator access ``` ### Account Setup Instructions @@ -153,7 +159,7 @@ $ pnpm test --min ✔ Select servers › express, hono, legacy-express ✔ Select clients › axios, fetch, httpx ✔ Select extensions › bazaar -✔ Select protocol families › EVM, SVM, Aptos, Stellar +✔ Select protocol families › EVM, SVM, Aptos, Stellar, TVM 📊 Coverage-Based Minimization Total scenarios: 156 diff --git a/e2e/clients/httpx/uv.lock b/e2e/clients/httpx/uv.lock index 1d634e6156..e8a9357d51 100644 --- a/e2e/clients/httpx/uv.lock +++ b/e2e/clients/httpx/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -1943,22 +1943,26 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.115.0" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0.0" }, { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.28.1" }, + { name = "httpx", marker = "extra == 'tvm'", specifier = ">=0.28.1" }, { name = "jsonschema", marker = "extra == 'extensions'", specifier = ">=4.0.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pynacl", marker = "extra == 'tvm'", specifier = ">=1.5.0" }, + { name = "pytoniq", marker = "extra == 'tvm'", specifier = ">=0.1.39" }, + { name = "pytoniq-core", marker = "extra == 'tvm'", specifier = ">=0.1.36" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.31.0" }, { name = "solana", marker = "extra == 'svm'", specifier = ">=0.36.0" }, { name = "solders", marker = "extra == 'svm'", specifier = ">=0.27.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, - { name = "x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "x402", extras = ["evm", "svm", "tvm"], marker = "extra == 'mechanisms'" }, { name = "x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions"], marker = "extra == 'all'" }, ] -provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] +provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] [package.metadata.requires-dev] dev = [ @@ -1974,8 +1978,11 @@ dev = [ { name = "mcp", specifier = ">=1.26.0" }, { name = "mypy", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "pynacl", specifier = ">=1.5.0" }, { name = "pytest", specifier = ">=7.0.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytoniq", specifier = ">=0.1.39" }, + { name = "pytoniq-core", specifier = ">=0.1.36" }, { name = "requests", specifier = ">=2.31.0" }, { name = "ruff", specifier = ">=0.1.0" }, { name = "solana", specifier = ">=0.36.0" }, diff --git a/e2e/clients/mcp-python/main.py b/e2e/clients/mcp-python/main.py index c2f1950978..4f5a41c337 100644 --- a/e2e/clients/mcp-python/main.py +++ b/e2e/clients/mcp-python/main.py @@ -18,11 +18,15 @@ server_url = os.getenv("RESOURCE_SERVER_URL", "") endpoint_path = os.getenv("ENDPOINT_PATH", "") # tool name, e.g. "get_weather" evm_private_key = os.getenv("EVM_PRIVATE_KEY", "") +tvm_private_key = os.getenv("TVM_PRIVATE_KEY", "") -if not server_url or not endpoint_path or not evm_private_key: +if not server_url or not endpoint_path or not (evm_private_key or tvm_private_key): result = { "success": False, - "error": "Missing required environment variables: RESOURCE_SERVER_URL, ENDPOINT_PATH, EVM_PRIVATE_KEY", + "error": ( + "Missing required environment variables: RESOURCE_SERVER_URL, ENDPOINT_PATH, " + "and one of EVM_PRIVATE_KEY or TVM_PRIVATE_KEY" + ), } print(json.dumps(result)) sys.exit(1) @@ -36,21 +40,34 @@ async def main() -> dict: from x402.mcp import create_x402_mcp_client from x402.mechanisms.evm.exact import register_exact_evm_client from x402.mechanisms.evm.signers import EthAccountSigner - - # Create x402 client with EVM scheme + from x402.mechanisms.tvm import ( + TVM_MAINNET, + TVM_TESTNET, + WalletV5R1Config, + WalletV5R1MnemonicSigner, + ) + from x402.mechanisms.tvm.exact import register_exact_tvm_client + + # Create x402 client with the configured payment schemes client = x402Client() - account = Account.from_key(evm_private_key) - evm_signer = EthAccountSigner(account) - register_exact_evm_client(client, evm_signer) + if evm_private_key: + account = Account.from_key(evm_private_key) + evm_signer = EthAccountSigner(account) + register_exact_evm_client(client, evm_signer) + + if tvm_private_key: + tvm_network = os.getenv("TVM_NETWORK", TVM_TESTNET) + if tvm_network not in {TVM_TESTNET, TVM_MAINNET}: + raise ValueError(f"Unsupported TVM network: {tvm_network}") + tvm_config = WalletV5R1Config.from_private_key(tvm_network, tvm_private_key) + tvm_config.api_key = os.getenv("TONCENTER_API_KEY") + tvm_config.base_url = os.getenv("TONCENTER_BASE_URL") + register_exact_tvm_client(client, WalletV5R1MnemonicSigner(tvm_config)) try: - async with create_x402_mcp_client( - client, server_url, auto_payment=True - ) as mcp: + async with create_x402_mcp_client(client, server_url, auto_payment=True) as mcp: # Call the paid tool - payment is handled automatically - result = await mcp.call_tool( - endpoint_path, {"city": "San Francisco"} - ) + result = await mcp.call_tool(endpoint_path, {"city": "San Francisco"}) # Extract data from content data = None diff --git a/e2e/clients/mcp-python/pyproject.toml b/e2e/clients/mcp-python/pyproject.toml index b32ad6237a..491f8e939d 100644 --- a/e2e/clients/mcp-python/pyproject.toml +++ b/e2e/clients/mcp-python/pyproject.toml @@ -6,7 +6,7 @@ requires-python = ">=3.10" dependencies = [ "python-dotenv>=1.0.0", "mcp>=1.9.0", - "x402[evm,mcp]" + "x402[evm,tvm,mcp]" ] [build-system] diff --git a/e2e/clients/mcp-python/test.config.json b/e2e/clients/mcp-python/test.config.json index bab065c9bc..6eb9eb4950 100644 --- a/e2e/clients/mcp-python/test.config.json +++ b/e2e/clients/mcp-python/test.config.json @@ -4,20 +4,28 @@ "transport": "mcp", "language": "python", "protocolFamilies": [ - "evm" + "evm", + "tvm" ], "x402Versions": [ 2 ], "evm": { - "transferMethods": ["eip3009"] + "transferMethods": [ + "eip3009" + ] }, "environment": { "required": [ - "EVM_PRIVATE_KEY", "RESOURCE_SERVER_URL", "ENDPOINT_PATH" ], - "optional": [] + "optional": [ + "EVM_PRIVATE_KEY", + "TVM_PRIVATE_KEY", + "TVM_NETWORK", + "TONCENTER_API_KEY", + "TONCENTER_BASE_URL" + ] } } diff --git a/e2e/clients/mcp-python/uv.lock b/e2e/clients/mcp-python/uv.lock index d4768a6ad5..700062fcd4 100644 --- a/e2e/clients/mcp-python/uv.lock +++ b/e2e/clients/mcp-python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -1466,6 +1466,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, ] +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695, upload-time = "2025-05-17T17:23:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772, upload-time = "2025-05-17T17:23:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083, upload-time = "2025-05-17T17:23:21.867Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056, upload-time = "2025-05-17T17:23:24.031Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478, upload-time = "2025-05-17T17:23:26.066Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -1627,6 +1662,41 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -1645,6 +1715,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] +[[package]] +name = "pytoniq" +version = "0.1.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytoniq-core" }, + { name = "requests" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/b2/9991a953e4b766918a142fe111f71f12803c6acf65eb30f36eb85ed08f31/pytoniq-0.1.43.tar.gz", hash = "sha256:b4b1c8fed2f9d2f1b6f0ab4b3f1fc5503a0088630d8081f817807ff31e608606", size = 50463, upload-time = "2025-11-30T12:30:41.102Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/c1/b6e5c739839e0e12bde4563438acd55d39552ea63f6de123724b4b91ac64/pytoniq-0.1.43-py3-none-any.whl", hash = "sha256:922c1721124bf7214e0b7044fba2a439e006367778611c0ce813cbe5b00079d3", size = 56118, upload-time = "2025-11-30T12:30:39.65Z" }, +] + +[[package]] +name = "pytoniq-core" +version = "0.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bitarray" }, + { name = "pycryptodomex" }, + { name = "pynacl" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "x25519" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/2c/7afbb9003a3aa72ccfe69711433fe36d2493db2c4acf66dde32f7b55799b/pytoniq_core-0.1.46.tar.gz", hash = "sha256:c8e3cf9ccb1852780a725cd51ba7a66a28122eb39c8b9bb97dcdc5bd02c24734", size = 101236, upload-time = "2025-11-28T10:23:21.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/0e/e27cf7ce1bebb47fb95e1d6deae5c91c6ffcb7851f156990e57079cbe8db/pytoniq_core-0.1.46-py3-none-any.whl", hash = "sha256:0a284c8b68f9fed9d54e4dad871238d844339183bf985a614796360e36e1b95e", size = 91400, upload-time = "2025-11-28T10:23:20.95Z" }, +] + [[package]] name = "pyunormalize" version = "17.0.0" @@ -1960,6 +2061,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + [[package]] name = "sse-starlette" version = "3.2.0" @@ -2135,6 +2245,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "x25519" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/b6/fca895aff0800cdf941f856df0685a5513094163664b904576e3e3ef1460/x25519-0.0.2.tar.gz", hash = "sha256:ed91d0aba7f4f4959ed8b37118c11d94f56d36c38bb6f2e6c20d0438d75b1556", size = 4833, upload-time = "2021-10-24T15:18:38.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/d1/66c637eb8e7a9601675bf7f04bb9a3015358a0f49e4c31d29a2b9a9d72d9/x25519-0.0.2-py3-none-any.whl", hash = "sha256:5c0833260a548bea9137a5a1b5c30334b751a59d148a62832df0c9e7b919ce99", size = 4907, upload-time = "2021-10-24T15:18:36.727Z" }, +] + [[package]] name = "x402" version = "2.5.0" @@ -2156,6 +2275,12 @@ evm = [ mcp = [ { name = "mcp" }, ] +tvm = [ + { name = "httpx" }, + { name = "pynacl" }, + { name = "pytoniq" }, + { name = "pytoniq-core" }, +] [package.metadata] requires-dist = [ @@ -2166,22 +2291,26 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.115.0" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0.0" }, { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.28.1" }, + { name = "httpx", marker = "extra == 'tvm'", specifier = ">=0.28.1" }, { name = "jsonschema", marker = "extra == 'extensions'", specifier = ">=4.0.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pynacl", marker = "extra == 'tvm'", specifier = ">=1.5.0" }, + { name = "pytoniq", marker = "extra == 'tvm'", specifier = ">=0.1.39" }, + { name = "pytoniq-core", marker = "extra == 'tvm'", specifier = ">=0.1.36" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.31.0" }, { name = "solana", marker = "extra == 'svm'", specifier = ">=0.36.0" }, { name = "solders", marker = "extra == 'svm'", specifier = ">=0.27.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, - { name = "x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "x402", extras = ["evm", "svm", "tvm"], marker = "extra == 'mechanisms'" }, { name = "x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions"], marker = "extra == 'all'" }, ] -provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] +provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] [package.metadata.requires-dev] dev = [ @@ -2197,8 +2326,11 @@ dev = [ { name = "mcp", specifier = ">=1.26.0" }, { name = "mypy", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "pynacl", specifier = ">=1.5.0" }, { name = "pytest", specifier = ">=7.0.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytoniq", specifier = ">=0.1.39" }, + { name = "pytoniq-core", specifier = ">=0.1.36" }, { name = "requests", specifier = ">=2.31.0" }, { name = "ruff", specifier = ">=0.1.0" }, { name = "solana", specifier = ">=0.36.0" }, @@ -2215,14 +2347,14 @@ source = { virtual = "." } dependencies = [ { name = "mcp" }, { name = "python-dotenv" }, - { name = "x402", extra = ["evm", "mcp"] }, + { name = "x402", extra = ["evm", "mcp", "tvm"] }, ] [package.metadata] requires-dist = [ { name = "mcp", specifier = ">=1.9.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, - { name = "x402", extras = ["evm", "mcp"], editable = "../../../python/x402" }, + { name = "x402", extras = ["evm", "tvm", "mcp"], editable = "../../../python/x402" }, ] [[package]] diff --git a/e2e/clients/requests/uv.lock b/e2e/clients/requests/uv.lock index 3fa9f0e1c2..9fe542cacd 100644 --- a/e2e/clients/requests/uv.lock +++ b/e2e/clients/requests/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -1943,22 +1943,26 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.115.0" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0.0" }, { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.28.1" }, + { name = "httpx", marker = "extra == 'tvm'", specifier = ">=0.28.1" }, { name = "jsonschema", marker = "extra == 'extensions'", specifier = ">=4.0.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pynacl", marker = "extra == 'tvm'", specifier = ">=1.5.0" }, + { name = "pytoniq", marker = "extra == 'tvm'", specifier = ">=0.1.39" }, + { name = "pytoniq-core", marker = "extra == 'tvm'", specifier = ">=0.1.36" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.31.0" }, { name = "solana", marker = "extra == 'svm'", specifier = ">=0.36.0" }, { name = "solders", marker = "extra == 'svm'", specifier = ">=0.27.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, - { name = "x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "x402", extras = ["evm", "svm", "tvm"], marker = "extra == 'mechanisms'" }, { name = "x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions"], marker = "extra == 'all'" }, ] -provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] +provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] [package.metadata.requires-dev] dev = [ @@ -1974,8 +1978,11 @@ dev = [ { name = "mcp", specifier = ">=1.26.0" }, { name = "mypy", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "pynacl", specifier = ">=1.5.0" }, { name = "pytest", specifier = ">=7.0.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytoniq", specifier = ">=0.1.39" }, + { name = "pytoniq-core", specifier = ">=0.1.36" }, { name = "requests", specifier = ">=2.31.0" }, { name = "ruff", specifier = ">=0.1.0" }, { name = "solana", specifier = ">=0.36.0" }, diff --git a/e2e/facilitators/python/README.md b/e2e/facilitators/python/README.md index 291e9f85b4..0538e549ae 100644 --- a/e2e/facilitators/python/README.md +++ b/e2e/facilitators/python/README.md @@ -4,7 +4,7 @@ A Python implementation of an x402 facilitator service for end-to-end testing. ## Features -- **Multi-Chain Support**: Handles both EVM (Base Sepolia) and SVM (Solana Devnet) networks +- **Multi-Chain Support**: Handles EVM (Base Sepolia), SVM (Solana Devnet), and TVM (TON testnet/mainnet) networks - **Protocol Versions**: Supports both x402 V1 and V2 protocols - **Bazaar Extension**: Full support for resource discovery and cataloging - **Lifecycle Hooks**: Payment verification tracking and discovery info extraction @@ -39,25 +39,29 @@ uv run uvicorn main:app --port 4022 ## Environment Variables -| Variable | Required | Description | -|----------|----------|-------------| -| `PORT` | No | Server port (default: 4022) | -| `EVM_PRIVATE_KEY` | Yes | Private key for EVM transactions | -| `SVM_PRIVATE_KEY` | Yes | Private key for SVM transactions | -| `EVM_RPC_URL` | No | Custom EVM RPC URL (default: Base Sepolia) | -| `EVM_NETWORK` | No | EVM network identifier | -| `SVM_NETWORK` | No | SVM network identifier | +| Variable | Required | Description | +| --------------------- | ------------- | ------------------------------------------------ | +| `PORT` | No | Server port (default: 4022) | +| `EVM_PRIVATE_KEY` | Conditionally | Private key for EVM transactions | +| `SVM_PRIVATE_KEY` | Conditionally | Private key for SVM transactions | +| `TVM_PRIVATE_KEY` | Conditionally | Private key for the TVM highload facilitator wallet | +| `EVM_RPC_URL` | No | Custom EVM RPC URL (default: Base Sepolia) | +| `EVM_NETWORK` | No | EVM network identifier | +| `SVM_NETWORK` | No | SVM network identifier | +| `TVM_NETWORK` | No | TVM network identifier (default: `tvm:-3`) | +| `TONCENTER_API_KEY` | No | Toncenter API key for TVM testnet | +| `TONCENTER_BASE_URL` | No | Custom Toncenter base URL for TVM | ## Endpoints -| Method | Path | Description | -|--------|------|-------------| -| POST | `/verify` | Verify a payment against requirements | -| POST | `/settle` | Settle a payment on-chain | -| GET | `/supported` | Get supported payment kinds and extensions | -| GET | `/discovery/resources` | List discovered resources (bazaar) | -| GET | `/health` | Health check | -| POST | `/close` | Graceful shutdown | +| Method | Path | Description | +| ------ | ---------------------- | ------------------------------------------ | +| POST | `/verify` | Verify a payment against requirements | +| POST | `/settle` | Settle a payment on-chain | +| GET | `/supported` | Get supported payment kinds and extensions | +| GET | `/discovery/resources` | List discovered resources (bazaar) | +| GET | `/health` | Health check | +| POST | `/close` | Graceful shutdown | ## API Examples @@ -97,6 +101,7 @@ The facilitator uses: - **x402 Python SDK**: Core x402 functionality - **web3.py**: EVM blockchain interactions - **solders**: SVM blockchain interactions +- **pytoniq + Toncenter**: TVM blockchain interactions ## E2E Test Integration @@ -107,4 +112,3 @@ This facilitator is automatically discovered by the e2e test framework via 2. Wait for the "Facilitator listening" log message 3. Run tests through the facilitator 4. Shut down via POST `/close` - diff --git a/e2e/facilitators/python/main.py b/e2e/facilitators/python/main.py index 97d55e8f0b..bde95d8ecd 100644 --- a/e2e/facilitators/python/main.py +++ b/e2e/facilitators/python/main.py @@ -6,6 +6,7 @@ Supports: - EVM networks (Base Sepolia) via web3.py - SVM networks (Solana Devnet) via solders +- TVM networks (TON testnet/mainnet) via pytoniq + Toncenter - Bazaar discovery extension for resource cataloging - EIP-2612 gas sponsoring extension (gasless Permit2 approval via permit) - ERC-20 approval gas sponsoring extension (gasless Permit2 via signed tx relay) @@ -19,10 +20,6 @@ import sys from typing import Any -logging.basicConfig(level=logging.INFO, format="%(name)s %(levelname)s: %(message)s") -logging.getLogger("x402.permit2").setLevel(logging.DEBUG) -logging.getLogger("x402.signers").setLevel(logging.DEBUG) - from dotenv import load_dotenv from fastapi import FastAPI, HTTPException from pydantic import BaseModel @@ -41,9 +38,19 @@ from x402.mechanisms.evm.types import TransactionReceipt from x402.mechanisms.svm import FacilitatorKeypairSigner from x402.mechanisms.svm.exact import register_exact_svm_facilitator +from x402.mechanisms.tvm import ( + TVM_TESTNET, + HighloadV3Config, + FacilitatorHighloadV3Signer, +) +from x402.mechanisms.tvm.exact import register_exact_tvm_facilitator from bazaar import BazaarCatalog +logging.basicConfig(level=logging.INFO, format="%(name)s %(levelname)s: %(message)s") +logging.getLogger("x402.permit2").setLevel(logging.DEBUG) +logging.getLogger("x402.signers").setLevel(logging.DEBUG) + # Load environment variables load_dotenv() @@ -53,27 +60,49 @@ # Initialize bazaar catalog bazaar_catalog = BazaarCatalog() -# Validate required environment variables -if not os.environ.get("EVM_PRIVATE_KEY"): - print("❌ EVM_PRIVATE_KEY environment variable is required") - sys.exit(1) - -if not os.environ.get("SVM_PRIVATE_KEY"): - print("❌ SVM_PRIVATE_KEY environment variable is required") +# Validate that at least one chain is configured +if not any( + [ + os.environ.get("EVM_PRIVATE_KEY"), + os.environ.get("SVM_PRIVATE_KEY"), + os.environ.get("TVM_PRIVATE_KEY"), + ] +): + print( + "❌ At least one of EVM_PRIVATE_KEY, SVM_PRIVATE_KEY, or TVM_PRIVATE_KEY is required" + ) sys.exit(1) -# Initialize the EVM signer from private key -evm_rpc_url = os.environ.get("EVM_RPC_URL") or "https://sepolia.base.org" -evm_signer = FacilitatorWeb3Signer( - private_key=os.environ["EVM_PRIVATE_KEY"], - rpc_url=evm_rpc_url, -) -print(f"EVM Facilitator account: {evm_signer.get_addresses()[0]}") - -# Initialize the SVM signer from private key -svm_keypair = Keypair.from_base58_string(os.environ["SVM_PRIVATE_KEY"]) -svm_signer = FacilitatorKeypairSigner(svm_keypair) -print(f"SVM Facilitator account: {svm_signer.get_addresses()[0]}") +# Network configuration +EVM_NETWORK = os.environ.get("EVM_NETWORK", "eip155:84532") +SVM_NETWORK = os.environ.get("SVM_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1") +TVM_NETWORK = os.environ.get("TVM_NETWORK", TVM_TESTNET) + +# Initialize the EVM signer from private key when configured +evm_signer = None +if os.environ.get("EVM_PRIVATE_KEY"): + evm_rpc_url = os.environ.get("EVM_RPC_URL") or "https://sepolia.base.org" + evm_signer = FacilitatorWeb3Signer( + private_key=os.environ["EVM_PRIVATE_KEY"], + rpc_url=evm_rpc_url, + ) + print(f"EVM Facilitator account: {evm_signer.get_addresses()[0]}") + +# Initialize the SVM signer from private key when configured +svm_signer = None +if os.environ.get("SVM_PRIVATE_KEY"): + svm_keypair = Keypair.from_base58_string(os.environ["SVM_PRIVATE_KEY"]) + svm_signer = FacilitatorKeypairSigner(svm_keypair) + print(f"SVM Facilitator account: {svm_signer.get_addresses()[0]}") + +# Initialize the TVM signer from private key when configured +tvm_signer = None +if os.environ.get("TVM_PRIVATE_KEY"): + tvm_config = HighloadV3Config.from_private_key(os.environ["TVM_PRIVATE_KEY"]) + tvm_config.api_key = os.environ.get("TONCENTER_API_KEY") + tvm_config.toncenter_base_url = os.environ.get("TONCENTER_BASE_URL") + tvm_signer = FacilitatorHighloadV3Signer({TVM_NETWORK: tvm_config}) + print(f"TVM Facilitator account: {tvm_signer.get_addresses()[0]}") class Erc20ApprovalSigner: @@ -138,7 +167,9 @@ def wait_for_transaction_receipt(self, tx_hash: str) -> TransactionReceipt: return self._signer.wait_for_transaction_receipt(tx_hash) -erc20_approval_signer = Erc20ApprovalSigner(evm_signer) +erc20_approval_signer = ( + Erc20ApprovalSigner(evm_signer) if evm_signer is not None else None +) def _handle_after_verify(ctx: Any) -> None: @@ -183,7 +214,7 @@ def _handle_after_verify(ctx: Any) -> None: print(f" ⚠️ Failed to extract discovery info: {err}") -# Initialize the x402 Facilitator with EVM and SVM support +# Initialize the x402 Facilitator with optional EVM/SVM/TVM support facilitator = ( x402Facilitator() .on_before_verify(lambda ctx: print("Before verify", ctx)) @@ -195,25 +226,36 @@ def _handle_after_verify(ctx: Any) -> None: ) # Register EVM schemes (V1 and V2) -register_exact_evm_facilitator( - facilitator, - evm_signer, - networks="eip155:84532", # Base Sepolia - deploy_erc4337_with_eip6492=True, -) +if evm_signer is not None: + register_exact_evm_facilitator( + facilitator, + evm_signer, + networks=EVM_NETWORK, + deploy_erc4337_with_eip6492=True, + ) # Register SVM schemes (V1 and V2) -register_exact_svm_facilitator( - facilitator, - svm_signer, - networks="solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", # Devnet -) +if svm_signer is not None: + register_exact_svm_facilitator( + facilitator, + svm_signer, + networks=SVM_NETWORK, + ) + +# Register TVM schemes (V2) +if tvm_signer is not None: + register_exact_tvm_facilitator( + facilitator, + tvm_signer, + networks=TVM_NETWORK, + ) # Register gas sponsoring extensions -facilitator.register_extension(EIP2612_GAS_SPONSORING) -facilitator.register_extension( - Erc20ApprovalFacilitatorExtension(signer=erc20_approval_signer) -) +if evm_signer is not None and erc20_approval_signer is not None: + facilitator.register_extension(EIP2612_GAS_SPONSORING) + facilitator.register_extension( + Erc20ApprovalFacilitatorExtension(signer=erc20_approval_signer) + ) # Pydantic models for request/response @@ -266,11 +308,14 @@ async def verify(request: VerifyRequest): response = await facilitator.verify(payload, requirements) if not response.is_valid: - print(f" ❌ Verify rejected: {response.invalid_reason} (payer={response.payer})") + print( + f" ❌ Verify rejected: {response.invalid_reason} (payer={response.payer})" + ) return response.model_dump(by_alias=True, exclude_none=True) except Exception as e: import traceback + print(f"Verify error: {e}") traceback.print_exc() raise HTTPException(status_code=500, detail=str(e)) @@ -335,7 +380,9 @@ async def supported(): response = facilitator.get_supported() return { - "kinds": [k.model_dump(by_alias=True, exclude_none=True) for k in response.kinds], + "kinds": [ + k.model_dump(by_alias=True, exclude_none=True) for k in response.kinds + ], "extensions": response.extensions, "signers": response.signers, } @@ -363,7 +410,7 @@ async def health(): """Health check endpoint.""" return { "status": "ok", - "network": "eip155:84532", + "networks": [kind.network for kind in facilitator.get_supported().kinds], "facilitator": "python", "version": "2.0.0", "extensions": facilitator.get_extensions(), @@ -377,6 +424,8 @@ async def close(): import asyncio print("Received shutdown request") + if tvm_signer is not None: + tvm_signer.close() async def shutdown(): await asyncio.sleep(0.1) @@ -389,14 +438,16 @@ async def shutdown(): if __name__ == "__main__": import uvicorn + supported_networks = [kind.network for kind in facilitator.get_supported().kinds] + active_extensions = facilitator.get_extensions() + print(f""" ╔════════════════════════════════════════════════════════╗ ║ x402 Python Facilitator (E2E) ║ ╠════════════════════════════════════════════════════════╣ ║ Server: http://localhost:{PORT} ║ -║ Network: eip155:84532 ║ -║ Address: {evm_signer.get_addresses()[0]} ║ -║ Extensions: bazaar, eip2612, erc20approval ║ +║ Networks: {", ".join(supported_networks[:2])[:36]:<36}║ +║ Extensions: {", ".join(active_extensions)[:36]:<36}║ ║ ║ ║ Endpoints: ║ ║ • POST /verify (verify payment) ║ diff --git a/e2e/facilitators/python/pyproject.toml b/e2e/facilitators/python/pyproject.toml index 635094c70d..474aabaca4 100644 --- a/e2e/facilitators/python/pyproject.toml +++ b/e2e/facilitators/python/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "Python facilitator for x402 e2e testing" requires-python = ">=3.10" dependencies = [ - "x402[fastapi,evm,svm,extensions]", + "x402[fastapi,evm,svm,tvm,extensions]", "python-dotenv>=1.2.1", "uvicorn[standard]>=0.40.0", ] @@ -14,4 +14,3 @@ package = false [tool.uv.sources] x402 = { path = "../../../python/x402", editable = true } - diff --git a/e2e/facilitators/python/test.config.json b/e2e/facilitators/python/test.config.json index ab0ea28eb9..b2a4e13bc0 100644 --- a/e2e/facilitators/python/test.config.json +++ b/e2e/facilitators/python/test.config.json @@ -4,7 +4,8 @@ "language": "python", "protocolFamilies": [ "evm", - "svm" + "svm", + "tvm" ], "x402Versions": [ 1, @@ -16,18 +17,25 @@ "erc20ApprovalGasSponsoring" ], "evm": { - "transferMethods": ["eip3009", "permit2"] + "transferMethods": [ + "eip3009", + "permit2" + ] }, "environment": { "required": [ - "PORT", - "EVM_PRIVATE_KEY", - "SVM_PRIVATE_KEY" + "PORT" ], "optional": [ "EVM_NETWORK", "SVM_NETWORK", - "EVM_RPC_URL" + "TVM_NETWORK", + "EVM_RPC_URL", + "EVM_PRIVATE_KEY", + "SVM_PRIVATE_KEY", + "TVM_PRIVATE_KEY", + "TONCENTER_API_KEY", + "TONCENTER_BASE_URL" ] } } diff --git a/e2e/facilitators/python/uv.lock b/e2e/facilitators/python/uv.lock index e3a4299b04..93dc5fb26a 100644 --- a/e2e/facilitators/python/uv.lock +++ b/e2e/facilitators/python/uv.lock @@ -289,6 +289,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -1657,6 +1739,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pycryptodome" version = "3.23.0" @@ -1692,6 +1783,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, ] +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695, upload-time = "2025-05-17T17:23:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772, upload-time = "2025-05-17T17:23:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083, upload-time = "2025-05-17T17:23:21.867Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056, upload-time = "2025-05-17T17:23:24.031Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478, upload-time = "2025-05-17T17:23:26.066Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -1866,6 +1992,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -1884,6 +2045,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, ] +[[package]] +name = "pytoniq" +version = "0.1.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytoniq-core" }, + { name = "requests" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/b2/9991a953e4b766918a142fe111f71f12803c6acf65eb30f36eb85ed08f31/pytoniq-0.1.43.tar.gz", hash = "sha256:b4b1c8fed2f9d2f1b6f0ab4b3f1fc5503a0088630d8081f817807ff31e608606", size = 50463, upload-time = "2025-11-30T12:30:41.102Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/c1/b6e5c739839e0e12bde4563438acd55d39552ea63f6de123724b4b91ac64/pytoniq-0.1.43-py3-none-any.whl", hash = "sha256:922c1721124bf7214e0b7044fba2a439e006367778611c0ce813cbe5b00079d3", size = 56118, upload-time = "2025-11-30T12:30:39.65Z" }, +] + +[[package]] +name = "pytoniq-core" +version = "0.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bitarray" }, + { name = "pycryptodomex" }, + { name = "pynacl" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "x25519" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/2c/7afbb9003a3aa72ccfe69711433fe36d2493db2c4acf66dde32f7b55799b/pytoniq_core-0.1.46.tar.gz", hash = "sha256:c8e3cf9ccb1852780a725cd51ba7a66a28122eb39c8b9bb97dcdc5bd02c24734", size = 101236, upload-time = "2025-11-28T10:23:21.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/0e/e27cf7ce1bebb47fb95e1d6deae5c91c6ffcb7851f156990e57079cbe8db/pytoniq_core-0.1.46-py3-none-any.whl", hash = "sha256:0a284c8b68f9fed9d54e4dad871238d844339183bf985a614796360e36e1b95e", size = 91400, upload-time = "2025-11-28T10:23:20.95Z" }, +] + [[package]] name = "pyunormalize" version = "17.0.0" @@ -2410,6 +2602,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/43/1c586f9f413765201234541857cb82fda076f4b0f7bad4a0ec248da39cf3/sentry_sdk-2.49.0-py2.py3-none-any.whl", hash = "sha256:6ea78499133874445a20fe9c826c9e960070abeb7ae0cdf930314ab16bb97aa0", size = 415693, upload-time = "2026-01-08T09:56:21.872Z" }, ] +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -2844,6 +3045,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "x25519" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/b6/fca895aff0800cdf941f856df0685a5513094163664b904576e3e3ef1460/x25519-0.0.2.tar.gz", hash = "sha256:ed91d0aba7f4f4959ed8b37118c11d94f56d36c38bb6f2e6c20d0438d75b1556", size = 4833, upload-time = "2021-10-24T15:18:38.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/d1/66c637eb8e7a9601675bf7f04bb9a3015358a0f49e4c31d29a2b9a9d72d9/x25519-0.0.2-py3-none-any.whl", hash = "sha256:5c0833260a548bea9137a5a1b5c30334b751a59d148a62832df0c9e7b919ce99", size = 4907, upload-time = "2021-10-24T15:18:36.727Z" }, +] + [[package]] name = "x402" version = "2.5.0" @@ -2873,6 +3083,12 @@ svm = [ { name = "solana" }, { name = "solders" }, ] +tvm = [ + { name = "httpx" }, + { name = "pynacl" }, + { name = "pytoniq" }, + { name = "pytoniq-core" }, +] [package.metadata] requires-dist = [ @@ -2883,22 +3099,26 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.115.0" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0.0" }, { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.28.1" }, + { name = "httpx", marker = "extra == 'tvm'", specifier = ">=0.28.1" }, { name = "jsonschema", marker = "extra == 'extensions'", specifier = ">=4.0.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pynacl", marker = "extra == 'tvm'", specifier = ">=1.5.0" }, + { name = "pytoniq", marker = "extra == 'tvm'", specifier = ">=0.1.39" }, + { name = "pytoniq-core", marker = "extra == 'tvm'", specifier = ">=0.1.36" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.31.0" }, { name = "solana", marker = "extra == 'svm'", specifier = ">=0.36.0" }, { name = "solders", marker = "extra == 'svm'", specifier = ">=0.27.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, - { name = "x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "x402", extras = ["evm", "svm", "tvm"], marker = "extra == 'mechanisms'" }, { name = "x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions"], marker = "extra == 'all'" }, ] -provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] +provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] [package.metadata.requires-dev] dev = [ @@ -2914,8 +3134,11 @@ dev = [ { name = "mcp", specifier = ">=1.26.0" }, { name = "mypy", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "pynacl", specifier = ">=1.5.0" }, { name = "pytest", specifier = ">=7.0.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytoniq", specifier = ">=0.1.39" }, + { name = "pytoniq-core", specifier = ">=0.1.36" }, { name = "requests", specifier = ">=2.31.0" }, { name = "ruff", specifier = ">=0.1.0" }, { name = "solana", specifier = ">=0.36.0" }, @@ -2932,14 +3155,14 @@ source = { virtual = "." } dependencies = [ { name = "python-dotenv" }, { name = "uvicorn", extra = ["standard"] }, - { name = "x402", extra = ["evm", "extensions", "fastapi", "svm"] }, + { name = "x402", extra = ["evm", "extensions", "fastapi", "svm", "tvm"] }, ] [package.metadata] requires-dist = [ { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" }, - { name = "x402", extras = ["fastapi", "evm", "svm", "extensions"], editable = "../../../python/x402" }, + { name = "x402", extras = ["fastapi", "evm", "svm", "tvm", "extensions"], editable = "../../../python/x402" }, ] [[package]] diff --git a/e2e/servers/fastapi/uv.lock b/e2e/servers/fastapi/uv.lock index d3fe5a45b0..c5a06e02bd 100644 --- a/e2e/servers/fastapi/uv.lock +++ b/e2e/servers/fastapi/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -2878,22 +2878,26 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.115.0" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0.0" }, { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.28.1" }, + { name = "httpx", marker = "extra == 'tvm'", specifier = ">=0.28.1" }, { name = "jsonschema", marker = "extra == 'extensions'", specifier = ">=4.0.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pynacl", marker = "extra == 'tvm'", specifier = ">=1.5.0" }, + { name = "pytoniq", marker = "extra == 'tvm'", specifier = ">=0.1.39" }, + { name = "pytoniq-core", marker = "extra == 'tvm'", specifier = ">=0.1.36" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.31.0" }, { name = "solana", marker = "extra == 'svm'", specifier = ">=0.36.0" }, { name = "solders", marker = "extra == 'svm'", specifier = ">=0.27.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, - { name = "x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "x402", extras = ["evm", "svm", "tvm"], marker = "extra == 'mechanisms'" }, { name = "x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions"], marker = "extra == 'all'" }, ] -provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] +provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] [package.metadata.requires-dev] dev = [ @@ -2909,8 +2913,11 @@ dev = [ { name = "mcp", specifier = ">=1.26.0" }, { name = "mypy", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "pynacl", specifier = ">=1.5.0" }, { name = "pytest", specifier = ">=7.0.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytoniq", specifier = ">=0.1.39" }, + { name = "pytoniq-core", specifier = ">=0.1.36" }, { name = "requests", specifier = ">=2.31.0" }, { name = "ruff", specifier = ">=0.1.0" }, { name = "solana", specifier = ">=0.36.0" }, diff --git a/e2e/servers/flask/uv.lock b/e2e/servers/flask/uv.lock index bec13acc9d..19cb500144 100644 --- a/e2e/servers/flask/uv.lock +++ b/e2e/servers/flask/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -2108,22 +2108,26 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.115.0" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0.0" }, { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.28.1" }, + { name = "httpx", marker = "extra == 'tvm'", specifier = ">=0.28.1" }, { name = "jsonschema", marker = "extra == 'extensions'", specifier = ">=4.0.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pynacl", marker = "extra == 'tvm'", specifier = ">=1.5.0" }, + { name = "pytoniq", marker = "extra == 'tvm'", specifier = ">=0.1.39" }, + { name = "pytoniq-core", marker = "extra == 'tvm'", specifier = ">=0.1.36" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.31.0" }, { name = "solana", marker = "extra == 'svm'", specifier = ">=0.36.0" }, { name = "solders", marker = "extra == 'svm'", specifier = ">=0.27.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, - { name = "x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "x402", extras = ["evm", "svm", "tvm"], marker = "extra == 'mechanisms'" }, { name = "x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions"], marker = "extra == 'all'" }, ] -provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] +provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] [package.metadata.requires-dev] dev = [ @@ -2139,8 +2143,11 @@ dev = [ { name = "mcp", specifier = ">=1.26.0" }, { name = "mypy", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "pynacl", specifier = ">=1.5.0" }, { name = "pytest", specifier = ">=7.0.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytoniq", specifier = ">=0.1.39" }, + { name = "pytoniq-core", specifier = ">=0.1.36" }, { name = "requests", specifier = ">=2.31.0" }, { name = "ruff", specifier = ">=0.1.0" }, { name = "solana", specifier = ">=0.36.0" }, diff --git a/e2e/servers/mcp-python/main.py b/e2e/servers/mcp-python/main.py index c27d2f80fd..f084ca3f80 100644 --- a/e2e/servers/mcp-python/main.py +++ b/e2e/servers/mcp-python/main.py @@ -16,10 +16,12 @@ PORT = int(os.getenv("PORT", "4022")) EVM_NETWORK = os.getenv("EVM_NETWORK", "eip155:84532") EVM_PAYEE_ADDRESS = os.getenv("EVM_PAYEE_ADDRESS", "") +TVM_NETWORK = os.getenv("TVM_NETWORK", "tvm:-3") +TVM_PAYEE_ADDRESS = os.getenv("TVM_PAYEE_ADDRESS", "") FACILITATOR_URL = os.getenv("FACILITATOR_URL", "") -if not EVM_PAYEE_ADDRESS: - print("EVM_PAYEE_ADDRESS environment variable is required") +if not EVM_PAYEE_ADDRESS and not TVM_PAYEE_ADDRESS: + print("At least one of EVM_PAYEE_ADDRESS or TVM_PAYEE_ADDRESS is required") exit(1) if not FACILITATOR_URL: @@ -43,6 +45,7 @@ def main() -> None: from x402.http import FacilitatorConfig, HTTPFacilitatorClient from x402.mcp import create_payment_wrapper from x402.mechanisms.evm.exact import register_exact_evm_server + from x402.mechanisms.tvm.exact import register_exact_tvm_server # Create FastMCP server mcp = FastMCP("x402 MCP E2E Server") @@ -50,19 +53,37 @@ def main() -> None: # Set up x402 resource server facilitator_client = HTTPFacilitatorClient(FacilitatorConfig(url=FACILITATOR_URL)) resource_server = x402ResourceServer(facilitator_client) - register_exact_evm_server(resource_server, EVM_NETWORK) + if EVM_PAYEE_ADDRESS: + register_exact_evm_server(resource_server, EVM_NETWORK) + if TVM_PAYEE_ADDRESS: + register_exact_tvm_server(resource_server, TVM_NETWORK) # Initialize (fetches supported kinds from facilitator) resource_server.initialize() - # Build payment requirements for the weather tool - weather_config = ResourceConfig( - scheme="exact", - network=EVM_NETWORK, - pay_to=EVM_PAYEE_ADDRESS, - price="$0.001", - ) - weather_accepts = resource_server.build_payment_requirements(weather_config) + weather_accepts = [] + if EVM_PAYEE_ADDRESS: + weather_accepts.extend( + resource_server.build_payment_requirements( + ResourceConfig( + scheme="exact", + network=EVM_NETWORK, + pay_to=EVM_PAYEE_ADDRESS, + price="$0.001", + ) + ) + ) + if TVM_PAYEE_ADDRESS: + weather_accepts.extend( + resource_server.build_payment_requirements( + ResourceConfig( + scheme="exact", + network=TVM_NETWORK, + pay_to=TVM_PAYEE_ADDRESS, + price="$0.001", + ) + ) + ) # Create payment wrapper for the weather tool weather_wrapper = create_payment_wrapper( @@ -96,7 +117,18 @@ def ping() -> str: async def health(request): return JSONResponse( - {"status": "ok", "tools": ["get_weather (paid: $0.001)", "ping (free)"]} + { + "status": "ok", + "tools": ["get_weather (paid: $0.001)", "ping (free)"], + "protocols": [ + protocol + for protocol, enabled in { + "evm": bool(EVM_PAYEE_ADDRESS), + "tvm": bool(TVM_PAYEE_ADDRESS), + }.items() + if enabled + ], + } ) async def close(request): @@ -128,6 +160,10 @@ def shutdown(): print(f"Server listening on port {PORT}") print(f"SSE endpoint: http://localhost:{PORT}/sse") print(f"Health: http://localhost:{PORT}/health") + if EVM_PAYEE_ADDRESS: + print(f"EVM payments enabled on {EVM_NETWORK}") + if TVM_PAYEE_ADDRESS: + print(f"TVM payments enabled on {TVM_NETWORK}") uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="warning") diff --git a/e2e/servers/mcp-python/pyproject.toml b/e2e/servers/mcp-python/pyproject.toml index 41a0e1705d..ad6adf3056 100644 --- a/e2e/servers/mcp-python/pyproject.toml +++ b/e2e/servers/mcp-python/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "mcp>=1.9.0", "uvicorn>=0.24.0", "starlette>=0.27.0", - "x402[evm,mcp]" + "x402[evm,tvm,mcp]" ] [build-system] diff --git a/e2e/servers/mcp-python/test.config.json b/e2e/servers/mcp-python/test.config.json index 665cb46119..ea247766fe 100644 --- a/e2e/servers/mcp-python/test.config.json +++ b/e2e/servers/mcp-python/test.config.json @@ -13,6 +13,13 @@ "protocolFamily": "evm", "transferMethod": "eip3009" }, + { + "path": "get_weather", + "method": "tool", + "description": "Paid weather tool via MCP transport", + "requiresPayment": true, + "protocolFamily": "tvm" + }, { "path": "/health", "method": "GET", @@ -29,9 +36,13 @@ "environment": { "required": [ "PORT", - "EVM_PAYEE_ADDRESS", "FACILITATOR_URL" ], - "optional": [] + "optional": [ + "EVM_PAYEE_ADDRESS", + "EVM_NETWORK", + "TVM_PAYEE_ADDRESS", + "TVM_NETWORK" + ] } } diff --git a/e2e/servers/mcp-python/uv.lock b/e2e/servers/mcp-python/uv.lock index 77e4c907b6..dbf7bbba7f 100644 --- a/e2e/servers/mcp-python/uv.lock +++ b/e2e/servers/mcp-python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -1466,6 +1466,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, ] +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695, upload-time = "2025-05-17T17:23:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772, upload-time = "2025-05-17T17:23:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083, upload-time = "2025-05-17T17:23:21.867Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056, upload-time = "2025-05-17T17:23:24.031Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478, upload-time = "2025-05-17T17:23:26.066Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -1627,6 +1662,41 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -1645,6 +1715,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] +[[package]] +name = "pytoniq" +version = "0.1.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytoniq-core" }, + { name = "requests" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/b2/9991a953e4b766918a142fe111f71f12803c6acf65eb30f36eb85ed08f31/pytoniq-0.1.43.tar.gz", hash = "sha256:b4b1c8fed2f9d2f1b6f0ab4b3f1fc5503a0088630d8081f817807ff31e608606", size = 50463, upload-time = "2025-11-30T12:30:41.102Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/c1/b6e5c739839e0e12bde4563438acd55d39552ea63f6de123724b4b91ac64/pytoniq-0.1.43-py3-none-any.whl", hash = "sha256:922c1721124bf7214e0b7044fba2a439e006367778611c0ce813cbe5b00079d3", size = 56118, upload-time = "2025-11-30T12:30:39.65Z" }, +] + +[[package]] +name = "pytoniq-core" +version = "0.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bitarray" }, + { name = "pycryptodomex" }, + { name = "pynacl" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "x25519" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/2c/7afbb9003a3aa72ccfe69711433fe36d2493db2c4acf66dde32f7b55799b/pytoniq_core-0.1.46.tar.gz", hash = "sha256:c8e3cf9ccb1852780a725cd51ba7a66a28122eb39c8b9bb97dcdc5bd02c24734", size = 101236, upload-time = "2025-11-28T10:23:21.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/0e/e27cf7ce1bebb47fb95e1d6deae5c91c6ffcb7851f156990e57079cbe8db/pytoniq_core-0.1.46-py3-none-any.whl", hash = "sha256:0a284c8b68f9fed9d54e4dad871238d844339183bf985a614796360e36e1b95e", size = 91400, upload-time = "2025-11-28T10:23:20.95Z" }, +] + [[package]] name = "pyunormalize" version = "17.0.0" @@ -1960,6 +2061,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + [[package]] name = "sse-starlette" version = "3.2.0" @@ -2135,6 +2245,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "x25519" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/b6/fca895aff0800cdf941f856df0685a5513094163664b904576e3e3ef1460/x25519-0.0.2.tar.gz", hash = "sha256:ed91d0aba7f4f4959ed8b37118c11d94f56d36c38bb6f2e6c20d0438d75b1556", size = 4833, upload-time = "2021-10-24T15:18:38.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/d1/66c637eb8e7a9601675bf7f04bb9a3015358a0f49e4c31d29a2b9a9d72d9/x25519-0.0.2-py3-none-any.whl", hash = "sha256:5c0833260a548bea9137a5a1b5c30334b751a59d148a62832df0c9e7b919ce99", size = 4907, upload-time = "2021-10-24T15:18:36.727Z" }, +] + [[package]] name = "x402" version = "2.5.0" @@ -2156,6 +2275,12 @@ evm = [ mcp = [ { name = "mcp" }, ] +tvm = [ + { name = "httpx" }, + { name = "pynacl" }, + { name = "pytoniq" }, + { name = "pytoniq-core" }, +] [package.metadata] requires-dist = [ @@ -2166,22 +2291,26 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.115.0" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0.0" }, { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.28.1" }, + { name = "httpx", marker = "extra == 'tvm'", specifier = ">=0.28.1" }, { name = "jsonschema", marker = "extra == 'extensions'", specifier = ">=4.0.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pynacl", marker = "extra == 'tvm'", specifier = ">=1.5.0" }, + { name = "pytoniq", marker = "extra == 'tvm'", specifier = ">=0.1.39" }, + { name = "pytoniq-core", marker = "extra == 'tvm'", specifier = ">=0.1.36" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.31.0" }, { name = "solana", marker = "extra == 'svm'", specifier = ">=0.36.0" }, { name = "solders", marker = "extra == 'svm'", specifier = ">=0.27.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, - { name = "x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "x402", extras = ["evm", "svm", "tvm"], marker = "extra == 'mechanisms'" }, { name = "x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions"], marker = "extra == 'all'" }, ] -provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] +provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] [package.metadata.requires-dev] dev = [ @@ -2197,8 +2326,11 @@ dev = [ { name = "mcp", specifier = ">=1.26.0" }, { name = "mypy", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "pynacl", specifier = ">=1.5.0" }, { name = "pytest", specifier = ">=7.0.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytoniq", specifier = ">=0.1.39" }, + { name = "pytoniq-core", specifier = ">=0.1.36" }, { name = "requests", specifier = ">=2.31.0" }, { name = "ruff", specifier = ">=0.1.0" }, { name = "solana", specifier = ">=0.36.0" }, @@ -2217,7 +2349,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "starlette" }, { name = "uvicorn" }, - { name = "x402", extra = ["evm", "mcp"] }, + { name = "x402", extra = ["evm", "mcp", "tvm"] }, ] [package.metadata] @@ -2226,7 +2358,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "starlette", specifier = ">=0.27.0" }, { name = "uvicorn", specifier = ">=0.24.0" }, - { name = "x402", extras = ["evm", "mcp"], editable = "../../../python/x402" }, + { name = "x402", extras = ["evm", "tvm", "mcp"], editable = "../../../python/x402" }, ] [[package]] diff --git a/e2e/src/cli/args.ts b/e2e/src/cli/args.ts index ec6332ff71..7da261156d 100644 --- a/e2e/src/cli/args.ts +++ b/e2e/src/cli/args.ts @@ -135,8 +135,8 @@ export function printHelp(): void { console.log(' pnpm test -v Interactive with verbose logging'); console.log(''); console.log('Network Selection:'); - console.log(' --testnet Use testnet networks (Base Sepolia + Solana Devnet)'); - console.log(' --mainnet Use mainnet networks (Base + Solana) ⚠️ Real funds!'); + console.log(' --testnet Use testnet networks (Base Sepolia + Solana Devnet + TON Testnet)'); + console.log(' --mainnet Use mainnet networks (Base + Solana + TON) ⚠️ Real funds!'); console.log(' (If not specified, will prompt in interactive mode)'); console.log(''); console.log('Programmatic Mode (for CI/workflows):'); @@ -146,7 +146,7 @@ export function printHelp(): void { console.log(' --clients= Comma-separated client names'); console.log(' --extensions= Comma-separated extensions (e.g., bazaar)'); console.log(' --versions= Comma-separated version numbers (e.g., 1,2)'); - console.log(' --families= Comma-separated protocol families (e.g., evm,svm)'); + console.log(' --families= Comma-separated protocol families (e.g., evm,svm,tvm)'); console.log(' --endpoints= Comma-separated endpoint paths or regex patterns (auto-anchored)'); console.log(''); console.log('Options:'); diff --git a/e2e/src/clients/generic-client.ts b/e2e/src/clients/generic-client.ts index aff2ed44a0..9222aba553 100644 --- a/e2e/src/clients/generic-client.ts +++ b/e2e/src/clients/generic-client.ts @@ -18,17 +18,38 @@ export class GenericClientProxy extends BaseProxy implements ClientProxy { async call(config: ClientConfig): Promise { try { - const runConfig: RunConfig = { - env: { - EVM_PRIVATE_KEY: config.evmPrivateKey, - SVM_PRIVATE_KEY: config.svmPrivateKey, - APTOS_PRIVATE_KEY: config.aptosPrivateKey, - STELLAR_PRIVATE_KEY: config.stellarPrivateKey, - RESOURCE_SERVER_URL: config.serverUrl, - ENDPOINT_PATH: config.endpointPath, - EVM_NETWORK: config.evmNetwork, - EVM_RPC_URL: config.evmRpcUrl, + const baseEnv: Record = { + EVM_PRIVATE_KEY: config.evmPrivateKey, + SVM_PRIVATE_KEY: config.svmPrivateKey, + APTOS_PRIVATE_KEY: config.aptosPrivateKey, + STELLAR_PRIVATE_KEY: config.stellarPrivateKey, + TVM_PRIVATE_KEY: config.tvmPrivateKey, + RESOURCE_SERVER_URL: config.serverUrl, + ENDPOINT_PATH: config.endpointPath, + EVM_NETWORK: config.evmNetwork, + EVM_RPC_URL: config.evmRpcUrl, + TVM_NETWORK: config.tvmNetwork, + TONCENTER_BASE_URL: process.env.TONCENTER_BASE_URL || config.tvmRpcUrl, + }; + + const clientConfig = this.loadConfig(); + if (clientConfig?.environment?.required) { + for (const envVar of clientConfig.environment.required) { + if (process.env[envVar] && !baseEnv[envVar]) { + baseEnv[envVar] = process.env[envVar]!; + } + } + } + if (clientConfig?.environment?.optional) { + for (const envVar of clientConfig.environment.optional) { + if (process.env[envVar] && !baseEnv[envVar]) { + baseEnv[envVar] = process.env[envVar]!; + } } + } + + const runConfig: RunConfig = { + env: baseEnv }; // For clients, we run the process and wait for it to complete @@ -71,4 +92,20 @@ export class GenericClientProxy extends BaseProxy implements ClientProxy { async forceStop(): Promise { await this.stopProcess(); } -} \ No newline at end of file + + private loadConfig(): any { + try { + const { readFileSync, existsSync } = require('fs'); + const { join } = require('path'); + const configPath = join(this.directory, 'test.config.json'); + + if (existsSync(configPath)) { + const configContent = readFileSync(configPath, 'utf-8'); + return JSON.parse(configContent); + } + } catch { + // Fall back to the explicitly provided env set when config loading fails. + } + return null; + } +} diff --git a/e2e/src/facilitators/facilitator-manager.ts b/e2e/src/facilitators/facilitator-manager.ts index e54be2da78..8190175285 100644 --- a/e2e/src/facilitators/facilitator-manager.ts +++ b/e2e/src/facilitators/facilitator-manager.ts @@ -37,6 +37,7 @@ export class FacilitatorManager { svmPrivateKey: process.env.FACILITATOR_SVM_PRIVATE_KEY, aptosPrivateKey: process.env.FACILITATOR_APTOS_PRIVATE_KEY, stellarPrivateKey: process.env.FACILITATOR_STELLAR_PRIVATE_KEY, + tvmPrivateKey: process.env.FACILITATOR_TVM_PRIVATE_KEY, networks, }); diff --git a/e2e/src/facilitators/generic-facilitator.ts b/e2e/src/facilitators/generic-facilitator.ts index 672f758a8a..6e521107ea 100644 --- a/e2e/src/facilitators/generic-facilitator.ts +++ b/e2e/src/facilitators/generic-facilitator.ts @@ -55,6 +55,7 @@ export interface FacilitatorConfig { svmPrivateKey?: string; aptosPrivateKey?: string; stellarPrivateKey?: string; + tvmPrivateKey?: string; networks: NetworkSet; } @@ -116,6 +117,7 @@ export class GenericFacilitatorProxy extends BaseProxy implements FacilitatorPro SVM_PRIVATE_KEY: config.svmPrivateKey || '', APTOS_PRIVATE_KEY: config.aptosPrivateKey || '', STELLAR_PRIVATE_KEY: config.stellarPrivateKey || '', + TVM_PRIVATE_KEY: config.tvmPrivateKey || '', // Network configs from NetworkSet EVM_NETWORK: config.networks.evm.caip2, @@ -126,6 +128,8 @@ export class GenericFacilitatorProxy extends BaseProxy implements FacilitatorPro APTOS_RPC_URL: config.networks.aptos.rpcUrl, STELLAR_NETWORK: config.networks.stellar.caip2, STELLAR_RPC_URL: config.networks.stellar.rpcUrl, + TVM_NETWORK: config.networks.tvm.caip2, + TONCENTER_BASE_URL: process.env.TONCENTER_BASE_URL || config.networks.tvm.rpcUrl, }; // Pass through any additional environment variables required by the facilitator diff --git a/e2e/src/networks/networks.ts b/e2e/src/networks/networks.ts index 5d8441a1b6..4ab01b007c 100644 --- a/e2e/src/networks/networks.ts +++ b/e2e/src/networks/networks.ts @@ -6,7 +6,7 @@ */ export type NetworkMode = 'testnet' | 'mainnet'; -export type ProtocolFamily = 'evm' | 'svm' | 'aptos' | 'stellar'; +export type ProtocolFamily = 'evm' | 'svm' | 'aptos' | 'stellar' | 'tvm'; export type NetworkConfig = { name: string; @@ -20,6 +20,7 @@ export type NetworkSet = { svm: NetworkConfig; aptos: NetworkConfig; stellar: NetworkConfig; + tvm: NetworkConfig; }; /** @@ -48,6 +49,11 @@ const NETWORK_SETS: Record = { caip2: 'stellar:testnet', rpcUrl: process.env.STELLAR_TESTNET_RPC_URL || 'https://soroban-testnet.stellar.org', }, + tvm: { + name: 'TON Testnet', + caip2: 'tvm:-3', + rpcUrl: process.env.TONCENTER_TESTNET_BASE_URL || 'https://testnet.toncenter.com', + }, }, mainnet: { evm: { @@ -71,6 +77,11 @@ const NETWORK_SETS: Record = { caip2: 'stellar:pubnet', rpcUrl: process.env.STELLAR_RPC_URL || 'https://mainnet.sorobanrpc.com', }, + tvm: { + name: 'TON Mainnet', + caip2: 'tvm:-239', + rpcUrl: process.env.TONCENTER_MAINNET_BASE_URL || 'https://toncenter.com', + }, }, }; @@ -78,7 +89,7 @@ const NETWORK_SETS: Record = { * Get the network set for a given mode * * @param mode - 'testnet' or 'mainnet' - * @returns NetworkSet containing EVM, SVM, and Aptos network configs + * @returns NetworkSet containing all protocol network configs */ export function getNetworkSet(mode: NetworkMode): NetworkSet { return NETWORK_SETS[mode]; @@ -88,7 +99,7 @@ export function getNetworkSet(mode: NetworkMode): NetworkSet { * Get network config for a protocol family in a given mode * * @param mode - 'testnet' or 'mainnet' - * @param protocolFamily - 'evm', 'svm', 'aptos', or 'stellar' + * @param protocolFamily - 'evm', 'svm', 'aptos', 'stellar', or 'tvm' * @returns NetworkConfig for the specified protocol */ export function getNetworkForProtocol( @@ -106,6 +117,6 @@ export function getNetworkForProtocol( */ export function getNetworkModeDescription(mode: NetworkMode): string { const set = NETWORK_SETS[mode]; - const networks = [set.evm.name, set.svm.name, set.aptos.name, set.stellar.name]; + const networks = [set.evm.name, set.svm.name, set.aptos.name, set.stellar.name, set.tvm.name]; return networks.join(' + '); } diff --git a/e2e/src/servers/generic-server.ts b/e2e/src/servers/generic-server.ts index f769dbed0d..39f6bbcca4 100644 --- a/e2e/src/servers/generic-server.ts +++ b/e2e/src/servers/generic-server.ts @@ -73,8 +73,8 @@ export class GenericServerProxy extends BaseProxy implements ServerProxy { verboseLog(` 📂 Server directory: ${this.directory}, isV1: ${isV1Server}`); // For legacy servers, translate CAIP-2 to v1 network names - let evmNetwork = config.networks.evm.caip2; - let svmNetwork = config.networks.svm.caip2; + let evmNetwork: string = config.networks.evm.caip2; + let svmNetwork: string = config.networks.svm.caip2; if (isV1Server) { evmNetwork = translateNetworkForV1(config.networks.evm.caip2); @@ -109,6 +109,10 @@ export class GenericServerProxy extends BaseProxy implements ServerProxy { STELLAR_RPC_URL: config.networks.stellar.rpcUrl, STELLAR_PAYEE_ADDRESS: config.stellarPayTo, + // TVM network config + TVM_NETWORK: config.networks.tvm.caip2, + TVM_PAYEE_ADDRESS: config.tvmPayTo, + // Facilitator FACILITATOR_URL: config.facilitatorUrl || '', MOCK_FACILITATOR_URL: config.mockFacilitatorUrl || '', diff --git a/e2e/src/types.ts b/e2e/src/types.ts index 841f200c39..dadd182bac 100644 --- a/e2e/src/types.ts +++ b/e2e/src/types.ts @@ -1,6 +1,6 @@ import type { NetworkSet } from './networks/networks'; -export type ProtocolFamily = 'evm' | 'svm' | 'aptos' | 'stellar'; +export type ProtocolFamily = 'evm' | 'svm' | 'aptos' | 'stellar' | 'tvm'; export type Transport = 'http' | 'mcp'; export type TransferMethod = 'eip3009' | 'permit2' | 'upto'; @@ -17,10 +17,13 @@ export interface ClientConfig { svmPrivateKey: string; aptosPrivateKey: string; stellarPrivateKey: string; + tvmPrivateKey: string; serverUrl: string; endpointPath: string; evmNetwork: string; evmRpcUrl: string; + tvmNetwork: string; + tvmRpcUrl: string; } export interface ServerConfig { @@ -29,6 +32,7 @@ export interface ServerConfig { svmPayTo: string; aptosPayTo: string; stellarPayTo: string; + tvmPayTo: string; networks: NetworkSet; facilitatorUrl?: string; mockFacilitatorUrl?: string; diff --git a/e2e/test.ts b/e2e/test.ts index 4349597506..39fa9a4aa4 100644 --- a/e2e/test.ts +++ b/e2e/test.ts @@ -385,19 +385,17 @@ async function runTest() { const serverSvmAddress = process.env.SERVER_SVM_ADDRESS; const serverAptosAddress = process.env.SERVER_APTOS_ADDRESS; const serverStellarAddress = process.env.SERVER_STELLAR_ADDRESS; + const serverTvmAddress = process.env.SERVER_TVM_ADDRESS; const clientEvmPrivateKey = process.env.CLIENT_EVM_PRIVATE_KEY; const clientSvmPrivateKey = process.env.CLIENT_SVM_PRIVATE_KEY; const clientAptosPrivateKey = process.env.CLIENT_APTOS_PRIVATE_KEY; const clientStellarPrivateKey = process.env.CLIENT_STELLAR_PRIVATE_KEY; + const clientTvmPrivateKey = process.env.CLIENT_TVM_PRIVATE_KEY; const facilitatorEvmPrivateKey = process.env.FACILITATOR_EVM_PRIVATE_KEY; const facilitatorSvmPrivateKey = process.env.FACILITATOR_SVM_PRIVATE_KEY; const facilitatorAptosPrivateKey = process.env.FACILITATOR_APTOS_PRIVATE_KEY; const facilitatorStellarPrivateKey = process.env.FACILITATOR_STELLAR_PRIVATE_KEY; - if (!serverEvmAddress || !serverSvmAddress || !clientEvmPrivateKey || !clientSvmPrivateKey || !facilitatorEvmPrivateKey || !facilitatorSvmPrivateKey) { - errorLog('❌ Missing required environment variables:'); - errorLog(' SERVER_EVM_ADDRESS, SERVER_SVM_ADDRESS, CLIENT_EVM_PRIVATE_KEY, CLIENT_SVM_PRIVATE_KEY, FACILITATOR_EVM_PRIVATE_KEY, and FACILITATOR_SVM_PRIVATE_KEY must be set'); - process.exit(1); - } + const facilitatorTvmPrivateKey = process.env.FACILITATOR_TVM_PRIVATE_KEY; // Discover all servers, clients, and facilitators (always include legacy) const discovery = new TestDiscovery('.', true); // Always discover legacy @@ -470,6 +468,7 @@ async function runTest() { log(` SVM: ${networks.svm.name} (${networks.svm.caip2})`); log(` APTOS: ${networks.aptos.name} (${networks.aptos.caip2})`); log(` STELLAR: ${networks.stellar.name} (${networks.stellar.caip2})`); + log(` TVM: ${networks.tvm.name} (${networks.tvm.caip2})`); if (networkMode === 'mainnet') { log('\n⚠️ WARNING: Running on MAINNET - real funds will be used!'); @@ -485,6 +484,34 @@ async function runTest() { return; } + const requiredEnvByFamily: Record> = { + evm: [ + ['SERVER_EVM_ADDRESS', serverEvmAddress], + ['CLIENT_EVM_PRIVATE_KEY', clientEvmPrivateKey], + ['FACILITATOR_EVM_PRIVATE_KEY', facilitatorEvmPrivateKey], + ], + svm: [ + ['SERVER_SVM_ADDRESS', serverSvmAddress], + ['CLIENT_SVM_PRIVATE_KEY', clientSvmPrivateKey], + ['FACILITATOR_SVM_PRIVATE_KEY', facilitatorSvmPrivateKey], + ], + aptos: [ + ['SERVER_APTOS_ADDRESS', serverAptosAddress], + ['CLIENT_APTOS_PRIVATE_KEY', clientAptosPrivateKey], + ['FACILITATOR_APTOS_PRIVATE_KEY', facilitatorAptosPrivateKey], + ], + stellar: [ + ['SERVER_STELLAR_ADDRESS', serverStellarAddress], + ['CLIENT_STELLAR_PRIVATE_KEY', clientStellarPrivateKey], + ['FACILITATOR_STELLAR_PRIVATE_KEY', facilitatorStellarPrivateKey], + ], + tvm: [ + ['SERVER_TVM_ADDRESS', serverTvmAddress], + ['CLIENT_TVM_PRIVATE_KEY', clientTvmPrivateKey], + ['FACILITATOR_TVM_PRIVATE_KEY', facilitatorTvmPrivateKey], + ], + }; + // Apply coverage-based minimization if --min flag is set if (parsedArgs.minimize) { filteredScenarios = minimizeScenarios(filteredScenarios); @@ -498,6 +525,22 @@ async function runTest() { log(`\n✅ ${filteredScenarios.length} scenarios selected`); } + const selectedProtocolFamilies = new Set(filteredScenarios.map(scenario => scenario.protocolFamily)); + const missingRequiredEnv = new Set(); + for (const family of selectedProtocolFamilies) { + for (const [name, value] of requiredEnvByFamily[family] || []) { + if (!value) { + missingRequiredEnv.add(name); + } + } + } + + if (missingRequiredEnv.size > 0) { + errorLog('❌ Missing required environment variables for selected protocol families:'); + Array.from(missingRequiredEnv).forEach(name => errorLog(` ${name}`)); + process.exit(1); + } + if (selectedExtensions && selectedExtensions.length > 0) { log(`🎁 Extensions enabled: ${selectedExtensions.join(', ')}`); } @@ -561,14 +604,17 @@ async function runTest() { 'SVM_PRIVATE_KEY', 'APTOS_PRIVATE_KEY', 'STELLAR_PRIVATE_KEY', + 'TVM_PRIVATE_KEY', 'EVM_NETWORK', 'SVM_NETWORK', 'APTOS_NETWORK', 'STELLAR_NETWORK', + 'TVM_NETWORK', 'EVM_RPC_URL', 'SVM_RPC_URL', 'APTOS_RPC_URL', 'STELLAR_RPC_URL', + 'TONCENTER_BASE_URL', ]); for (const [facilitatorName, facilitator] of uniqueFacilitators) { @@ -711,6 +757,7 @@ async function runTest() { SVM_NETWORK: networks.svm.caip2, APTOS_NETWORK: networks.aptos.caip2, STELLAR_NETWORK: networks.stellar.caip2, + TVM_NETWORK: networks.tvm.caip2, }, stdio: 'pipe', }, @@ -766,14 +813,17 @@ async function runTest() { const testName = `${scenario.client.name} → ${scenario.server.name} → ${scenario.endpoint.path}${facilitatorLabel}`; const clientConfig: ClientConfig = { - evmPrivateKey: clientEvmPrivateKey!, - svmPrivateKey: clientSvmPrivateKey!, + evmPrivateKey: clientEvmPrivateKey || '', + svmPrivateKey: clientSvmPrivateKey || '', aptosPrivateKey: clientAptosPrivateKey || '', stellarPrivateKey: clientStellarPrivateKey || '', + tvmPrivateKey: clientTvmPrivateKey || '', serverUrl: `http://localhost:${port}`, endpointPath: scenario.endpoint.path, evmNetwork: networks.evm.caip2, evmRpcUrl: networks.evm.rpcUrl, + tvmNetwork: networks.tvm.caip2, + tvmRpcUrl: networks.tvm.rpcUrl, }; try { @@ -855,10 +905,11 @@ async function runTest() { const serverConfig: ServerConfig = { port, - evmPayTo: serverEvmAddress!, - svmPayTo: serverSvmAddress!, + evmPayTo: serverEvmAddress || '', + svmPayTo: serverSvmAddress || '', aptosPayTo: facilitatorSupportsAptos ? (serverAptosAddress || '') : '', stellarPayTo: facilitatorSupportsStellar ? (serverStellarAddress || '') : '', + tvmPayTo: serverTvmAddress || '', networks, facilitatorUrl, mockFacilitatorUrl, diff --git a/examples/python/clients/advanced/.env-local b/examples/python/clients/advanced/.env-local index 8b6ba4c6ba..1904b1d9e5 100644 --- a/examples/python/clients/advanced/.env-local +++ b/examples/python/clients/advanced/.env-local @@ -1,4 +1,8 @@ EVM_PRIVATE_KEY= SVM_PRIVATE_KEY= +TVM_PRIVATE_KEY= +TVM_NETWORK=tvm:-3 +TONCENTER_API_KEY= +TONCENTER_BASE_URL=https://testnet.toncenter.com RESOURCE_SERVER_URL=http://localhost:4021 ENDPOINT_PATH=/weather diff --git a/examples/python/clients/advanced/README.md b/examples/python/clients/advanced/README.md index c905b152ce..8aee871c82 100644 --- a/examples/python/clients/advanced/README.md +++ b/examples/python/clients/advanced/README.md @@ -1,11 +1,14 @@ # Advanced Python Client Examples -This directory contains advanced x402 client examples demonstrating hooks, custom selectors, and builder patterns. +This directory contains advanced x402 client examples demonstrating hooks, custom selectors, and builder patterns across EVM, SVM, and TVM networks. ## Prerequisites - Python 3.11+ -- An EVM private key with testnet funds (e.g., Base Sepolia) +- At least one configured signer: + - EVM private key with testnet funds (e.g., Base Sepolia) + - SVM private key with Solana Devnet funds + - TVM private key with TON testnet funds and testnet USDT - A running x402 resource server (e.g., the FastAPI example server) ## Setup @@ -21,7 +24,7 @@ This directory contains advanced x402 client examples demonstrating hooks, custo ```bash cp .env-local .env - # Edit .env and add your private key + # Edit .env and add one or more signer credentials ``` 3. **Start a test server** (in another terminal): @@ -62,7 +65,7 @@ uv run python builder_pattern.py ### 0. All Networks (`all_networks.py`) -Demonstrates how to add all supported networks with optional chain configuration +Demonstrates how to add all supported networks with optional chain configuration, including TVM. ### 1. Hooks (`hooks.py`) @@ -110,6 +113,7 @@ Demonstrates network-specific scheme registration: advanced/ ├── .env-local # Environment template ├── README.md # This file +├── all_networks.py # Register EVM, SVM, and TVM schemes ├── pyproject.toml # Dependencies ├── index.py # CLI entry point ├── hooks.py # Lifecycle hooks example diff --git a/examples/python/clients/advanced/all_networks.py b/examples/python/clients/advanced/all_networks.py index fa4158d010..080f26c050 100644 --- a/examples/python/clients/advanced/all_networks.py +++ b/examples/python/clients/advanced/all_networks.py @@ -4,7 +4,7 @@ optional chain configuration via environment variables. New chain support should be added here in alphabetic order by network prefix -(e.g., "eip155" before "solana"). +(e.g., "eip155" before "solana" before "tvm"). """ import asyncio @@ -21,28 +21,38 @@ from x402.mechanisms.evm.exact.register import register_exact_evm_client from x402.mechanisms.svm import KeypairSigner from x402.mechanisms.svm.exact.register import register_exact_svm_client +from x402.mechanisms.tvm import ( + TVM_TESTNET, + TVM_MAINNET, + WalletV5R1Config, + WalletV5R1MnemonicSigner, +) +from x402.mechanisms.tvm.exact.register import register_exact_tvm_client # Load environment variables load_dotenv() -def validate_environment() -> tuple[str | None, str | None, str, str]: +def validate_environment() -> tuple[str | None, str | None, str | None, str, str]: """Validate required environment variables. Returns: - Tuple of (evm_private_key, svm_private_key, base_url, endpoint_path). + Tuple of (evm_private_key, svm_private_key, tvm_private_key, base_url, endpoint_path). Raises: SystemExit: If required environment variables are missing. """ evm_private_key = os.getenv("EVM_PRIVATE_KEY") svm_private_key = os.getenv("SVM_PRIVATE_KEY") + tvm_private_key = os.getenv("TVM_PRIVATE_KEY") base_url = os.getenv("RESOURCE_SERVER_URL") endpoint_path = os.getenv("ENDPOINT_PATH") - # Validate at least one private key is provided - if not evm_private_key and not svm_private_key: - print("❌ At least one of EVM_PRIVATE_KEY or SVM_PRIVATE_KEY is required") + # Validate at least one signer credential is provided + if not evm_private_key and not svm_private_key and not tvm_private_key: + print( + "❌ At least one of EVM_PRIVATE_KEY, SVM_PRIVATE_KEY, or TVM_PRIVATE_KEY is required" + ) print("Please copy .env-local to .env and fill in the values.") sys.exit(1) @@ -54,13 +64,21 @@ def validate_environment() -> tuple[str | None, str | None, str, str]: print("❌ ENDPOINT_PATH is required") sys.exit(1) - return evm_private_key, svm_private_key, base_url, endpoint_path + return ( + evm_private_key, + svm_private_key, + tvm_private_key, + base_url, + endpoint_path, + ) async def main() -> None: """Main entry point demonstrating httpx with x402 payments.""" # Validate environment - evm_private_key, svm_private_key, base_url, endpoint_path = validate_environment() + evm_private_key, svm_private_key, tvm_private_key, base_url, endpoint_path = ( + validate_environment() + ) # Create x402 client client = x402Client() @@ -77,6 +95,20 @@ async def main() -> None: register_exact_svm_client(client, svm_signer) print(f"Initialized SVM account: {svm_signer.address}") + # Register TVM payment scheme if private key provided + if tvm_private_key: + tvm_network = os.getenv("TVM_NETWORK", TVM_TESTNET) + if tvm_network not in {TVM_TESTNET, TVM_MAINNET}: + print(f"❌ Unsupported TVM network: {tvm_network}") + sys.exit(1) + + tvm_config = WalletV5R1Config.from_private_key(tvm_network, tvm_private_key) + tvm_config.api_key = os.getenv("TONCENTER_API_KEY") + tvm_config.base_url = os.getenv("TONCENTER_BASE_URL") + tvm_signer = WalletV5R1MnemonicSigner(tvm_config) + register_exact_tvm_client(client, tvm_signer) + print(f"Initialized TVM account: {tvm_signer.address}") + # Create HTTP client helper for payment response extraction http_client = x402HTTPClient(client) diff --git a/examples/python/clients/advanced/pyproject.toml b/examples/python/clients/advanced/pyproject.toml index 1d30abdddb..bc38f6938c 100644 --- a/examples/python/clients/advanced/pyproject.toml +++ b/examples/python/clients/advanced/pyproject.toml @@ -5,7 +5,7 @@ description = "Advanced x402 client examples demonstrating hooks, custom selecto readme = "README.md" requires-python = ">=3.11" dependencies = [ - "x402[evm,svm,httpx]", + "x402[evm,svm,tvm,httpx]", "python-dotenv>=1.0.0", ] diff --git a/examples/python/clients/advanced/uv.lock b/examples/python/clients/advanced/uv.lock index 0d9249b34f..e8f7e12d01 100644 --- a/examples/python/clients/advanced/uv.lock +++ b/examples/python/clients/advanced/uv.lock @@ -239,6 +239,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -1069,6 +1139,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pycryptodome" version = "3.23.0" @@ -1099,6 +1178,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, ] +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -1211,6 +1320,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -1220,6 +1364,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "pytoniq" +version = "0.1.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytoniq-core" }, + { name = "requests" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/b2/9991a953e4b766918a142fe111f71f12803c6acf65eb30f36eb85ed08f31/pytoniq-0.1.43.tar.gz", hash = "sha256:b4b1c8fed2f9d2f1b6f0ab4b3f1fc5503a0088630d8081f817807ff31e608606", size = 50463, upload-time = "2025-11-30T12:30:41.102Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/c1/b6e5c739839e0e12bde4563438acd55d39552ea63f6de123724b4b91ac64/pytoniq-0.1.43-py3-none-any.whl", hash = "sha256:922c1721124bf7214e0b7044fba2a439e006367778611c0ce813cbe5b00079d3", size = 56118, upload-time = "2025-11-30T12:30:39.65Z" }, +] + +[[package]] +name = "pytoniq-core" +version = "0.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bitarray" }, + { name = "pycryptodomex" }, + { name = "pynacl" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "x25519" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/2c/7afbb9003a3aa72ccfe69711433fe36d2493db2c4acf66dde32f7b55799b/pytoniq_core-0.1.46.tar.gz", hash = "sha256:c8e3cf9ccb1852780a725cd51ba7a66a28122eb39c8b9bb97dcdc5bd02c24734", size = 101236, upload-time = "2025-11-28T10:23:21.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/0e/e27cf7ce1bebb47fb95e1d6deae5c91c6ffcb7851f156990e57079cbe8db/pytoniq_core-0.1.46-py3-none-any.whl", hash = "sha256:0a284c8b68f9fed9d54e4dad871238d844339183bf985a614796360e36e1b95e", size = 91400, upload-time = "2025-11-28T10:23:20.95Z" }, +] + [[package]] name = "pyunormalize" version = "17.0.0" @@ -1367,6 +1542,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/fb/e4c0ced9893b84ac95b7181d69a9786ce5879aeb3bbbcbba80a164f85d6a/rlp-4.1.0-py3-none-any.whl", hash = "sha256:8eca394c579bad34ee0b937aecb96a57052ff3716e19c7a578883e767bc5da6f", size = 19973, upload-time = "2025-02-04T22:05:57.05Z" }, ] +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + [[package]] name = "solana" version = "0.36.11" @@ -1521,9 +1705,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "x25519" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/b6/fca895aff0800cdf941f856df0685a5513094163664b904576e3e3ef1460/x25519-0.0.2.tar.gz", hash = "sha256:ed91d0aba7f4f4959ed8b37118c11d94f56d36c38bb6f2e6c20d0438d75b1556", size = 4833, upload-time = "2021-10-24T15:18:38.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/d1/66c637eb8e7a9601675bf7f04bb9a3015358a0f49e4c31d29a2b9a9d72d9/x25519-0.0.2-py3-none-any.whl", hash = "sha256:5c0833260a548bea9137a5a1b5c30334b751a59d148a62832df0c9e7b919ce99", size = 4907, upload-time = "2021-10-24T15:18:36.727Z" }, +] + [[package]] name = "x402" -version = "2.1.0" +version = "2.5.0" source = { editable = "../../../../python/x402" } dependencies = [ { name = "nest-asyncio" }, @@ -1546,38 +1739,48 @@ svm = [ { name = "solana" }, { name = "solders" }, ] +tvm = [ + { name = "httpx" }, + { name = "pynacl" }, + { name = "pytoniq" }, + { name = "pytoniq-core" }, +] [package.metadata] requires-dist = [ { name = "eth-abi", marker = "extra == 'evm'", specifier = ">=5.0.0" }, - { name = "eth-account", marker = "extra == 'evm'", specifier = ">=0.12.0" }, + { name = "eth-account", marker = "extra == 'evm'", specifier = ">=0.13.0" }, { name = "eth-keys", marker = "extra == 'evm'", specifier = ">=0.5.0" }, { name = "eth-utils", marker = "extra == 'evm'", specifier = ">=4.0.0" }, { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.115.0" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0.0" }, { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.28.1" }, + { name = "httpx", marker = "extra == 'tvm'", specifier = ">=0.28.1" }, { name = "jsonschema", marker = "extra == 'extensions'", specifier = ">=4.0.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pynacl", marker = "extra == 'tvm'", specifier = ">=1.5.0" }, + { name = "pytoniq", marker = "extra == 'tvm'", specifier = ">=0.1.39" }, + { name = "pytoniq-core", marker = "extra == 'tvm'", specifier = ">=0.1.36" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.31.0" }, { name = "solana", marker = "extra == 'svm'", specifier = ">=0.36.0" }, { name = "solders", marker = "extra == 'svm'", specifier = ">=0.27.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, - { name = "x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "x402", extras = ["evm", "svm", "tvm"], marker = "extra == 'mechanisms'" }, { name = "x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions"], marker = "extra == 'all'" }, ] -provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] +provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] [package.metadata.requires-dev] dev = [ { name = "black", specifier = ">=23.0.0" }, { name = "eth-abi", specifier = ">=5.0.0" }, - { name = "eth-account", specifier = ">=0.12.0" }, + { name = "eth-account", specifier = ">=0.13.0" }, { name = "eth-keys", specifier = ">=0.5.0" }, { name = "eth-utils", specifier = ">=4.0.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.0" }, @@ -1587,8 +1790,11 @@ dev = [ { name = "mcp", specifier = ">=1.26.0" }, { name = "mypy", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "pynacl", specifier = ">=1.5.0" }, { name = "pytest", specifier = ">=7.0.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytoniq", specifier = ">=0.1.39" }, + { name = "pytoniq-core", specifier = ">=0.1.36" }, { name = "requests", specifier = ">=2.31.0" }, { name = "ruff", specifier = ">=0.1.0" }, { name = "solana", specifier = ">=0.36.0" }, @@ -1604,13 +1810,13 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "python-dotenv" }, - { name = "x402", extra = ["evm", "httpx", "svm"] }, + { name = "x402", extra = ["evm", "httpx", "svm", "tvm"] }, ] [package.metadata] requires-dist = [ { name = "python-dotenv", specifier = ">=1.0.0" }, - { name = "x402", extras = ["evm", "svm", "httpx"], editable = "../../../../python/x402" }, + { name = "x402", extras = ["evm", "svm", "tvm", "httpx"], editable = "../../../../python/x402" }, ] [[package]] diff --git a/examples/python/facilitator/.env-local b/examples/python/facilitator/.env-local index 553e40d27d..69ff9ccf5c 100644 --- a/examples/python/facilitator/.env-local +++ b/examples/python/facilitator/.env-local @@ -1,3 +1,6 @@ PORT= EVM_PRIVATE_KEY= SVM_PRIVATE_KEY= +TVM_PRIVATE_KEY= +TONCENTER_API_KEY= +TONCENTER_BASE_URL= diff --git a/examples/python/facilitator/advanced/.env-local b/examples/python/facilitator/advanced/.env-local new file mode 100644 index 0000000000..a0fe6af403 --- /dev/null +++ b/examples/python/facilitator/advanced/.env-local @@ -0,0 +1,7 @@ +PORT=4022 +EVM_PRIVATE_KEY= +SVM_PRIVATE_KEY= +TVM_PRIVATE_KEY= +TVM_NETWORK=tvm:-3 +TONCENTER_API_KEY= +TONCENTER_BASE_URL= diff --git a/examples/python/facilitator/advanced/README.md b/examples/python/facilitator/advanced/README.md index c2421cbc97..6bcac7b425 100644 --- a/examples/python/facilitator/advanced/README.md +++ b/examples/python/facilitator/advanced/README.md @@ -1,13 +1,15 @@ # x402 Advanced Facilitator Examples (Python) -FastAPI facilitator service demonstrating advanced x402 patterns including all-networks support, bazaar discovery, and lifecycle hooks. +FastAPI facilitator service demonstrating advanced x402 patterns including all-networks support, bazaar discovery, and lifecycle hooks across EVM, SVM, and TVM. ## Prerequisites - Python 3.10+ - uv (install via [docs.astral.sh/uv](https://docs.astral.sh/uv/getting-started/installation/)) -- EVM private key with Base Sepolia ETH for transaction fees -- SVM private key with Solana Devnet SOL for transaction fees +- Any configured payment signer set: + - EVM private key with Base Sepolia ETH for transaction fees + - SVM private key with Solana Devnet SOL for transaction fees + - TVM private key with TON testnet funds ## Setup @@ -21,6 +23,10 @@ cp .env-local .env - `EVM_PRIVATE_KEY` - Ethereum private key - `SVM_PRIVATE_KEY` - Solana private key +- `TVM_PRIVATE_KEY` - TVM private key for the facilitator wallet +- `TVM_NETWORK` - TVM CAIP-2 network (optional, defaults to `tvm:-3`) +- `TONCENTER_API_KEY` - Toncenter API key for TVM testnet (optional) +- `TONCENTER_BASE_URL` - Custom Toncenter base URL (optional) - `PORT` - Server port (optional, defaults to 4022) 3. Install dependencies: @@ -38,10 +44,10 @@ uv run python bazaar.py # Bazaar discovery extension ## Available Examples -| Example | Command | Description | -| --- | --- | --- | +| Example | Command | Description | +| -------------- | ------------------------------- | -------------------------------------------------------- | | `all_networks` | `uv run python all_networks.py` | All supported networks with optional chain configuration | -| `bazaar` | `uv run python bazaar.py` | Bazaar discovery extension for cataloging x402 resources | +| `bazaar` | `uv run python bazaar.py` | Bazaar discovery extension for cataloging x402 resources | ## API Endpoints @@ -65,3 +71,5 @@ Networks use [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/cai - `eip155:8453` — Base Mainnet - `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` — Solana Devnet - `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` — Solana Mainnet +- `tvm:-3` — TON Testnet +- `tvm:-239` — TON Mainnet diff --git a/examples/python/facilitator/advanced/all_networks.py b/examples/python/facilitator/advanced/all_networks.py index 2cc72f2656..410befd0ff 100644 --- a/examples/python/facilitator/advanced/all_networks.py +++ b/examples/python/facilitator/advanced/all_networks.py @@ -4,7 +4,7 @@ optional chain configuration via environment variables. New chain support should be added here in alphabetic order by network prefix -(e.g., "eip155" before "solana"). +(e.g., "eip155" before "solana" before "tvm"). """ import os @@ -20,6 +20,8 @@ from x402.mechanisms.evm.exact.facilitator import ExactEvmScheme, ExactEvmSchemeConfig from x402.mechanisms.svm import FacilitatorKeypairSigner from x402.mechanisms.svm.exact.facilitator import ExactSvmScheme +from x402.mechanisms.tvm import TVM_TESTNET, FacilitatorHighloadV3Signer, HighloadV3Config +from x402.mechanisms.tvm.exact import register_exact_tvm_facilitator # Load environment variables load_dotenv() @@ -30,19 +32,22 @@ # Configuration - optional per network evm_private_key = os.environ.get("EVM_PRIVATE_KEY") svm_private_key = os.environ.get("SVM_PRIVATE_KEY") +tvm_private_key = os.environ.get("TVM_PRIVATE_KEY") # Validate at least one private key is provided -if not evm_private_key and not svm_private_key: - print("❌ At least one of EVM_PRIVATE_KEY or SVM_PRIVATE_KEY is required") +if not evm_private_key and not svm_private_key and not tvm_private_key: + print("❌ At least one of EVM_PRIVATE_KEY, SVM_PRIVATE_KEY, or TVM_PRIVATE_KEY is required") sys.exit(1) # Network configuration -EVM_NETWORK = "eip155:84532" # Base Sepolia -SVM_NETWORK = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" # Solana Devnet +EVM_NETWORK = os.environ.get("EVM_NETWORK", "eip155:84532") # Base Sepolia +SVM_NETWORK = os.environ.get("SVM_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1") +TVM_NETWORK = os.environ.get("TVM_NETWORK", TVM_TESTNET) # Initialize signers based on available keys evm_signer = None svm_signer = None +tvm_signer = None if evm_private_key: evm_signer = FacilitatorWeb3Signer( @@ -56,6 +61,13 @@ svm_signer = FacilitatorKeypairSigner(svm_keypair) print(f"SVM Facilitator account: {svm_signer.get_addresses()[0]}") +if tvm_private_key: + tvm_config = HighloadV3Config.from_private_key(tvm_private_key) + tvm_config.api_key = os.environ.get("TONCENTER_API_KEY") + tvm_config.toncenter_base_url = os.environ.get("TONCENTER_BASE_URL") + tvm_signer = FacilitatorHighloadV3Signer({TVM_NETWORK: tvm_config}) + print(f"TVM Facilitator account: {tvm_signer.get_addresses()[0]}") + # Async hook functions for the facilitator async def before_verify_hook(ctx): @@ -100,6 +112,12 @@ async def settle_failure_hook(ctx): if svm_signer: facilitator.register([SVM_NETWORK], ExactSvmScheme(svm_signer)) +if tvm_signer: + register_exact_tvm_facilitator( + facilitator, + tvm_signer, + networks=TVM_NETWORK, + ) # Pydantic models for request/response @@ -120,7 +138,7 @@ class SettleRequest(BaseModel): # Initialize FastAPI app app = FastAPI( title="All Networks Facilitator", - description="Verifies and settles x402 payments on-chain with optional EVM/SVM support", + description="Verifies and settles x402 payments on-chain with optional EVM/SVM/TVM support", version="2.0.0", ) diff --git a/examples/python/facilitator/advanced/pyproject.toml b/examples/python/facilitator/advanced/pyproject.toml index 3452fba5aa..fb8ef7c776 100644 --- a/examples/python/facilitator/advanced/pyproject.toml +++ b/examples/python/facilitator/advanced/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.10" keywords = ["x402", "payment", "protocol", "facilitator", "402"] dependencies = [ - "x402[fastapi,evm,svm,extensions]", + "x402[fastapi,evm,svm,tvm,extensions]", "python-dotenv>=1.2.1", "uvicorn[standard]>=0.40.0", ] @@ -55,4 +55,3 @@ warn_unused_configs = true [tool.pytest.ini_options] testpaths = ["tests"] - diff --git a/examples/python/facilitator/advanced/uv.lock b/examples/python/facilitator/advanced/uv.lock index a2cd7d69aa..5fe4421980 100644 --- a/examples/python/facilitator/advanced/uv.lock +++ b/examples/python/facilitator/advanced/uv.lock @@ -342,6 +342,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -1883,6 +1965,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pycryptodome" version = "3.23.0" @@ -1918,6 +2009,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, ] +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695, upload-time = "2025-05-17T17:23:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772, upload-time = "2025-05-17T17:23:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083, upload-time = "2025-05-17T17:23:21.867Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056, upload-time = "2025-05-17T17:23:24.031Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478, upload-time = "2025-05-17T17:23:26.066Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -2092,6 +2218,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -2151,6 +2312,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, ] +[[package]] +name = "pytoniq" +version = "0.1.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytoniq-core" }, + { name = "requests" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/b2/9991a953e4b766918a142fe111f71f12803c6acf65eb30f36eb85ed08f31/pytoniq-0.1.43.tar.gz", hash = "sha256:b4b1c8fed2f9d2f1b6f0ab4b3f1fc5503a0088630d8081f817807ff31e608606", size = 50463, upload-time = "2025-11-30T12:30:41.102Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/c1/b6e5c739839e0e12bde4563438acd55d39552ea63f6de123724b4b91ac64/pytoniq-0.1.43-py3-none-any.whl", hash = "sha256:922c1721124bf7214e0b7044fba2a439e006367778611c0ce813cbe5b00079d3", size = 56118, upload-time = "2025-11-30T12:30:39.65Z" }, +] + +[[package]] +name = "pytoniq-core" +version = "0.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bitarray" }, + { name = "pycryptodomex" }, + { name = "pynacl" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "x25519" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/2c/7afbb9003a3aa72ccfe69711433fe36d2493db2c4acf66dde32f7b55799b/pytoniq_core-0.1.46.tar.gz", hash = "sha256:c8e3cf9ccb1852780a725cd51ba7a66a28122eb39c8b9bb97dcdc5bd02c24734", size = 101236, upload-time = "2025-11-28T10:23:21.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/0e/e27cf7ce1bebb47fb95e1d6deae5c91c6ffcb7851f156990e57079cbe8db/pytoniq_core-0.1.46-py3-none-any.whl", hash = "sha256:0a284c8b68f9fed9d54e4dad871238d844339183bf985a614796360e36e1b95e", size = 91400, upload-time = "2025-11-28T10:23:20.95Z" }, +] + [[package]] name = "pyunormalize" version = "17.0.0" @@ -2703,6 +2895,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/43/1c586f9f413765201234541857cb82fda076f4b0f7bad4a0ec248da39cf3/sentry_sdk-2.49.0-py2.py3-none-any.whl", hash = "sha256:6ea78499133874445a20fe9c826c9e960070abeb7ae0cdf930314ab16bb97aa0", size = 415693, upload-time = "2026-01-08T09:56:21.872Z" }, ] +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -3137,9 +3338,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "x25519" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/b6/fca895aff0800cdf941f856df0685a5513094163664b904576e3e3ef1460/x25519-0.0.2.tar.gz", hash = "sha256:ed91d0aba7f4f4959ed8b37118c11d94f56d36c38bb6f2e6c20d0438d75b1556", size = 4833, upload-time = "2021-10-24T15:18:38.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/d1/66c637eb8e7a9601675bf7f04bb9a3015358a0f49e4c31d29a2b9a9d72d9/x25519-0.0.2-py3-none-any.whl", hash = "sha256:5c0833260a548bea9137a5a1b5c30334b751a59d148a62832df0c9e7b919ce99", size = 4907, upload-time = "2021-10-24T15:18:36.727Z" }, +] + [[package]] name = "x402" -version = "2.1.0" +version = "2.5.0" source = { editable = "../../../../python/x402" } dependencies = [ { name = "nest-asyncio" }, @@ -3166,38 +3376,48 @@ svm = [ { name = "solana" }, { name = "solders" }, ] +tvm = [ + { name = "httpx" }, + { name = "pynacl" }, + { name = "pytoniq" }, + { name = "pytoniq-core" }, +] [package.metadata] requires-dist = [ { name = "eth-abi", marker = "extra == 'evm'", specifier = ">=5.0.0" }, - { name = "eth-account", marker = "extra == 'evm'", specifier = ">=0.12.0" }, + { name = "eth-account", marker = "extra == 'evm'", specifier = ">=0.13.0" }, { name = "eth-keys", marker = "extra == 'evm'", specifier = ">=0.5.0" }, { name = "eth-utils", marker = "extra == 'evm'", specifier = ">=4.0.0" }, { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.115.0" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0.0" }, { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.28.1" }, + { name = "httpx", marker = "extra == 'tvm'", specifier = ">=0.28.1" }, { name = "jsonschema", marker = "extra == 'extensions'", specifier = ">=4.0.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pynacl", marker = "extra == 'tvm'", specifier = ">=1.5.0" }, + { name = "pytoniq", marker = "extra == 'tvm'", specifier = ">=0.1.39" }, + { name = "pytoniq-core", marker = "extra == 'tvm'", specifier = ">=0.1.36" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.31.0" }, { name = "solana", marker = "extra == 'svm'", specifier = ">=0.36.0" }, { name = "solders", marker = "extra == 'svm'", specifier = ">=0.27.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, - { name = "x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "x402", extras = ["evm", "svm", "tvm"], marker = "extra == 'mechanisms'" }, { name = "x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions"], marker = "extra == 'all'" }, ] -provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] +provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] [package.metadata.requires-dev] dev = [ { name = "black", specifier = ">=23.0.0" }, { name = "eth-abi", specifier = ">=5.0.0" }, - { name = "eth-account", specifier = ">=0.12.0" }, + { name = "eth-account", specifier = ">=0.13.0" }, { name = "eth-keys", specifier = ">=0.5.0" }, { name = "eth-utils", specifier = ">=4.0.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.0" }, @@ -3207,8 +3427,11 @@ dev = [ { name = "mcp", specifier = ">=1.26.0" }, { name = "mypy", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "pynacl", specifier = ">=1.5.0" }, { name = "pytest", specifier = ">=7.0.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytoniq", specifier = ">=0.1.39" }, + { name = "pytoniq-core", specifier = ">=0.1.36" }, { name = "requests", specifier = ">=2.31.0" }, { name = "ruff", specifier = ">=0.1.0" }, { name = "solana", specifier = ">=0.36.0" }, @@ -3225,7 +3448,7 @@ source = { virtual = "." } dependencies = [ { name = "python-dotenv" }, { name = "uvicorn", extra = ["standard"] }, - { name = "x402", extra = ["evm", "extensions", "fastapi", "svm"] }, + { name = "x402", extra = ["evm", "extensions", "fastapi", "svm", "tvm"] }, ] [package.dev-dependencies] @@ -3241,7 +3464,7 @@ dev = [ requires-dist = [ { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" }, - { name = "x402", extras = ["fastapi", "evm", "svm", "extensions"], editable = "../../../../python/x402" }, + { name = "x402", extras = ["fastapi", "evm", "svm", "tvm", "extensions"], editable = "../../../../python/x402" }, ] [package.metadata.requires-dev] diff --git a/examples/python/servers/advanced/.env-local b/examples/python/servers/advanced/.env-local new file mode 100644 index 0000000000..c9c0c0fbe8 --- /dev/null +++ b/examples/python/servers/advanced/.env-local @@ -0,0 +1,6 @@ +PORT=4021 +EVM_ADDRESS= +SVM_ADDRESS= +TVM_ADDRESS= +TVM_NETWORK=tvm:-3 +FACILITATOR_URL=http://localhost:4022 diff --git a/examples/python/servers/advanced/README.md b/examples/python/servers/advanced/README.md index 497694b743..2a0f4afe2f 100644 --- a/examples/python/servers/advanced/README.md +++ b/examples/python/servers/advanced/README.md @@ -1,6 +1,6 @@ # x402 FastAPI Advanced Example -FastAPI server demonstrating advanced x402 patterns including dynamic pricing, payment routing, lifecycle hooks and API discoverability. +FastAPI server demonstrating advanced x402 patterns including dynamic pricing, payment routing, lifecycle hooks and API discoverability across EVM, SVM, and TVM. ```python from fastapi import FastAPI @@ -10,18 +10,21 @@ from x402.http.types import RouteConfig from x402.server import x402ResourceServer from x402.mechanisms.evm.exact import ExactEvmServerScheme from x402.mechanisms.svm.exact import ExactSvmServerScheme +from x402.mechanisms.tvm.exact import ExactTvmServerScheme app = FastAPI() server = x402ResourceServer(HTTPFacilitatorClient(FacilitatorConfig(url=facilitator_url))) server.register("eip155:84532", ExactEvmServerScheme()) server.register("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", ExactSvmServerScheme()) +server.register("tvm:-3", ExactTvmServerScheme()) routes = { "GET /weather": RouteConfig( accepts=[ PaymentOption(scheme="exact", price="$0.01", network="eip155:84532", pay_to=evm_address), PaymentOption(scheme="exact", price="$0.01", network="solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", pay_to=svm_address), + PaymentOption(scheme="exact", price="$0.001", network="tvm:-3", pay_to=tvm_address), ] ), } @@ -36,8 +39,10 @@ async def get_weather(): - Python 3.10+ - uv (install via [docs.astral.sh/uv](https://docs.astral.sh/uv/getting-started/installation/)) -- Valid EVM address for receiving payments (Base Sepolia) -- Valid SVM address for receiving payments (Solana Devnet) +- Optional payment addresses for one or more networks: + - EVM address for Base Sepolia + - SVM address for Solana Devnet + - TVM address for TON testnet/mainnet - URL of a facilitator supporting the desired payment network, see [facilitator list](https://www.x402.org/ecosystem?category=facilitators) ## Setup @@ -52,6 +57,8 @@ cp .env-local .env - `EVM_ADDRESS` - Ethereum address to receive payments (Base Sepolia) - `SVM_ADDRESS` - Solana address to receive payments (Solana Devnet) +- `TVM_ADDRESS` - TON wallet address to receive TVM payments +- `TVM_NETWORK` - TVM CAIP-2 network (optional, defaults to `tvm:-3`) - `FACILITATOR_URL` - Facilitator endpoint URL (optional, defaults to production) 3. Install dependencies: @@ -156,6 +163,13 @@ routes = { network="solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", pay_to=SVM_ADDRESS, ), + # TVM payment option + PaymentOption( + scheme="exact", + price="$0.10", + network="tvm:-3", + pay_to=TVM_ADDRESS, + ), ] ), } @@ -193,6 +207,10 @@ Network identifiers use [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/mai - `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` — Solana Devnet - `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` — Solana Mainnet +**TVM Networks:** +- `tvm:-3` — TON Testnet +- `tvm:-239` — TON Mainnet + ## Advanced Features ### Paywall (Browser Payment UI) diff --git a/examples/python/servers/advanced/all_networks.py b/examples/python/servers/advanced/all_networks.py index a5f21c4c10..ab56bf6bc4 100644 --- a/examples/python/servers/advanced/all_networks.py +++ b/examples/python/servers/advanced/all_networks.py @@ -4,7 +4,7 @@ optional chain configuration via environment variables. New chain support should be added here in alphabetic order by network prefix -(e.g., "eip155" before "solana"). +(e.g., "eip155" before "solana" before "tvm"). """ import os @@ -19,6 +19,8 @@ from x402.http.types import RouteConfig from x402.mechanisms.evm.exact import ExactEvmServerScheme from x402.mechanisms.svm.exact import ExactSvmServerScheme +from x402.mechanisms.tvm import TVM_TESTNET +from x402.mechanisms.tvm.exact import ExactTvmServerScheme from x402.schemas import Network from x402.server import x402ResourceServer @@ -27,15 +29,17 @@ # Configuration - optional per network EVM_ADDRESS = os.getenv("EVM_ADDRESS") SVM_ADDRESS = os.getenv("SVM_ADDRESS") +TVM_ADDRESS = os.getenv("TVM_ADDRESS") # Validate at least one address is provided -if not EVM_ADDRESS and not SVM_ADDRESS: - print("❌ At least one of EVM_ADDRESS or SVM_ADDRESS is required") +if not EVM_ADDRESS and not SVM_ADDRESS and not TVM_ADDRESS: + print("❌ At least one of EVM_ADDRESS, SVM_ADDRESS, or TVM_ADDRESS is required") sys.exit(1) # Network configuration EVM_NETWORK: Network = "eip155:84532" # Base Sepolia SVM_NETWORK: Network = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" # Solana Devnet +TVM_NETWORK: Network = os.getenv("TVM_NETWORK", TVM_TESTNET) # TON testnet by default FACILITATOR_URL = os.getenv("FACILITATOR_URL", "https://x402.org/facilitator") @@ -52,7 +56,7 @@ class WeatherResponse(BaseModel): # App app = FastAPI( title="All Networks Server", - description="x402 server supporting both EVM and SVM networks", + description="x402 server supporting EVM, SVM, and TVM networks", version="2.0.0", ) @@ -77,6 +81,15 @@ class WeatherResponse(BaseModel): network=SVM_NETWORK, ) ) +if TVM_ADDRESS: + accepts.append( + PaymentOption( + scheme="exact", + pay_to=TVM_ADDRESS, + price="$0.001", + network=TVM_NETWORK, + ) + ) # x402 Middleware facilitator = HTTPFacilitatorClient(FacilitatorConfig(url=FACILITATOR_URL)) @@ -87,6 +100,8 @@ class WeatherResponse(BaseModel): server.register(EVM_NETWORK, ExactEvmServerScheme()) if SVM_ADDRESS: server.register(SVM_NETWORK, ExactSvmServerScheme()) +if TVM_ADDRESS: + server.register(TVM_NETWORK, ExactTvmServerScheme()) routes = { "GET /weather": RouteConfig( @@ -118,6 +133,8 @@ async def get_weather() -> WeatherResponse: print(f" EVM: {EVM_ADDRESS} on {EVM_NETWORK}") if SVM_ADDRESS: print(f" SVM: {SVM_ADDRESS} on {SVM_NETWORK}") + if TVM_ADDRESS: + print(f" TVM: {TVM_ADDRESS} on {TVM_NETWORK}") print(f" Facilitator: {FACILITATOR_URL}") print() uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/examples/python/servers/advanced/pyproject.toml b/examples/python/servers/advanced/pyproject.toml index f42260e728..0075386eeb 100644 --- a/examples/python/servers/advanced/pyproject.toml +++ b/examples/python/servers/advanced/pyproject.toml @@ -9,7 +9,7 @@ authors = [ requires-python = ">=3.10" keywords = ["x402", "payment", "protocol", "http", "402"] dependencies = [ - "x402[fastapi,evm,svm,extensions]", + "x402[fastapi,evm,svm,tvm,extensions]", "python-dotenv>=1.2.1", "uvicorn[standard]>=0.40.0", ] diff --git a/examples/python/servers/advanced/uv.lock b/examples/python/servers/advanced/uv.lock index 69ec02187d..b829668a35 100644 --- a/examples/python/servers/advanced/uv.lock +++ b/examples/python/servers/advanced/uv.lock @@ -342,6 +342,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -1883,6 +1965,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pycryptodome" version = "3.23.0" @@ -1918,6 +2009,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, ] +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695, upload-time = "2025-05-17T17:23:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772, upload-time = "2025-05-17T17:23:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083, upload-time = "2025-05-17T17:23:21.867Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056, upload-time = "2025-05-17T17:23:24.031Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478, upload-time = "2025-05-17T17:23:26.066Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -2092,6 +2218,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -2151,6 +2312,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, ] +[[package]] +name = "pytoniq" +version = "0.1.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytoniq-core" }, + { name = "requests" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/b2/9991a953e4b766918a142fe111f71f12803c6acf65eb30f36eb85ed08f31/pytoniq-0.1.43.tar.gz", hash = "sha256:b4b1c8fed2f9d2f1b6f0ab4b3f1fc5503a0088630d8081f817807ff31e608606", size = 50463, upload-time = "2025-11-30T12:30:41.102Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/c1/b6e5c739839e0e12bde4563438acd55d39552ea63f6de123724b4b91ac64/pytoniq-0.1.43-py3-none-any.whl", hash = "sha256:922c1721124bf7214e0b7044fba2a439e006367778611c0ce813cbe5b00079d3", size = 56118, upload-time = "2025-11-30T12:30:39.65Z" }, +] + +[[package]] +name = "pytoniq-core" +version = "0.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bitarray" }, + { name = "pycryptodomex" }, + { name = "pynacl" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "x25519" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/2c/7afbb9003a3aa72ccfe69711433fe36d2493db2c4acf66dde32f7b55799b/pytoniq_core-0.1.46.tar.gz", hash = "sha256:c8e3cf9ccb1852780a725cd51ba7a66a28122eb39c8b9bb97dcdc5bd02c24734", size = 101236, upload-time = "2025-11-28T10:23:21.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/0e/e27cf7ce1bebb47fb95e1d6deae5c91c6ffcb7851f156990e57079cbe8db/pytoniq_core-0.1.46-py3-none-any.whl", hash = "sha256:0a284c8b68f9fed9d54e4dad871238d844339183bf985a614796360e36e1b95e", size = 91400, upload-time = "2025-11-28T10:23:20.95Z" }, +] + [[package]] name = "pyunormalize" version = "17.0.0" @@ -2703,6 +2895,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/43/1c586f9f413765201234541857cb82fda076f4b0f7bad4a0ec248da39cf3/sentry_sdk-2.49.0-py2.py3-none-any.whl", hash = "sha256:6ea78499133874445a20fe9c826c9e960070abeb7ae0cdf930314ab16bb97aa0", size = 415693, upload-time = "2026-01-08T09:56:21.872Z" }, ] +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -3137,9 +3338,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "x25519" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/b6/fca895aff0800cdf941f856df0685a5513094163664b904576e3e3ef1460/x25519-0.0.2.tar.gz", hash = "sha256:ed91d0aba7f4f4959ed8b37118c11d94f56d36c38bb6f2e6c20d0438d75b1556", size = 4833, upload-time = "2021-10-24T15:18:38.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/d1/66c637eb8e7a9601675bf7f04bb9a3015358a0f49e4c31d29a2b9a9d72d9/x25519-0.0.2-py3-none-any.whl", hash = "sha256:5c0833260a548bea9137a5a1b5c30334b751a59d148a62832df0c9e7b919ce99", size = 4907, upload-time = "2021-10-24T15:18:36.727Z" }, +] + [[package]] name = "x402" -version = "2.1.0" +version = "2.5.0" source = { editable = "../../../../python/x402" } dependencies = [ { name = "nest-asyncio" }, @@ -3166,38 +3376,48 @@ svm = [ { name = "solana" }, { name = "solders" }, ] +tvm = [ + { name = "httpx" }, + { name = "pynacl" }, + { name = "pytoniq" }, + { name = "pytoniq-core" }, +] [package.metadata] requires-dist = [ { name = "eth-abi", marker = "extra == 'evm'", specifier = ">=5.0.0" }, - { name = "eth-account", marker = "extra == 'evm'", specifier = ">=0.12.0" }, + { name = "eth-account", marker = "extra == 'evm'", specifier = ">=0.13.0" }, { name = "eth-keys", marker = "extra == 'evm'", specifier = ">=0.5.0" }, { name = "eth-utils", marker = "extra == 'evm'", specifier = ">=4.0.0" }, { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.115.0" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0.0" }, { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.28.1" }, + { name = "httpx", marker = "extra == 'tvm'", specifier = ">=0.28.1" }, { name = "jsonschema", marker = "extra == 'extensions'", specifier = ">=4.0.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pynacl", marker = "extra == 'tvm'", specifier = ">=1.5.0" }, + { name = "pytoniq", marker = "extra == 'tvm'", specifier = ">=0.1.39" }, + { name = "pytoniq-core", marker = "extra == 'tvm'", specifier = ">=0.1.36" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.31.0" }, { name = "solana", marker = "extra == 'svm'", specifier = ">=0.36.0" }, { name = "solders", marker = "extra == 'svm'", specifier = ">=0.27.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, - { name = "x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "x402", extras = ["evm", "svm", "tvm"], marker = "extra == 'mechanisms'" }, { name = "x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions"], marker = "extra == 'all'" }, ] -provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] +provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] [package.metadata.requires-dev] dev = [ { name = "black", specifier = ">=23.0.0" }, { name = "eth-abi", specifier = ">=5.0.0" }, - { name = "eth-account", specifier = ">=0.12.0" }, + { name = "eth-account", specifier = ">=0.13.0" }, { name = "eth-keys", specifier = ">=0.5.0" }, { name = "eth-utils", specifier = ">=4.0.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.0" }, @@ -3207,8 +3427,11 @@ dev = [ { name = "mcp", specifier = ">=1.26.0" }, { name = "mypy", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "pynacl", specifier = ">=1.5.0" }, { name = "pytest", specifier = ">=7.0.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytoniq", specifier = ">=0.1.39" }, + { name = "pytoniq-core", specifier = ">=0.1.36" }, { name = "requests", specifier = ">=2.31.0" }, { name = "ruff", specifier = ">=0.1.0" }, { name = "solana", specifier = ">=0.36.0" }, @@ -3225,7 +3448,7 @@ source = { virtual = "." } dependencies = [ { name = "python-dotenv" }, { name = "uvicorn", extra = ["standard"] }, - { name = "x402", extra = ["evm", "extensions", "fastapi", "svm"] }, + { name = "x402", extra = ["evm", "extensions", "fastapi", "svm", "tvm"] }, ] [package.dev-dependencies] @@ -3241,7 +3464,7 @@ dev = [ requires-dist = [ { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" }, - { name = "x402", extras = ["fastapi", "evm", "svm", "extensions"], editable = "../../../../python/x402" }, + { name = "x402", extras = ["fastapi", "evm", "svm", "tvm", "extensions"], editable = "../../../../python/x402" }, ] [package.metadata.requires-dev] diff --git a/python/x402/README.md b/python/x402/README.md index ee5b2187f6..8da5c86dc2 100644 --- a/python/x402/README.md +++ b/python/x402/README.md @@ -15,9 +15,10 @@ uv add x402[requests] # requests client uv add x402[fastapi] # FastAPI middleware uv add x402[flask] # Flask middleware -# Blockchain mechanisms (pick one or both) +# Blockchain mechanisms (pick one or more) uv add x402[evm] # EVM/Ethereum uv add x402[svm] # Solana +uv add x402[tvm] # TON/TVM # Multiple extras uv add x402[fastapi,httpx,evm] @@ -53,6 +54,25 @@ client.register("eip155:*", ExactEvmScheme(signer=my_signer)) payload = client.create_payment_payload(payment_required) ``` +### TVM Client (Async) + +```python +import os + +from x402 import x402Client +from x402.mechanisms.tvm import TVM_TESTNET, WalletV5R1Config, WalletV5R1MnemonicSigner +from x402.mechanisms.tvm.exact import ExactTvmScheme + +tvm_config = WalletV5R1Config.from_private_key( + TVM_TESTNET, + os.environ["TVM_PRIVATE_KEY"], +) +tvm_config.api_key = os.environ.get("TONCENTER_API_KEY") + +client = x402Client() +client.register(TVM_TESTNET, ExactTvmScheme(WalletV5R1MnemonicSigner(tvm_config))) +``` + ### Server (Async) ```python @@ -153,11 +173,16 @@ Use `from_config()` for declarative setup: ```python from x402 import x402Client, x402ClientConfig, SchemeRegistration +from x402 import prefer_network +from x402.mechanisms.evm.exact import ExactEvmScheme +from x402.mechanisms.svm.exact import ExactSvmScheme +from x402.mechanisms.tvm.exact import ExactTvmScheme config = x402ClientConfig( schemes=[ SchemeRegistration(network="eip155:*", client=ExactEvmScheme(signer)), SchemeRegistration(network="solana:*", client=ExactSvmScheme(signer)), + SchemeRegistration(network="tvm:*", client=ExactTvmScheme(tvm_signer)), ], policies=[prefer_network("eip155:8453")], ) @@ -256,6 +281,7 @@ client.register("eip155:8453", CustomScheme()) - `x402.http` - HTTP clients, middleware, and facilitator client - `x402.mechanisms.evm` - EVM/Ethereum implementation - `x402.mechanisms.svm` - Solana implementation +- `x402.mechanisms.tvm` - TON/TVM implementation - `x402.extensions` - Protocol extensions (Bazaar discovery) ## Examples diff --git a/python/x402/changelog.d/tvm-python-sdk.feature.md b/python/x402/changelog.d/tvm-python-sdk.feature.md new file mode 100644 index 0000000000..6d7dd89055 --- /dev/null +++ b/python/x402/changelog.d/tvm-python-sdk.feature.md @@ -0,0 +1 @@ +Added the TVM exact-payment mechanism to the Python SDK, including client, server, facilitator, and example coverage for TON testnet/mainnet flows. diff --git a/python/x402/mechanisms/tvm/README.md b/python/x402/mechanisms/tvm/README.md new file mode 100644 index 0000000000..11e734eab0 --- /dev/null +++ b/python/x402/mechanisms/tvm/README.md @@ -0,0 +1,150 @@ +# x402 TVM Mechanism + +TON/TVM implementation of the x402 payment protocol using the **Exact** payment scheme with jetton transfers relayed through W5R1 and highload-wallet-v3 wallets. + +## Installation + +```bash +uv add x402[tvm] +``` + +## Overview + +Three components for handling x402 payments on TVM-compatible networks: + +- **Client** (`ExactTvmClientScheme`) - Creates signed W5R1 payment payloads +- **Server** (`ExactTvmServerScheme`) - Builds payment requirements and parses prices +- **Facilitator** (`ExactTvmFacilitatorScheme`) - Verifies payloads and relays settlements on-chain + +`ExactTvmScheme` in `x402.mechanisms.tvm.exact` is an alias for the client scheme (`ExactTvmClientScheme`). + +## Quick Start + +### Client + +```python +import os + +from x402 import x402Client +from x402.mechanisms.tvm import TVM_TESTNET, WalletV5R1Config, WalletV5R1MnemonicSigner +from x402.mechanisms.tvm.exact import ExactTvmScheme + +config = WalletV5R1Config.from_private_key( + TVM_TESTNET, + os.environ["TVM_CLIENT_PRIVATE_KEY"], +) +config.api_key = os.environ.get("TONCENTER_API_KEY") + +signer = WalletV5R1MnemonicSigner(config) + +client = x402Client() +client.register(TVM_TESTNET, ExactTvmScheme(signer=signer)) + +payload = await client.create_payment_payload(payment_required) +``` + +Call `scheme.close()` when you are done with a long-lived `ExactTvmScheme` so its cached Toncenter HTTP clients are released. + +### Server + +```python +from x402 import x402ResourceServer +from x402.mechanisms.tvm.exact import ExactTvmServerScheme + +server = x402ResourceServer(facilitator_client) +server.register("tvm:*", ExactTvmServerScheme()) +``` + +### Facilitator + +```python +import os + +from x402 import x402Facilitator +from x402.mechanisms.tvm import HighloadV3Config, TVM_TESTNET, FacilitatorHighloadV3Signer +from x402.mechanisms.tvm.exact import ExactTvmFacilitatorScheme + +config = HighloadV3Config.from_private_key(os.environ["TVM_FACILITATOR_PRIVATE_KEY"]) +config.api_key = os.environ.get("TONCENTER_API_KEY") + +signer = FacilitatorHighloadV3Signer({TVM_TESTNET: config}) + +facilitator = x402Facilitator() +facilitator.register([TVM_TESTNET], ExactTvmFacilitatorScheme(signer)) +``` + +## Exports + +### `x402.mechanisms.tvm.exact` + +| Export | Description | +| ---------------------------------- | ------------------------------------------------ | +| `ExactTvmScheme` | Client scheme (alias for `ExactTvmClientScheme`) | +| `ExactTvmClientScheme` | Client-side payment creation | +| `ExactTvmServerScheme` | Server-side requirement building | +| `ExactTvmFacilitatorScheme` | Facilitator verification/settlement | +| `register_exact_tvm_client()` | Helper to register client | +| `register_exact_tvm_server()` | Helper to register server | +| `register_exact_tvm_facilitator()` | Helper to register facilitator | + +### `x402.mechanisms.tvm` + +| Export | Description | +| ----------------------------- | ------------------------------------------- | +| `ClientTvmSigner` | Protocol for client signers | +| `FacilitatorTvmSigner` | Protocol for facilitator signers | +| `WalletV5R1MnemonicSigner` | Client signer using a W5R1 wallet | +| `FacilitatorHighloadV3Signer` | Facilitator signer using highload-wallet-v3 | +| `ToncenterRestClient` | Toncenter provider client | +| `TVM_MAINNET` | TON mainnet CAIP-2 identifier | +| `TVM_TESTNET` | TON testnet CAIP-2 identifier | + +## Supported Networks + +- `tvm:-239` - TON mainnet +- `tvm:-3` - TON testnet +- `tvm:*` - Wildcard (all supported TVM chains) + +## Asset Support + +Supports [TEP-74](https://github.com/ton-blockchain/TEPs/blob/master/text/0074-jettons-standard.md) jetton payments with explicit asset requirements: + +- Mainnet USDT (`USDT_MAINNET_MINTER`) +- Testnet USDT (`USDT_TESTNET_MINTER`) +- Any TEP-74 jetton when the server is given an explicit asset address + +Server-side prices may be supplied as: + +- `AssetAmount(...)` or `{"amount": "...", "asset": "..."}` for an explicit jetton +- `int`, `float`, or strings like `"$0.01"` and `"0.01 USDT"` for built-in USDT conversion + +For non-default jettons, provide either: + +- the amount in atomic units, or +- `extra.decimals` so decimal amounts can be normalized correctly + +## Technical Details + +### Client Wallet Format + +The TVM client flow uses Wallet V5R1: + +1. Build wallet state init from the configured private key +2. Read account state and seqno from Toncenter +3. Derive the payer jetton wallet for the configured asset +4. Create a signed W5R1 internal message targeting the payer wallet +5. Wrap the message as a base64 BOC settlement payload + +The signer network must match the selected payment requirement network. + +### Facilitator Settlement Flow + +The facilitator batches relay requests through a highload-wallet-v3 account: + +1. Verify the settlement BOC, signature, wallet code hash, wallet id, seqno, timeout, and jetton transfer +2. Reserve the settlement in `SettlementCache` to reject duplicate settlements +3. Batch valid relay requests per network +4. Send the batched external message through the facilitator wallet +5. Wait for finalized trace confirmation through Toncenter APIs + +Call `signer.close()` when you are done with a long-lived facilitator signer so its Toncenter clients and streaming watchers are released. diff --git a/python/x402/mechanisms/tvm/__init__.py b/python/x402/mechanisms/tvm/__init__.py new file mode 100644 index 0000000000..4bde758c94 --- /dev/null +++ b/python/x402/mechanisms/tvm/__init__.py @@ -0,0 +1,165 @@ +"""TVM mechanism for x402 payment protocol.""" + +from .codecs.common import ( + get_network_global_id, + normalize_address, + parse_amount, + parse_money_to_decimal, +) +from .codecs.jetton import build_jetton_transfer_body, build_jetton_transfer_body_fields +from .codecs.w5 import ( + address_from_state_init, + build_w5_signed_body, + build_w5r1_state_init, + make_w5r1_wallet_id, + verify_w5_signature, +) +from .constants import ( + DEFAULT_DECIMALS, + DEFAULT_HIGHLOAD_SUBWALLET_ID, + DEFAULT_HIGHLOAD_TIMEOUT, + DEFAULT_MAX_TIMEOUT_SECONDS, + DEFAULT_RELAY_AMOUNT, + DEFAULT_SETTLEMENT_BATCH_FLUSH_INTERVAL_SECONDS, + DEFAULT_SETTLEMENT_BATCH_FLUSH_SIZE, + DEFAULT_SETTLEMENT_CONFIRMATION_WORKERS, + DEFAULT_TRACE_CONFIRMATION_TIMEOUT_SECONDS, + DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS, + DEFAULT_TONCENTER_TIMEOUT_SECONDS, + DEFAULT_W5R1_SUBWALLET_NUMBER, + ERR_EXACT_TVM_ACCOUNT_FROZEN, + ERR_EXACT_TVM_DUPLICATE_SETTLEMENT, + ERR_EXACT_TVM_INSUFFICIENT_BALANCE, + ERR_EXACT_TVM_INVALID_AMOUNT, + ERR_EXACT_TVM_INVALID_ASSET, + ERR_EXACT_TVM_INVALID_CODE_HASH, + ERR_EXACT_TVM_INVALID_EXTENSIONS_DICT, + ERR_EXACT_TVM_INVALID_JETTON_TRANSFER, + ERR_EXACT_TVM_INVALID_PAYLOAD, + ERR_EXACT_TVM_INVALID_RECIPIENT, + ERR_EXACT_TVM_INVALID_SEQNO, + ERR_EXACT_TVM_INVALID_SETTLEMENT_BOC, + ERR_EXACT_TVM_INVALID_SIGNATURE, + ERR_EXACT_TVM_INVALID_SIGNATURE_MODE, + ERR_EXACT_TVM_INVALID_UNTIL_EXPIRED, + ERR_EXACT_TVM_INVALID_W5_ACTIONS, + ERR_EXACT_TVM_INVALID_W5_MESSAGE, + ERR_EXACT_TVM_INVALID_WALLET_ID, + ERR_EXACT_TVM_NETWORK_MISMATCH, + ERR_EXACT_TVM_SIMULATION_FAILED, + ERR_EXACT_TVM_TRANSACTION_FAILED, + ERR_EXACT_TVM_UNSUPPORTED_NETWORK, + ERR_EXACT_TVM_UNSUPPORTED_SCHEME, + ERR_EXACT_TVM_UNSUPPORTED_VERSION, + ERR_EXACT_TVM_VALID_UNTIL_TOO_FAR, + HIGHLOAD_V3_CODE_HASH, + JETTON_TRANSFER_OPCODE, + SCHEME_EXACT, + SUPPORTED_NETWORKS, + TONCENTER_MAINNET_BASE_URL, + TONCENTER_TESTNET_BASE_URL, + TVM_MAINNET, + TVM_TESTNET, + USDT_MAINNET_MINTER, + USDT_TESTNET_MINTER, + W5_INTERNAL_SIGNED_OPCODE, + ALLOWED_CLIENT_CODES, + W5R1_CODE_HEX, +) +from .exact.codec import parse_exact_tvm_payload +from .provider import ToncenterRestClient +from .settlement_cache import SettlementCache +from .signer import ClientTvmSigner, FacilitatorTvmSigner +from .signers import ( + FacilitatorHighloadV3Signer, + HighloadV3Config, + WalletV5R1Config, + WalletV5R1MnemonicSigner, +) +from .types import ( + ExactTvmPayload, + ParsedJettonTransfer, + ParsedTvmSettlement, + TvmAccountState, + TvmJettonWalletData, + TvmRelayRequest, +) + +__all__ = [ + "SCHEME_EXACT", + "SUPPORTED_NETWORKS", + "TVM_MAINNET", + "TVM_TESTNET", + "DEFAULT_DECIMALS", + "DEFAULT_MAX_TIMEOUT_SECONDS", + "DEFAULT_W5R1_SUBWALLET_NUMBER", + "DEFAULT_HIGHLOAD_SUBWALLET_ID", + "DEFAULT_HIGHLOAD_TIMEOUT", + "DEFAULT_RELAY_AMOUNT", + "DEFAULT_TRACE_CONFIRMATION_TIMEOUT_SECONDS", + "DEFAULT_SETTLEMENT_BATCH_FLUSH_INTERVAL_SECONDS", + "DEFAULT_SETTLEMENT_BATCH_FLUSH_SIZE", + "DEFAULT_SETTLEMENT_CONFIRMATION_WORKERS", + "DEFAULT_TONCENTER_TIMEOUT_SECONDS", + "DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS", + "USDT_MAINNET_MINTER", + "USDT_TESTNET_MINTER", + "TONCENTER_MAINNET_BASE_URL", + "TONCENTER_TESTNET_BASE_URL", + "JETTON_TRANSFER_OPCODE", + "W5_INTERNAL_SIGNED_OPCODE", + "W5R1_CODE_HEX", + "ALLOWED_CLIENT_CODES", + "HIGHLOAD_V3_CODE_HASH", + "ERR_EXACT_TVM_UNSUPPORTED_SCHEME", + "ERR_EXACT_TVM_UNSUPPORTED_NETWORK", + "ERR_EXACT_TVM_NETWORK_MISMATCH", + "ERR_EXACT_TVM_UNSUPPORTED_VERSION", + "ERR_EXACT_TVM_INVALID_PAYLOAD", + "ERR_EXACT_TVM_INVALID_SETTLEMENT_BOC", + "ERR_EXACT_TVM_INVALID_W5_MESSAGE", + "ERR_EXACT_TVM_INVALID_W5_ACTIONS", + "ERR_EXACT_TVM_INVALID_JETTON_TRANSFER", + "ERR_EXACT_TVM_INVALID_SIGNATURE", + "ERR_EXACT_TVM_INVALID_CODE_HASH", + "ERR_EXACT_TVM_INVALID_ASSET", + "ERR_EXACT_TVM_INVALID_EXTENSIONS_DICT", + "ERR_EXACT_TVM_INVALID_RECIPIENT", + "ERR_EXACT_TVM_INVALID_AMOUNT", + "ERR_EXACT_TVM_ACCOUNT_FROZEN", + "ERR_EXACT_TVM_INVALID_SEQNO", + "ERR_EXACT_TVM_INVALID_SIGNATURE_MODE", + "ERR_EXACT_TVM_INVALID_UNTIL_EXPIRED", + "ERR_EXACT_TVM_INVALID_WALLET_ID", + "ERR_EXACT_TVM_VALID_UNTIL_TOO_FAR", + "ERR_EXACT_TVM_INSUFFICIENT_BALANCE", + "ERR_EXACT_TVM_DUPLICATE_SETTLEMENT", + "ERR_EXACT_TVM_SIMULATION_FAILED", + "ERR_EXACT_TVM_TRANSACTION_FAILED", + "ClientTvmSigner", + "FacilitatorTvmSigner", + "WalletV5R1Config", + "WalletV5R1MnemonicSigner", + "FacilitatorHighloadV3Signer", + "HighloadV3Config", + "ToncenterRestClient", + "SettlementCache", + "ExactTvmPayload", + "ParsedJettonTransfer", + "ParsedTvmSettlement", + "TvmAccountState", + "TvmJettonWalletData", + "TvmRelayRequest", + "address_from_state_init", + "build_jetton_transfer_body", + "build_jetton_transfer_body_fields", + "build_w5_signed_body", + "build_w5r1_state_init", + "get_network_global_id", + "make_w5r1_wallet_id", + "normalize_address", + "parse_amount", + "parse_exact_tvm_payload", + "parse_money_to_decimal", + "verify_w5_signature", +] diff --git a/python/x402/mechanisms/tvm/codecs/__init__.py b/python/x402/mechanisms/tvm/codecs/__init__.py new file mode 100644 index 0000000000..caef3aa6b2 --- /dev/null +++ b/python/x402/mechanisms/tvm/codecs/__init__.py @@ -0,0 +1,67 @@ +"""TVM codec helpers for state, message, and payload encoding/decoding.""" + +from .common import ( + address_to_stack_item, + get_network_global_id, + normalize_address, + parse_amount, + parse_money_to_decimal, +) +from .highload_v3 import ( + MAX_BIT_NUMBER, + MAX_SHIFT, + MAX_USABLE_QUERY_SEQNO, + HighloadQueryState, + load_highload_query_state, + query_id_is_processed, + seqno_to_query_id, + serialize_internal_transfer, +) +from .jetton import ( + build_jetton_transfer_body, + build_jetton_transfer_body_fields, + parse_jetton_transfer, +) +from .w5 import ( + address_from_state_init, + build_w5_signed_body, + build_w5r1_state_init, + get_w5_seqno, + make_w5r1_wallet_id, + parse_active_w5_account_state, + parse_out_list, + parse_w5_init_data, + serialize_out_list, + serialize_send_msg_action, + verify_w5_signature, +) + +__all__ = [ + "address_from_state_init", + "address_to_stack_item", + "build_jetton_transfer_body", + "build_jetton_transfer_body_fields", + "build_w5_signed_body", + "build_w5r1_state_init", + "get_network_global_id", + "get_w5_seqno", + "HighloadQueryState", + "load_highload_query_state", + "make_w5r1_wallet_id", + "MAX_BIT_NUMBER", + "MAX_SHIFT", + "MAX_USABLE_QUERY_SEQNO", + "normalize_address", + "parse_active_w5_account_state", + "parse_amount", + "parse_jetton_transfer", + "parse_money_to_decimal", + "parse_out_list", + "parse_w5_init_data", + "query_id_is_processed", + "seqno_to_query_id", + "serialize_internal_transfer", + "serialize_out_list", + "serialize_send_msg_action", + "verify_w5_signature", +] diff --git a/python/x402/mechanisms/tvm/codecs/common.py b/python/x402/mechanisms/tvm/codecs/common.py new file mode 100644 index 0000000000..cf7cd5c4c7 --- /dev/null +++ b/python/x402/mechanisms/tvm/codecs/common.py @@ -0,0 +1,74 @@ +"""Shared TVM codec helpers that are not wallet-contract specific.""" + +from __future__ import annotations + +import base64 +import binascii +import re +from decimal import Decimal + +try: + from pytoniq_core import Address, Builder, Cell +except ImportError as e: + raise ImportError( + "TVM mechanism requires pytoniq packages. Install with: pip install x402[tvm]" + ) from e + + +def normalize_address(address: str | Address) -> str: + """Normalize a TVM address to raw ``wc:hex`` form.""" + if isinstance(address, Address): + return address.to_str(is_user_friendly=False) + return Address(address).to_str(is_user_friendly=False) + + +def address_to_stack_item(address: str) -> object: + """Serialize an address for the Toncenter getter stack.""" + cell = Builder().store_address(Address(address)).end_cell() + return { + "type": "slice", + "value": base64.b64encode(cell.to_boc()).decode("utf-8"), + } + + +def decode_base64_boc(value: object) -> Cell: + """Decode a base64-encoded BoC string into a ``Cell``.""" + if not isinstance(value, str): + raise ValueError("Expected a base64-encoded BoC string") + try: + return Cell.one_from_boc(base64.b64decode(value, validate=True)) + except (ValueError, binascii.Error) as exc: + raise ValueError("Expected a base64-encoded BoC string") from exc + + +def make_zero_bit_cell() -> Cell: + """Build the effective default forward payload: a zero-bit cell.""" + return Builder().store_bit(0).end_cell() + + +def encode_base64_boc(cell: Cell) -> str: + """Encode a single-cell BoC as base64 text.""" + return base64.b64encode(cell.to_boc()).decode("utf-8") + + +def get_network_global_id(network: str) -> int: + """Extract the TVM global network ID from a CAIP-2 network string.""" + if not network.startswith("tvm:"): + raise ValueError(f"Unsupported TVM network: {network}") + return int(network.split(":", 1)[1]) + + +def parse_amount(amount: str, decimals: int) -> int: + """Convert decimal string to smallest unit.""" + return int(Decimal(amount) * Decimal(10**decimals)) + + +def parse_money_to_decimal(money: str | float | int) -> float: + """Parse Money into a decimal float.""" + if isinstance(money, int | float): + return float(money) + + clean = money.strip() + clean = clean.lstrip("$") + clean = re.sub(r"\s*(USD|USDT|usd|usdt)\s*$", "", clean) + return float(clean.strip()) diff --git a/python/x402/mechanisms/tvm/codecs/highload_v3.py b/python/x402/mechanisms/tvm/codecs/highload_v3.py new file mode 100644 index 0000000000..93c27c7a8e --- /dev/null +++ b/python/x402/mechanisms/tvm/codecs/highload_v3.py @@ -0,0 +1,96 @@ +"""Highload V3 state and message codecs.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass + +from ..types import TvmAccountState + +try: + from pytoniq_core import Cell, begin_cell +except ImportError as e: + raise ImportError( + "TVM mechanism requires pytoniq packages. Install with: pip install x402[tvm]" + ) from e + + +MAX_SHIFT = 8191 +MAX_BIT_NUMBER = 1022 +MAX_USABLE_QUERY_SEQNO = MAX_SHIFT * 1023 + (MAX_BIT_NUMBER - 1) + + +@dataclass +class HighloadQueryState: + old_queries: dict[int, Cell] + queries: dict[int, Cell] + + +def seqno_to_query_id(seqno: int) -> int: + """Convert a monotonic seqno into a Highload V3 query id.""" + if seqno < 0 or seqno > MAX_USABLE_QUERY_SEQNO: + raise ValueError("Highload V3 seqno is out of range") + shift = seqno // 1023 + bit_number = seqno % 1023 + return (shift << 10) + bit_number + + +def serialize_internal_transfer(actions: Cell, query_id: int) -> Cell: + """Serialize Highload V3 internal_transfer body that installs OutActions.""" + return ( + begin_cell() + .store_uint(0xAE42E5A4, 32) + .store_uint(query_id, 64) + .store_ref(actions) + .end_cell() + ) + + +def load_highload_query_state( + account_state: TvmAccountState, + *, + expected_code_hash: str, + now: int | None = None, +) -> HighloadQueryState | None: + """Decode current Highload V3 query bitmaps from account state.""" + if not account_state.is_active: + return None + + state_init = account_state.state_init + if state_init is None or state_init.code is None or state_init.data is None: + raise RuntimeError("Active Highload V3 wallet state is missing code or data") + if state_init.code.hash.hex() != expected_code_hash: + raise RuntimeError("Unexpected code hash for Highload V3 facilitator wallet") + + data = state_init.data.begin_parse() + data.load_bytes(32) + data.load_uint(32) + old_queries = data.load_dict(13, value_deserializer=lambda item: item.load_ref()) or {} + queries = data.load_dict(13, value_deserializer=lambda item: item.load_ref()) or {} + last_clean_time = data.load_uint(64) + timeout = data.load_uint(22) + + if now is None: + now = int(time.time()) + + if last_clean_time < now - timeout: + old_queries, queries = queries, {} + if last_clean_time < now - (timeout * 2): + old_queries = {} + + return HighloadQueryState(old_queries=dict(old_queries), queries=dict(queries)) + + +def query_id_is_processed(query_state: HighloadQueryState, query_id: int) -> bool: + """Check whether a Highload V3 query id was already processed.""" + shift = query_id >> 10 + bit_number = query_id & 1023 + return _bitmap_contains(query_state.old_queries.get(shift), bit_number) or _bitmap_contains( + query_state.queries.get(shift), bit_number + ) + + +def _bitmap_contains(bitmap: Cell | None, bit_number: int) -> bool: + if bitmap is None or bit_number >= len(bitmap.bits): + return False + return bitmap.begin_parse().skip_bits(bit_number).preload_bit() != 0 diff --git a/python/x402/mechanisms/tvm/codecs/jetton.py b/python/x402/mechanisms/tvm/codecs/jetton.py new file mode 100644 index 0000000000..3e6b98f4d2 --- /dev/null +++ b/python/x402/mechanisms/tvm/codecs/jetton.py @@ -0,0 +1,98 @@ +"""Jetton-specific TVM payload encoding and decoding.""" + +from __future__ import annotations + +from collections.abc import Mapping + +from ....schemas import PaymentRequirements +from ..constants import ERR_EXACT_TVM_INVALID_JETTON_TRANSFER, JETTON_TRANSFER_OPCODE +from ..types import ParsedJettonTransfer +from .common import decode_base64_boc, normalize_address + +try: + from pytoniq_core import Address, Cell, begin_cell +except ImportError as e: + raise ImportError( + "TVM mechanism requires pytoniq packages. Install with: pip install x402[tvm]" + ) from e + + +def build_jetton_transfer_body(requirements: PaymentRequirements) -> Cell: + """Build a TEP-74 ``transfer`` body from x402 TVM payment requirements.""" + return build_jetton_transfer_body_fields( + amount=int(requirements.amount), + pay_to=requirements.pay_to, + extra=requirements.extra, + ) + + +def build_jetton_transfer_body_fields( + *, + amount: int, + pay_to: str, + extra: Mapping[str, object], +) -> Cell: + """Build a TEP-74 ``transfer`` body from normalized transfer fields.""" + forward_ton_amount = int(extra.get("forwardTonAmount", 0)) + if forward_ton_amount < 0: + raise ValueError("Forward ton amount should be >= 0") + response_destination = extra.get("responseDestination") + + transfer_body = ( + begin_cell() + .store_uint(JETTON_TRANSFER_OPCODE, 32) + .store_uint(0, 64) + .store_coins(amount) + .store_address(Address(pay_to)) + .store_address(response_destination) + .store_bit(0) + .store_coins(forward_ton_amount) + ) + encoded_forward_payload = extra.get("forwardPayload") + if encoded_forward_payload is None: + transfer_body = transfer_body.store_uint(0, 2) + else: + forward_payload = decode_base64_boc(str(encoded_forward_payload)) + transfer_body = transfer_body.store_maybe_ref(forward_payload) + return transfer_body.end_cell() + + +def parse_jetton_transfer(jetton_wallet: str, body: Cell) -> ParsedJettonTransfer: + """ + Parse a TEP-74 `transfer` body: + transfer#0f8a7ea5 query_id:uint64 amount:(VarUInteger 16) destination:MsgAddress + response_destination:MsgAddress custom_payload:(Maybe ^Cell) + forward_ton_amount:(VarUInteger 16) forward_payload:(Either Cell ^Cell) + = InternalMsgBody; + """ + body_slice = body.begin_parse() + + opcode = body_slice.load_uint(32) + if opcode != JETTON_TRANSFER_OPCODE: + raise ValueError(ERR_EXACT_TVM_INVALID_JETTON_TRANSFER) + + body_slice.load_uint(64) + amount = body_slice.load_coins() + destination = body_slice.load_address() + if destination is None: + raise ValueError(ERR_EXACT_TVM_INVALID_JETTON_TRANSFER) + + response_destination = body_slice.load_address() + + if body_slice.load_bit(): + raise ValueError(ERR_EXACT_TVM_INVALID_JETTON_TRANSFER) + forward_ton_amount = body_slice.load_coins() + forward_payload = body_slice.load_ref() if body_slice.load_bit() else body_slice.to_cell() + + return ParsedJettonTransfer( + source_wallet=jetton_wallet, + destination=normalize_address(destination), + response_destination=( + normalize_address(response_destination) if response_destination else None + ), + jetton_amount=amount, + attached_ton_amount=0, + forward_ton_amount=forward_ton_amount, + forward_payload=forward_payload, + body_hash=body.hash, + ) diff --git a/python/x402/mechanisms/tvm/codecs/w5.py b/python/x402/mechanisms/tvm/codecs/w5.py new file mode 100644 index 0000000000..6b60c22f27 --- /dev/null +++ b/python/x402/mechanisms/tvm/codecs/w5.py @@ -0,0 +1,168 @@ +"""Wallet V5 R1 codecs and state decoding helpers.""" + +from __future__ import annotations + +from collections.abc import Callable + +from pytoniq_core import TransactionError + +from ..constants import ( + ERR_EXACT_TVM_INVALID_W5_ACTIONS, + ERR_EXACT_TVM_INVALID_W5_MESSAGE, + SEND_MODE_IGNORE_ERRORS, + SEND_MODE_PAY_FEES_SEPARATELY, + ALLOWED_CLIENT_CODES, + W5R1_CODE_HEX, +) +from ..types import TvmAccountState, W5InitData +from .common import get_network_global_id, normalize_address + +try: + from pytoniq_core import Address, Cell, begin_cell + from pytoniq_core.crypto.signature import verify_sign + from pytoniq_core.tlb.account import StateInit + from pytoniq_core.tlb.transaction import OutAction +except ImportError as e: + raise ImportError( + "TVM mechanism requires pytoniq packages. Install with: pip install x402[tvm]" + ) from e + + +def make_w5r1_wallet_id(network: str, workchain: int = 0, subwallet_number: int = 0) -> int: + """Build the unsigned W5R1 wallet_id for a client wallet.""" + network_global_id = get_network_global_id(network) + context = ( + begin_cell() + .store_uint(1, 1) + .store_int(workchain, 8) + .store_uint(0, 8) + .store_uint(subwallet_number, 15) + .end_cell() + .begin_parse() + .load_int(32) + ) + return ((network_global_id & 0xFFFFFFFF) ^ (context & 0xFFFFFFFF)) & 0xFFFFFFFF + + +def address_from_state_init(state_init: StateInit, workchain: int) -> str: + """Compute the contract address derived from ``state_init``.""" + return normalize_address(Address((workchain, state_init.serialize().hash))) + + +def build_w5r1_state_init(public_key: bytes, wallet_id: int) -> StateInit: + """Build a W5R1 wallet StateInit for a client wallet.""" + code = Cell.one_from_boc(bytes.fromhex(W5R1_CODE_HEX)) + + data = ( + begin_cell() + .store_uint(1, 1) + .store_uint(0, 32) + .store_uint(wallet_id, 32) + .store_bytes(public_key) + .store_bit(0) + .end_cell() + ) + return StateInit(code=code, data=data) + + +def parse_w5_init_data(state_init: StateInit) -> W5InitData: + """Extract W5 wallet fields from StateInit data.""" + if state_init.data is None: + raise ValueError(ERR_EXACT_TVM_INVALID_W5_MESSAGE) + data = state_init.data.begin_parse() + result = W5InitData( + signature_allowed=data.load_bit(), + seqno=data.load_uint(32), + wallet_id=data.load_uint(32), + public_key=data.load_bytes(32), + extensions_dict=data.load_maybe_ref(), + ) + if data.remaining_bits or data.remaining_refs: + raise ValueError(ERR_EXACT_TVM_INVALID_W5_MESSAGE) + return result + + +def parse_active_w5_account_state(account_state: TvmAccountState) -> W5InitData: + """Decode W5 state from an active account state.""" + if ( + not account_state.is_active + or account_state.state_init is None + or account_state.state_init.code is None + ): + raise RuntimeError(f"Account {account_state.address} does not have active W5 state") + if account_state.state_init.code.hash.hex() not in ALLOWED_CLIENT_CODES: + raise RuntimeError(f"Account {account_state.address} is not a W5R1 wallet") + return parse_w5_init_data(account_state.state_init) + + +def get_w5_seqno(account_state: TvmAccountState) -> int: + """Extract seqno from W5 account state, treating undeployed accounts as seqno=0.""" + if account_state.is_uninitialized: + return 0 + return parse_active_w5_account_state(account_state).seqno + + +def parse_out_list(cell: Cell) -> list[OutAction]: + """Parse a recursive OutList cell into actions.""" + cell_slice = cell.begin_parse() + out_actions = [] + while cell_slice.remaining_bits or cell_slice.remaining_refs: + n_bits = cell_slice.remaining_bits + n_refs = cell_slice.remaining_refs + if n_refs != 2 or n_bits != 32 + 8: + raise ValueError(ERR_EXACT_TVM_INVALID_W5_ACTIONS) + + prev = cell_slice.load_ref().begin_parse() + try: + out_actions.append(OutAction.deserialize(cell_slice)) + except TransactionError as exc: + raise ValueError(ERR_EXACT_TVM_INVALID_W5_ACTIONS) from exc + cell_slice = prev + + return out_actions + + +def serialize_send_msg_action( + message: Cell, mode: int = SEND_MODE_IGNORE_ERRORS + SEND_MODE_PAY_FEES_SEPARATELY +) -> Cell: + """Serialize one action_send_msg item.""" + return begin_cell().store_uint(0x0EC3C86D, 32).store_uint(mode, 8).store_ref(message).end_cell() + + +def serialize_out_list(actions: list[Cell]) -> Cell: + """Serialize a recursive OutList.""" + out_list = Cell.empty() + for action in actions: + out_list = begin_cell().store_ref(out_list).store_cell(action).end_cell() + return out_list + + +def build_w5_signed_body( + *, + out_message: Cell, + seqno: int, + valid_until: int, + sign_message: Callable[[bytes], bytes], + wallet_id: int, + opcode: int, + send_mode: int = SEND_MODE_PAY_FEES_SEPARATELY, +) -> Cell: + """Build and sign a W5 request body for a single outgoing message.""" + actions = serialize_out_list([serialize_send_msg_action(out_message, send_mode)]) + unsigned_body = ( + begin_cell() + .store_uint(opcode, 32) + .store_uint(wallet_id, 32) + .store_uint(valid_until, 32) + .store_uint(seqno, 32) + .store_maybe_ref(actions) + .store_bit(0) + .end_cell() + ) + signature = sign_message(unsigned_body.hash) + return begin_cell().store_slice(unsigned_body.begin_parse()).store_bytes(signature).end_cell() + + +def verify_w5_signature(public_key: bytes, signed_slice_hash: bytes, signature: bytes) -> bool: + """Verify a signed W5 request body.""" + return bool(verify_sign(public_key, signed_slice_hash, signature)) diff --git a/python/x402/mechanisms/tvm/constants.py b/python/x402/mechanisms/tvm/constants.py new file mode 100644 index 0000000000..9b46d49dd1 --- /dev/null +++ b/python/x402/mechanisms/tvm/constants.py @@ -0,0 +1,87 @@ +"""TVM mechanism constants.""" + +SCHEME_EXACT = "exact" + +TVM_MAINNET = "tvm:-239" +TVM_TESTNET = "tvm:-3" +SUPPORTED_NETWORKS = {TVM_MAINNET, TVM_TESTNET} + +DEFAULT_DECIMALS = 6 +DEFAULT_MAX_TIMEOUT_SECONDS = 300 +DEFAULT_W5R1_SUBWALLET_NUMBER = 0 +DEFAULT_HIGHLOAD_SUBWALLET_ID = 0x10AD +DEFAULT_HIGHLOAD_TIMEOUT = 3600 +DEFAULT_RELAY_AMOUNT = 40_000_000 +DEFAULT_JETTON_WALLET_MESSAGE_AMOUNT = 30_000_000 +DEFAULT_TVM_INNER_GAS_BUFFER = 7_100_000 +DEFAULT_TVM_OUTER_GAS_BUFFER = 500_000 +DEFAULT_TONCENTER_TIMEOUT_SECONDS = 2.0 +DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS = 10.0 +DEFAULT_TRACE_CONFIRMATION_TIMEOUT_SECONDS = 20.0 +DEFAULT_STREAMING_CONFIRMATION_GRACE_SECONDS = 5.0 +DEFAULT_SETTLEMENT_BATCH_FLUSH_INTERVAL_SECONDS = 1.0 +DEFAULT_SETTLEMENT_BATCH_FLUSH_SIZE = 100 +DEFAULT_SETTLEMENT_CONFIRMATION_WORKERS = 4 +DEFAULT_SETTLEMENT_BATCH_MAX_SIZE = 185 + +TONCENTER_MAINNET_BASE_URL = "https://toncenter.com" +TONCENTER_TESTNET_BASE_URL = "https://testnet.toncenter.com" + +# Active W5 wallet used only for relay-style fee emulation of sponsored payments. +DEFAULT_TVM_EMULATION_ADDRESS = "0:0f8110d76414005a9f0a7deb4d15938b1cd8db22df3f160ed5b48337735abb62" +DEFAULT_TVM_EMULATION_WALLET_ID = 2147483409 +DEFAULT_TVM_EMULATION_SEQNO = 1 +DEFAULT_TVM_EMULATION_RELAY_AMOUNT = 130_000_000 + +USDT_MAINNET_MINTER = "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe" +USDT_TESTNET_MINTER = "0:f418a04cf196ebc959366844a6cdf53a6fd6fff1eadafc892f05210bba31593e" + +SEND_MODE_REGULAR = 0 +SEND_MODE_PAY_FEES_SEPARATELY = 1 +SEND_MODE_IGNORE_ERRORS = 2 +SEND_MODE_BOUNCE_ON_ACTION_FAIL = 16 +SEND_MODE_DESTROY = 32 +SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE = 64 +SEND_MODE_CARRY_ALL_BALANCE = 128 +SEND_MODE_ESTIMATE_FEE_ONLY = 1024 + +JETTON_TRANSFER_OPCODE = 0x0F8A7EA5 +W5_EXTERNAL_SIGNED_OPCODE = 0x7369676E +W5_INTERNAL_SIGNED_OPCODE = 0x73696E74 +# fmt: off +W5R1_CODE_HEX = "b5ee9c7241021401000281000114ff00f4a413f4bcf2c80b01020120020d020148030402dcd020d749c120915b8f6320d70b1f2082106578746ebd21821073696e74bdb0925f03e082106578746eba8eb48020d72101d074d721fa4030fa44f828fa443058bd915be0ed44d0810141d721f4058307f40e6fa1319130e18040d721707fdb3ce03120d749810280b99130e070e2100f020120050c020120060902016e07080019adce76a2684020eb90eb85ffc00019af1df6a2684010eb90eb858fc00201480a0b0017b325fb51341c75c875c2c7e00011b262fb513435c280200019be5f0f6a2684080a0eb90fa02c0102f20e011e20d70b1f82107369676ebaf2e08a7f0f01e68ef0eda2edfb218308d722028308d723208020d721d31fd31fd31fed44d0d200d31f20d31fd3ffd70a000af90140ccf9109a28945f0adb31e1f2c087df02b35007b0f2d0845125baf2e0855036baf2e086f823bbf2d0882292f800de01a47fc8ca00cb1f01cf16c9ed542092f80fde70db3cd81003f6eda2edfb02f404216e926c218e4c0221d73930709421c700b38e2d01d72820761e436c20d749c008f2e09320d74ac002f2e09320d71d06c712c2005230b0f2d089d74cd7393001a4e86c128407bbf2e093d74ac000f2e093ed55e2d20001c000915be0ebd72c08142091709601d72c081c12e25210b1e30f20d74a111213009601fa4001fa44f828fa443058baf2e091ed44d0810141d718f405049d7fc8ca0040048307f453f2e08b8e14038307f45bf2e08c22d70a00216e01b3b0f2d090e2c85003cf1612f400c9ed54007230d72c08248e2d21f2e092d200ed44d0d2005113baf2d08f54503091319c01810140d721d70a00f2e08ee2c8ca0058cf16c9ed5493f2c08de20010935bdb31e1d74cd0b4d6c35e" +# fmt: on + +W5R1_CODE_HASH = "20834b7b72b112147e1b2fb457b84e74d1a30f04f737d4f62a668e9552d2b72f" +ALLOWED_CLIENT_CODES = [W5R1_CODE_HASH] + +# fmt: off +HIGHLOAD_V3_CODE_HEX = "b5ee9c7241021001000228000114ff00f4a413f4bcf2c80b01020120020d02014803040078d020d74bc00101c060b0915be101d0d3030171b0915be0fa4030f828c705b39130e0d31f018210ae42e5a4ba9d8040d721d74cf82a01ed55fb04e030020120050a02027306070011adce76a2686b85ffc00201200809001aabb6ed44d0810122d721d70b3f0018aa3bed44d08307d721d70b1f0201200b0c001bb9a6eed44d0810162d721d70b15800e5b8bf2eda2edfb21ab09028409b0ed44d0810120d721f404f404d33fd315d1058e1bf82325a15210b99f326df82305aa0015a112b992306dde923033e2923033e25230800df40f6fa19ed021d721d70a00955f037fdb31e09130e259800df40f6fa19cd001d721d70a00937fdb31e0915be270801f6f2d48308d718d121f900ed44d0d3ffd31ff404f404d33fd315d1f82321a15220b98e12336df82324aa00a112b9926d32de58f82301de541675f910f2a106d0d31fd4d307d30cd309d33fd315d15168baf2a2515abaf2a6f8232aa15250bcf2a304f823bbf2a35304800df40f6fa199d024d721d70a00f2649130e20e01fe5309800df40f6fa18e13d05004d718d20001f264c858cf16cf8301cf168e1030c824cf40cf8384095005a1a514cf40e2f800c94039800df41704c8cbff13cb1ff40012f40012cb3f12cb15c9ed54f80f21d0d30001f265d3020171b0925f03e0fa4001d70b01c000f2a5fa4031fa0031f401fa0031fa00318060d721d300010f0020f265d2000193d431d19130e272b1fb00b585bf03" +# fmt: on +HIGHLOAD_V3_CODE_HASH = "11acad7955844090f283bf238bc1449871f783e7cc0979408d3f4859483e8525" + +ERR_EXACT_TVM_UNSUPPORTED_SCHEME = "unsupported_scheme" +ERR_EXACT_TVM_UNSUPPORTED_VERSION = "unsupported_version" +ERR_EXACT_TVM_UNSUPPORTED_NETWORK = "unsupported_network" +ERR_EXACT_TVM_NETWORK_MISMATCH = "network_mismatch" +ERR_EXACT_TVM_INVALID_PAYLOAD = "invalid_exact_tvm_payload" +ERR_EXACT_TVM_INVALID_SETTLEMENT_BOC = "invalid_exact_tvm_payload_settlement_boc" +ERR_EXACT_TVM_INVALID_W5_MESSAGE = "invalid_exact_tvm_payload_w5_internal_signed_request" +ERR_EXACT_TVM_INVALID_W5_ACTIONS = "invalid_exact_tvm_payload_w5_actions" +ERR_EXACT_TVM_INVALID_JETTON_TRANSFER = "invalid_exact_tvm_payload_jetton_transfer" +ERR_EXACT_TVM_INVALID_SIGNATURE = "invalid_exact_tvm_payload_invalid_signature" +ERR_EXACT_TVM_INVALID_CODE_HASH = "invalid_exact_tvm_payload_invalid_code_hash" +ERR_EXACT_TVM_INVALID_ASSET = "invalid_exact_tvm_payload_asset_mismatch" +ERR_EXACT_TVM_INVALID_RECIPIENT = "invalid_exact_tvm_payload_recipient_mismatch" +ERR_EXACT_TVM_INVALID_AMOUNT = "invalid_exact_tvm_payload_amount_mismatch" +ERR_EXACT_TVM_INVALID_SIGNATURE_MODE = "invalid_exact_tvm_payload_signature_mode_mismatch" +ERR_EXACT_TVM_INVALID_SEQNO = "invalid_exact_tvm_payload_seqno_mismatch" +ERR_EXACT_TVM_INVALID_WALLET_ID = "invalid_exact_tvm_payload_wallet_id_mismatch" +ERR_EXACT_TVM_INVALID_EXTENSIONS_DICT = "invalid_exact_tvm_payload_extensions_dict_mismatch" +ERR_EXACT_TVM_ACCOUNT_FROZEN = "account_frozen" +ERR_EXACT_TVM_INVALID_UNTIL_EXPIRED = "invalid_exact_tvm_payload_valid_until_expired" +ERR_EXACT_TVM_VALID_UNTIL_TOO_FAR = "invalid_exact_tvm_payload_valid_until_too_far" +ERR_EXACT_TVM_INSUFFICIENT_BALANCE = "insufficient_balance" +ERR_EXACT_TVM_DUPLICATE_SETTLEMENT = "duplicate_settlement" +ERR_EXACT_TVM_SIMULATION_FAILED = "simulation_failed" +ERR_EXACT_TVM_TRANSACTION_FAILED = "transaction_failed" diff --git a/python/x402/mechanisms/tvm/exact/__init__.py b/python/x402/mechanisms/tvm/exact/__init__.py new file mode 100644 index 0000000000..dfd40337d2 --- /dev/null +++ b/python/x402/mechanisms/tvm/exact/__init__.py @@ -0,0 +1,22 @@ +"""Exact TVM payment scheme helpers.""" + +from .client import ExactTvmScheme as ExactTvmClientScheme +from .facilitator import ExactTvmScheme as ExactTvmFacilitatorScheme +from .register import ( + register_exact_tvm_client, + register_exact_tvm_facilitator, + register_exact_tvm_server, +) +from .server import ExactTvmScheme as ExactTvmServerScheme + +ExactTvmScheme = ExactTvmClientScheme + +__all__ = [ + "ExactTvmScheme", + "ExactTvmClientScheme", + "ExactTvmFacilitatorScheme", + "ExactTvmServerScheme", + "register_exact_tvm_client", + "register_exact_tvm_facilitator", + "register_exact_tvm_server", +] diff --git a/python/x402/mechanisms/tvm/exact/client.py b/python/x402/mechanisms/tvm/exact/client.py new file mode 100644 index 0000000000..9ee0cdb8bd --- /dev/null +++ b/python/x402/mechanisms/tvm/exact/client.py @@ -0,0 +1,293 @@ +"""TVM client implementation for the Exact payment scheme (V2).""" + +from __future__ import annotations + +import base64 +import time +from typing import Any + +from ....schemas import PaymentRequirements +from ..codecs.jetton import build_jetton_transfer_body +from ..codecs.common import normalize_address +from ..codecs.w5 import build_w5_signed_body, get_w5_seqno +from ..constants import ( + DEFAULT_TVM_EMULATION_ADDRESS, + DEFAULT_TVM_EMULATION_RELAY_AMOUNT, + DEFAULT_TVM_EMULATION_SEQNO, + DEFAULT_TVM_EMULATION_WALLET_ID, + DEFAULT_JETTON_WALLET_MESSAGE_AMOUNT, + DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS, + DEFAULT_TONCENTER_TIMEOUT_SECONDS, + DEFAULT_TVM_INNER_GAS_BUFFER, + SCHEME_EXACT, + SEND_MODE_IGNORE_ERRORS, + SEND_MODE_PAY_FEES_SEPARATELY, + SUPPORTED_NETWORKS, + W5_EXTERNAL_SIGNED_OPCODE, + W5_INTERNAL_SIGNED_OPCODE, +) +from ..provider import ToncenterRestClient +from ..signer import ClientTvmSigner +from ..trace_utils import ( + parse_trace_transactions, + trace_transaction_balance_before, + trace_transaction_compute_fees, + trace_transaction_fwd_fees, + trace_transaction_storage_fees, + transaction_succeeded, +) +from ..types import ExactTvmPayload + +try: + from pytoniq.contract.contract import Contract + from pytoniq_core import Address, Cell +except ImportError as e: + raise ImportError( + "TVM mechanism requires pytoniq packages. Install with: pip install x402[tvm]" + ) from e + + +class ExactTvmScheme: + """TVM client implementation for the Exact payment scheme (V2).""" + + scheme = SCHEME_EXACT + + def __init__(self, signer: ClientTvmSigner) -> None: + self._signer = signer + self._clients: dict[str, ToncenterRestClient] = {} + + def close(self) -> None: + """Close any cached Toncenter clients owned by this scheme.""" + for client in self._clients.values(): + client.close() + self._clients.clear() + + def create_payment_payload( + self, + requirements: PaymentRequirements, + ) -> dict[str, Any]: + """Create a signed TON exact payment payload.""" + network = str(requirements.network) + if network not in SUPPORTED_NETWORKS: + raise ValueError(f"Unsupported TVM network: {network}") + if network != self._signer.network: + raise ValueError( + f"Signer network {self._signer.network} does not match requirements network {network}" + ) + if requirements.extra.get("areFeesSponsored") is not True: + raise ValueError("Exact TVM scheme requires extra.areFeesSponsored to be true") + + client = self._get_client(network) + payer = normalize_address(self._signer.address) + asset = normalize_address(requirements.asset) + source_wallet = self._get_jetton_wallet(client, asset, payer) + + account = client.get_account_state(payer) + include_state_init = not account.is_active + seqno = get_w5_seqno(account) + valid_until = int(time.time()) + ( + requirements.max_timeout_seconds - 5 + if requirements.max_timeout_seconds > 10 + else (requirements.max_timeout_seconds + 1) // 2 + ) + transfer_body = self._build_transfer_body(requirements) + required_inner = self._estimate_required_inner_value( + client=client, + source_wallet=source_wallet, + requirements=requirements, + seqno=seqno, + valid_until=valid_until, + transfer_body=transfer_body, + include_state_init=include_state_init, + ) + + signed_body = self._build_signed_body( + source_wallet=source_wallet, + transfer_body=transfer_body, + seqno=seqno, + valid_until=valid_until, + attached_amount=required_inner, + ) + settlement_boc = self._build_settlement_boc(payer, signed_body, include_state_init) + + return ExactTvmPayload( + settlement_boc=settlement_boc, + asset=asset, + ).to_dict() + + def _get_client(self, network: str) -> ToncenterRestClient: + if network not in self._clients: + self._clients[network] = ToncenterRestClient( + network, + api_key=getattr(self._signer, "api_key", None), + base_url=getattr(self._signer, "base_url", None), + timeout=getattr( + self._signer, + "toncenter_timeout_seconds", + DEFAULT_TONCENTER_TIMEOUT_SECONDS, + ), + ) + return self._clients[network] + + def _get_jetton_wallet(self, client: ToncenterRestClient, asset: str, payer: str) -> str: + return client.get_jetton_wallet(asset, payer) + + def _build_w5_signed_body( + self, + *, + out_message: Cell, + seqno: int, + valid_until: int, + opcode: int = W5_INTERNAL_SIGNED_OPCODE, + send_mode: int = SEND_MODE_PAY_FEES_SEPARATELY, + wallet_id: int | None = None, + ) -> Cell: + return build_w5_signed_body( + out_message=out_message, + seqno=seqno, + valid_until=valid_until, + sign_message=self._signer.sign_message, + wallet_id=self._signer.wallet_id if wallet_id is None else wallet_id, + opcode=opcode, + send_mode=send_mode, + ) + + def _build_signed_body( + self, + *, + source_wallet: str, + transfer_body: Cell, + seqno: int, + valid_until: int, + attached_amount: int, + opcode: int = W5_INTERNAL_SIGNED_OPCODE, + send_mode: int = SEND_MODE_PAY_FEES_SEPARATELY, + wallet_id: int | None = None, + ) -> Cell: + out_msg = Contract.create_internal_msg( + src=None, + dest=Address(source_wallet), + bounce=True, + value=attached_amount, + body=transfer_body, + ).serialize() + return self._build_w5_signed_body( + out_message=out_msg, + seqno=seqno, + valid_until=valid_until, + opcode=opcode, + send_mode=send_mode, + wallet_id=wallet_id, + ) + + def _build_settlement_boc(self, payer: str, body: Cell, include_state_init: bool) -> str: + message = Contract.create_internal_msg( + src=None, + dest=Address(payer), + bounce=True, + value=0, + state_init=self._signer.state_init if include_state_init else None, + body=body, + ) + return base64.b64encode(message.serialize().to_boc()).decode("utf-8") + + def _build_transfer_body(self, requirements: PaymentRequirements) -> Cell: + return build_jetton_transfer_body(requirements) + + def _estimate_required_inner_value( + self, + *, + client: ToncenterRestClient, + source_wallet: str, + requirements: PaymentRequirements, + seqno: int, + valid_until: int, + transfer_body: Cell, + include_state_init: bool, + ) -> int: + forward_ton_amount = int(requirements.extra.get("forwardTonAmount", 0)) + provisional_value = DEFAULT_JETTON_WALLET_MESSAGE_AMOUNT + forward_ton_amount + payer_body = self._build_signed_body( + source_wallet=source_wallet, + transfer_body=transfer_body, + seqno=seqno, + valid_until=valid_until, + attached_amount=provisional_value, + ) + relay_message = Contract.create_internal_msg( + src=None, + dest=Address(self._signer.address), + bounce=True, + value=DEFAULT_TVM_EMULATION_RELAY_AMOUNT, + state_init=self._signer.state_init if include_state_init else None, + body=payer_body, + ).serialize() + external_body = self._build_w5_signed_body( + out_message=relay_message, + seqno=DEFAULT_TVM_EMULATION_SEQNO, + valid_until=valid_until, + opcode=W5_EXTERNAL_SIGNED_OPCODE, + send_mode=SEND_MODE_PAY_FEES_SEPARATELY + SEND_MODE_IGNORE_ERRORS, + wallet_id=DEFAULT_TVM_EMULATION_WALLET_ID, + ) + external_message = Contract.create_external_msg( + dest=Address(DEFAULT_TVM_EMULATION_ADDRESS), + body=external_body, + ) + trace = client.emulate_trace( + external_message.serialize().to_boc(), + ignore_chksig=True, + timeout=getattr( + self._signer, + "toncenter_emulation_timeout_seconds", + DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS, + ), + ) + transactions = parse_trace_transactions(trace) + + source_wallet_tx = None + for transaction in transactions: + if normalize_address(transaction["account"]) != normalize_address(source_wallet): + continue + if not transaction_succeeded(transaction): + continue + in_msg = transaction.get("in_msg") or {} + if in_msg.get("decoded_opcode") == "jetton_transfer" and normalize_address( + in_msg["source"] + ) == normalize_address(self._signer.address): + source_wallet_tx = transaction + break + if source_wallet_tx is None: + raise ValueError("Trace does not contain the expected source jetton wallet transaction") + + receiver_wallet_tx = None + for transaction in transactions: + if not transaction_succeeded(transaction): + continue + in_msg = transaction.get("in_msg") or {} + if in_msg.get("decoded_opcode") == "jetton_internal_transfer" and normalize_address( + in_msg["source"] + ) == normalize_address(source_wallet): + receiver_wallet_tx = transaction + break + if receiver_wallet_tx is None: + raise ValueError( + "Trace does not contain the expected destination jetton wallet transaction" + ) + + forward_fees = trace_transaction_fwd_fees( + source_wallet_tx, + expected_count=2 if forward_ton_amount > 0 else 1, + ) + compute_fee_source = trace_transaction_compute_fees(source_wallet_tx) + compute_fee_destination = trace_transaction_compute_fees(receiver_wallet_tx) + storage_fees_source = trace_transaction_storage_fees(source_wallet_tx) + + return ( + DEFAULT_TVM_INNER_GAS_BUFFER + + forward_fees + + compute_fee_source + + compute_fee_destination + + forward_ton_amount + + storage_fees_source + ) diff --git a/python/x402/mechanisms/tvm/exact/codec.py b/python/x402/mechanisms/tvm/exact/codec.py new file mode 100644 index 0000000000..e34a89c079 --- /dev/null +++ b/python/x402/mechanisms/tvm/exact/codec.py @@ -0,0 +1,102 @@ +"""Exact TVM settlement payload codec.""" + +from __future__ import annotations + +import base64 + +from ..codecs.common import normalize_address +from ..codecs.jetton import parse_jetton_transfer +from ..codecs.w5 import parse_out_list +from ..constants import ( + ERR_EXACT_TVM_INVALID_SETTLEMENT_BOC, + ERR_EXACT_TVM_INVALID_W5_ACTIONS, + ERR_EXACT_TVM_INVALID_W5_MESSAGE, + SEND_MODE_IGNORE_ERRORS, + SEND_MODE_PAY_FEES_SEPARATELY, + W5_INTERNAL_SIGNED_OPCODE, +) +from ..types import ParsedTvmSettlement + +try: + from pytoniq_core import Cell + from pytoniq_core.tlb.transaction import MessageAny +except ImportError as e: + raise ImportError( + "TVM mechanism requires pytoniq packages. Install with: pip install x402[tvm]" + ) from e + + +def parse_exact_tvm_payload(settlement_boc: str) -> ParsedTvmSettlement: + """Parse an exact TVM settlement payload into structured fields.""" + try: + root = Cell.one_from_boc(base64.b64decode(settlement_boc)) + message = MessageAny.deserialize(root.begin_parse()) + except Exception as exc: + raise ValueError(ERR_EXACT_TVM_INVALID_SETTLEMENT_BOC) from exc + + if not message.is_internal or message.info.dest is None: + raise ValueError(ERR_EXACT_TVM_INVALID_SETTLEMENT_BOC) + + payer = normalize_address(message.info.dest) + state_init = message.init + body = message.body + + body_slice = body.begin_parse() + opcode = body_slice.load_uint(32) + if opcode != W5_INTERNAL_SIGNED_OPCODE: + raise ValueError(ERR_EXACT_TVM_INVALID_W5_MESSAGE) + + wallet_id = body_slice.load_uint(32) + valid_until = body_slice.load_uint(32) + seqno = body_slice.load_uint(32) + + has_actions = body_slice.load_bit() + actions = parse_out_list(body_slice.load_ref()) if has_actions else [] + has_extra_actions = body_slice.load_bit() + if has_extra_actions: + raise ValueError(ERR_EXACT_TVM_INVALID_W5_ACTIONS) + + if len(actions) != 1 or actions[0].type_ != "action_send_msg": + raise ValueError(ERR_EXACT_TVM_INVALID_W5_ACTIONS) + + action = actions[0] + if ( + not action.out_msg.is_internal + or action.out_msg.info.dest is None + or not action.out_msg.info.bounce + ): + raise ValueError(ERR_EXACT_TVM_INVALID_W5_ACTIONS) + + allowed_send_modes = { + SEND_MODE_PAY_FEES_SEPARATELY, + SEND_MODE_PAY_FEES_SEPARATELY + SEND_MODE_IGNORE_ERRORS, + } + if action.mode not in allowed_send_modes: + raise ValueError(ERR_EXACT_TVM_INVALID_W5_ACTIONS) + + transfer = parse_jetton_transfer( + jetton_wallet=normalize_address(action.out_msg.info.dest), + body=action.out_msg.body, + ) + transfer.attached_ton_amount = action.out_msg.info.value_coins + + signature = body_slice.load_bytes(64) + if body_slice.remaining_bits or body_slice.remaining_refs: + raise ValueError(ERR_EXACT_TVM_INVALID_W5_MESSAGE) + + signed_slice = body.begin_parse().copy() + signed_slice.bits = signed_slice.bits[:-512] + signed_slice_hash = signed_slice.to_cell().hash + + return ParsedTvmSettlement( + payer=payer, + wallet_id=wallet_id, + valid_until=valid_until, + seqno=seqno, + settlement_hash=root.hash.hex(), + body=body, + signed_slice_hash=signed_slice_hash, + signature=signature, + state_init=state_init, + transfer=transfer, + ) diff --git a/python/x402/mechanisms/tvm/exact/facilitator.py b/python/x402/mechanisms/tvm/exact/facilitator.py new file mode 100644 index 0000000000..c804705ca3 --- /dev/null +++ b/python/x402/mechanisms/tvm/exact/facilitator.py @@ -0,0 +1,496 @@ +"""TVM facilitator implementation for the Exact payment scheme (V2).""" + +from __future__ import annotations + +import time +from typing import Any + +from pytoniq_core import begin_cell + +from ....schemas import ( + Network, + PaymentPayload, + PaymentRequirements, + SettleResponse, + VerifyResponse, +) +from ..codecs.common import decode_base64_boc, normalize_address +from ..codecs.w5 import ( + address_from_state_init, + parse_active_w5_account_state, + parse_w5_init_data, + verify_w5_signature, +) +from ..constants import ( + ALLOWED_CLIENT_CODES, + DEFAULT_SETTLEMENT_BATCH_FLUSH_INTERVAL_SECONDS, + DEFAULT_SETTLEMENT_BATCH_FLUSH_SIZE, + DEFAULT_SETTLEMENT_CONFIRMATION_WORKERS, + DEFAULT_TRACE_CONFIRMATION_TIMEOUT_SECONDS, + DEFAULT_TVM_OUTER_GAS_BUFFER, + ERR_EXACT_TVM_ACCOUNT_FROZEN, + ERR_EXACT_TVM_DUPLICATE_SETTLEMENT, + ERR_EXACT_TVM_INSUFFICIENT_BALANCE, + ERR_EXACT_TVM_INVALID_AMOUNT, + ERR_EXACT_TVM_INVALID_ASSET, + ERR_EXACT_TVM_INVALID_CODE_HASH, + ERR_EXACT_TVM_INVALID_EXTENSIONS_DICT, + ERR_EXACT_TVM_INVALID_JETTON_TRANSFER, + ERR_EXACT_TVM_INVALID_PAYLOAD, + ERR_EXACT_TVM_INVALID_RECIPIENT, + ERR_EXACT_TVM_INVALID_SEQNO, + ERR_EXACT_TVM_INVALID_SIGNATURE, + ERR_EXACT_TVM_INVALID_SIGNATURE_MODE, + ERR_EXACT_TVM_INVALID_UNTIL_EXPIRED, + ERR_EXACT_TVM_INVALID_W5_MESSAGE, + ERR_EXACT_TVM_INVALID_WALLET_ID, + ERR_EXACT_TVM_NETWORK_MISMATCH, + ERR_EXACT_TVM_SIMULATION_FAILED, + ERR_EXACT_TVM_TRANSACTION_FAILED, + ERR_EXACT_TVM_UNSUPPORTED_NETWORK, + ERR_EXACT_TVM_UNSUPPORTED_SCHEME, + ERR_EXACT_TVM_UNSUPPORTED_VERSION, + ERR_EXACT_TVM_VALID_UNTIL_TOO_FAR, + SCHEME_EXACT, + SUPPORTED_NETWORKS, +) +from ..settlement_cache import SettlementCache +from ..signer import FacilitatorTvmSigner +from ..trace_utils import ( + message_body_hash_matches, + parse_trace_transactions, + trace_transaction_hash_to_hex, + trace_transaction_compute_fees, + trace_transaction_fwd_fees, + trace_transaction_storage_fees, + transaction_succeeded, +) +from ..types import ExactTvmPayload, ParsedTvmSettlement, TvmRelayRequest, W5InitData +from .codec import parse_exact_tvm_payload +from .settlement_batcher import _BatchResult, _QueuedSettlement, _SettlementBatcher + + +def _effective_response_destination(extra: dict[str, Any]) -> str | None: + response_destination = extra.get("responseDestination") + if response_destination is None: + return None + return normalize_address(response_destination) + + +def _effective_forward_ton_amount(extra: dict[str, Any]) -> int: + return int(extra.get("forwardTonAmount", 0)) + + +def _effective_forward_payload(extra: dict[str, Any]): + encoded_payload = extra.get("forwardPayload") + if encoded_payload is None: + return begin_cell().store_bit(0).end_cell() + return decode_base64_boc(encoded_payload) + + +class ExactTvmScheme: + """TVM facilitator implementation for the Exact payment scheme (V2).""" + + scheme = SCHEME_EXACT + caip_family = "tvm:*" + + def __init__( + self, + signer: FacilitatorTvmSigner, + settlement_cache: SettlementCache | None = None, + *, + batch_flush_interval_seconds: float = DEFAULT_SETTLEMENT_BATCH_FLUSH_INTERVAL_SECONDS, + batch_flush_size: int = DEFAULT_SETTLEMENT_BATCH_FLUSH_SIZE, + confirmation_workers: int = DEFAULT_SETTLEMENT_CONFIRMATION_WORKERS, + confirmation_timeout_seconds: float = DEFAULT_TRACE_CONFIRMATION_TIMEOUT_SECONDS, + ) -> None: + self._signer = signer + self._settlement_cache = settlement_cache or SettlementCache() + self._batcher = _SettlementBatcher( + signer, + self._settlement_cache, + flush_interval_seconds=batch_flush_interval_seconds, + batch_flush_size=batch_flush_size, + confirmation_workers=confirmation_workers, + confirmation_timeout_seconds=confirmation_timeout_seconds, + settlement_verifier=lambda trace_data, settlement: ( + self._verify_finalized_trace_settlement( + trace_data, + settlement=settlement, + ) + ), + ) + + def get_extra(self, network: Network) -> dict[str, Any] | None: + """Get mechanism-specific extra data.""" + if str(network) not in SUPPORTED_NETWORKS: + return None + return {"areFeesSponsored": True} + + def get_signers(self, network: Network) -> list[str]: + """Get facilitator wallet addresses.""" + return self._signer.get_addresses_for_network(str(network)) + + def verify( + self, + payload: PaymentPayload, + requirements: PaymentRequirements, + context=None, + ) -> VerifyResponse: + """Verify a TON exact payment payload.""" + try: + tvm_payload = ExactTvmPayload.from_dict(payload.payload) + except ValueError as e: + return VerifyResponse( + is_valid=False, + invalid_reason=ERR_EXACT_TVM_INVALID_PAYLOAD, + invalid_message=str(e), + payer="", + ) + + try: + settlement = parse_exact_tvm_payload(tvm_payload.settlement_boc) + verification, _ = self._verify(payload, requirements, tvm_payload, settlement) + return verification + except ValueError as e: + return VerifyResponse(is_valid=False, invalid_reason=str(e), payer="") + except Exception as e: + return VerifyResponse( + is_valid=False, + invalid_reason=ERR_EXACT_TVM_SIMULATION_FAILED, + invalid_message=str(e), + payer="", + ) + + def settle( + self, + payload: PaymentPayload, + requirements: PaymentRequirements, + context=None, + ) -> SettleResponse: + """Settle a TON exact payment payload.""" + try: + tvm_payload = ExactTvmPayload.from_dict(payload.payload) + except ValueError as e: + return SettleResponse( + success=False, + error_reason=ERR_EXACT_TVM_INVALID_PAYLOAD, + error_message=str(e), + payer="", + transaction="", + network=requirements.network, + ) + + try: + settlement = parse_exact_tvm_payload(tvm_payload.settlement_boc) + verification, relay_request = self._verify( + payload, requirements, tvm_payload, settlement + ) + except ValueError as e: + return SettleResponse( + success=False, + error_reason=str(e), + payer="", + transaction="", + network=requirements.network, + ) + except Exception as e: + return SettleResponse( + success=False, + error_reason=ERR_EXACT_TVM_SIMULATION_FAILED, + error_message=str(e), + payer="", + transaction="", + network=requirements.network, + ) + if not verification.is_valid: + return SettleResponse( + success=False, + error_reason=verification.invalid_reason, + error_message=verification.invalid_message, + payer=verification.payer, + transaction="", + network=requirements.network, + ) + + if self._settlement_cache.is_duplicate( + settlement.settlement_hash, requirements.max_timeout_seconds + ): + return SettleResponse( + success=False, + error_reason=ERR_EXACT_TVM_DUPLICATE_SETTLEMENT, + payer=settlement.payer, + transaction="", + network=requirements.network, + ) + + try: + batch_result = self._batcher.enqueue( + _QueuedSettlement( + network=str(requirements.network), + settlement_hash=settlement.settlement_hash, + settlement=settlement, + relay_request=relay_request, + ) + ) + except Exception as e: + self._settlement_cache.release(settlement.settlement_hash) + batch_result = _BatchResult( + success=False, + error_reason=ERR_EXACT_TVM_TRANSACTION_FAILED, + error_message=str(e), + ) + + return SettleResponse( + success=batch_result.success, + error_reason=batch_result.error_reason, + error_message=batch_result.error_message, + payer=settlement.payer, + transaction=batch_result.transaction, + network=requirements.network, + ) + + def _verify( + self, + payload: PaymentPayload, + requirements: PaymentRequirements, + tvm_payload: ExactTvmPayload, + settlement: ParsedTvmSettlement, + ) -> tuple[VerifyResponse, TvmRelayRequest | None]: + payer = settlement.payer + + def invalid_response(reason: str) -> tuple[VerifyResponse, TvmRelayRequest | None]: + return (VerifyResponse(is_valid=False, invalid_reason=reason, payer=payer), None) + + if payload.x402_version != 2: + return invalid_response(ERR_EXACT_TVM_UNSUPPORTED_VERSION) + + if payload.accepted.scheme != SCHEME_EXACT or requirements.scheme != SCHEME_EXACT: + return invalid_response(ERR_EXACT_TVM_UNSUPPORTED_SCHEME) + + if str(requirements.network) not in SUPPORTED_NETWORKS: + return invalid_response(ERR_EXACT_TVM_UNSUPPORTED_NETWORK) + + if str(payload.accepted.network) != str(requirements.network): + return invalid_response(ERR_EXACT_TVM_NETWORK_MISMATCH) + + if int(payload.accepted.amount) != int(requirements.amount): + return invalid_response(ERR_EXACT_TVM_INVALID_AMOUNT) + + if normalize_address(payload.accepted.asset) != normalize_address(requirements.asset): + return invalid_response(ERR_EXACT_TVM_INVALID_ASSET) + + if normalize_address(payload.accepted.pay_to) != normalize_address(requirements.pay_to): + return invalid_response(ERR_EXACT_TVM_INVALID_RECIPIENT) + + if ( + payload.accepted.extra.get("areFeesSponsored") is not True + or requirements.extra.get("areFeesSponsored") is not True + ): + return invalid_response(ERR_EXACT_TVM_UNSUPPORTED_SCHEME) + + if normalize_address(tvm_payload.asset) != normalize_address(requirements.asset): + return invalid_response(ERR_EXACT_TVM_INVALID_ASSET) + + expected_response_destination = _effective_response_destination(requirements.extra) + if _effective_response_destination(payload.accepted.extra) != expected_response_destination: + return invalid_response(ERR_EXACT_TVM_INVALID_JETTON_TRANSFER) + + expected_forward_ton_amount = _effective_forward_ton_amount(requirements.extra) + if _effective_forward_ton_amount(payload.accepted.extra) != expected_forward_ton_amount: + return invalid_response(ERR_EXACT_TVM_INVALID_JETTON_TRANSFER) + + expected_forward_payload = _effective_forward_payload(requirements.extra) + if _effective_forward_payload(payload.accepted.extra).hash != expected_forward_payload.hash: + return invalid_response(ERR_EXACT_TVM_INVALID_JETTON_TRANSFER) + + # Up to this point, we've checked all fields in PaymentRequirements and PaymentPayload except for settlementBoc + + if settlement.transfer.destination != normalize_address(requirements.pay_to): + return invalid_response(ERR_EXACT_TVM_INVALID_RECIPIENT) + + if settlement.transfer.jetton_amount != int(requirements.amount): + return invalid_response(ERR_EXACT_TVM_INVALID_AMOUNT) + + if settlement.transfer.forward_ton_amount != expected_forward_ton_amount: + return invalid_response(ERR_EXACT_TVM_INVALID_JETTON_TRANSFER) + if settlement.transfer.response_destination != expected_response_destination: + return invalid_response(ERR_EXACT_TVM_INVALID_JETTON_TRANSFER) + if settlement.transfer.forward_payload.hash != expected_forward_payload.hash: + return invalid_response(ERR_EXACT_TVM_INVALID_JETTON_TRANSFER) + + now = int(time.time()) + if settlement.valid_until <= now: + return invalid_response(ERR_EXACT_TVM_INVALID_UNTIL_EXPIRED) + if settlement.valid_until > now + requirements.max_timeout_seconds: + return invalid_response(ERR_EXACT_TVM_VALID_UNTIL_TOO_FAR) + + account = self._signer.get_account_state(payer, str(requirements.network)) + init_data_parsed: W5InitData + + if account.is_frozen: + return invalid_response(ERR_EXACT_TVM_ACCOUNT_FROZEN) + + if settlement.state_init is not None and account.is_uninitialized: + if ( + settlement.state_init.code is None + or settlement.state_init.code.hash.hex() not in ALLOWED_CLIENT_CODES + ): + return invalid_response(ERR_EXACT_TVM_INVALID_CODE_HASH) + payer_workchain = int(payer.split(":", 1)[0]) + if address_from_state_init(settlement.state_init, payer_workchain) != payer: + return invalid_response(ERR_EXACT_TVM_INVALID_W5_MESSAGE) + init_data_parsed = parse_w5_init_data(settlement.state_init) + if init_data_parsed.seqno != 0: + return invalid_response(ERR_EXACT_TVM_INVALID_SEQNO) + if init_data_parsed.extensions_dict: + return invalid_response(ERR_EXACT_TVM_INVALID_EXTENSIONS_DICT) + else: + try: + init_data_parsed = parse_active_w5_account_state(account) + except RuntimeError: + return invalid_response(ERR_EXACT_TVM_INVALID_CODE_HASH) + + if not init_data_parsed.signature_allowed: + return invalid_response(ERR_EXACT_TVM_INVALID_SIGNATURE_MODE) + if init_data_parsed.seqno != settlement.seqno: + return invalid_response(ERR_EXACT_TVM_INVALID_SEQNO) + if init_data_parsed.wallet_id != settlement.wallet_id: + return invalid_response(ERR_EXACT_TVM_INVALID_WALLET_ID) + + if not verify_w5_signature( + init_data_parsed.public_key, + settlement.signed_slice_hash, + settlement.signature, + ): + return invalid_response(ERR_EXACT_TVM_INVALID_SIGNATURE) + + canonical_source_wallet = normalize_address( + self._signer.get_jetton_wallet( + requirements.asset, + payer, + str(requirements.network), + ) + ) + if normalize_address(settlement.transfer.source_wallet) != canonical_source_wallet: + return invalid_response(ERR_EXACT_TVM_INVALID_JETTON_TRANSFER) + + jetton_wallet_data = self._signer.get_jetton_wallet_data( + settlement.transfer.source_wallet, + str(requirements.network), + ) + if normalize_address(jetton_wallet_data.owner) != payer: + return invalid_response(ERR_EXACT_TVM_INVALID_RECIPIENT) + if normalize_address(jetton_wallet_data.jetton_minter) != normalize_address( + requirements.asset + ): + return invalid_response(ERR_EXACT_TVM_INVALID_ASSET) + if jetton_wallet_data.balance < settlement.transfer.jetton_amount: + return invalid_response(ERR_EXACT_TVM_INSUFFICIENT_BALANCE) + + try: + provisional_relay_request = TvmRelayRequest( + destination=settlement.payer, + body=settlement.body, + state_init=settlement.state_init, + forward_ton_amount=settlement.transfer.forward_ton_amount, + ) + external_boc = self._signer.build_relay_external_boc( + requirements.network, + provisional_relay_request, + for_emulation=True, + ) + emulation = self._signer.emulate_external_message(requirements.network, external_boc) + payer_transaction = self._verify_finalized_trace_settlement( + emulation, + settlement=settlement, + return_transaction=True, + ) + actual_inner = settlement.transfer.attached_ton_amount + required_outer = ( + actual_inner + + trace_transaction_storage_fees(payer_transaction) + + trace_transaction_compute_fees(payer_transaction) + + trace_transaction_fwd_fees(payer_transaction) + + DEFAULT_TVM_OUTER_GAS_BUFFER + ) + relay_request = TvmRelayRequest( + destination=settlement.payer, + body=settlement.body, + state_init=settlement.state_init, + forward_ton_amount=settlement.transfer.forward_ton_amount, + relay_amount=required_outer, + ) + except Exception as e: + return ( + VerifyResponse( + is_valid=False, + invalid_reason=ERR_EXACT_TVM_SIMULATION_FAILED, + invalid_message=str(e), + payer=payer, + ), + None, + ) + + return (VerifyResponse(is_valid=True, payer=payer), relay_request) + + @staticmethod + def _verify_finalized_trace_settlement( + trace_data: dict[str, object], + *, + settlement: ParsedTvmSettlement, + return_transaction: bool = False, + ) -> str | dict[str, object]: + transactions = parse_trace_transactions(trace_data) + expected_source_wallet = normalize_address(settlement.transfer.source_wallet) + + payer_transaction = None + for transaction in transactions: + if normalize_address(transaction["account"]) != settlement.payer: + continue + if not transaction_succeeded(transaction): + continue + in_msg: dict = transaction.get("in_msg") + if not message_body_hash_matches(in_msg, settlement.body.hash): + continue + payer_transaction = transaction + break + if payer_transaction is None: + raise ValueError("Trace does not contain the expected payer wallet transaction") + + out_msgs: list[dict] = payer_transaction.get("out_msgs") + payer_out_hash = None + for out_msg in out_msgs: + if normalize_address(out_msg["destination"]) != expected_source_wallet: + continue + if not message_body_hash_matches(out_msg, settlement.transfer.body_hash): + continue + payer_out_hash = out_msg["hash"] + break + if payer_out_hash is None: + raise ValueError("Trace payer wallet transaction is missing out message hash") + + # According to TEP-74, it is sufficient to check the success of the transaction on the payer's jetton wallet + source_wallet_transaction = None + for transaction in transactions: + if normalize_address(transaction["account"]) != expected_source_wallet: + continue + if not transaction_succeeded(transaction): + continue + in_msg: dict = transaction.get("in_msg") + if not in_msg: + continue + if in_msg.get("hash") == payer_out_hash: + source_wallet_transaction = transaction + break + if source_wallet_transaction is None: + raise ValueError("Trace does not contain the expected source jetton wallet transaction") + + transaction_hash = payer_transaction.get("hash_norm") or payer_transaction.get("hash") + if not transaction_hash: + raise ValueError("Trace payer wallet transaction is missing transaction hash") + return ( + payer_transaction + if return_transaction + else trace_transaction_hash_to_hex(transaction_hash) + ) diff --git a/python/x402/mechanisms/tvm/exact/register.py b/python/x402/mechanisms/tvm/exact/register.py new file mode 100644 index 0000000000..f012e00d14 --- /dev/null +++ b/python/x402/mechanisms/tvm/exact/register.py @@ -0,0 +1,84 @@ +"""Registration helpers for TVM exact payment schemes.""" + +from typing import TYPE_CHECKING, TypeVar + +from ..constants import SUPPORTED_NETWORKS + +if TYPE_CHECKING: + from x402 import ( + x402Client, + x402ClientSync, + x402Facilitator, + x402FacilitatorSync, + x402ResourceServer, + x402ResourceServerSync, + ) + + from ..signer import ClientTvmSigner, FacilitatorTvmSigner + +ClientT = TypeVar("ClientT", "x402Client", "x402ClientSync") +ServerT = TypeVar("ServerT", "x402ResourceServer", "x402ResourceServerSync") +FacilitatorT = TypeVar("FacilitatorT", "x402Facilitator", "x402FacilitatorSync") + + +def register_exact_tvm_client( + client: ClientT, + signer: "ClientTvmSigner", + networks: str | list[str] | None = None, + policies: list | None = None, +) -> ClientT: + """Register TVM exact payment schemes to x402Client.""" + from .client import ExactTvmScheme as ExactTvmClientScheme + + scheme = ExactTvmClientScheme(signer) + + if networks is None: + networks = [signer.network] + elif isinstance(networks, str): + networks = [networks] + + for network in networks: + client.register(network, scheme) + + if policies: + for policy in policies: + client.register_policy(policy) + + return client + + +def register_exact_tvm_server( + server: ServerT, + networks: str | list[str] | None = None, +) -> ServerT: + """Register TVM exact payment schemes to x402ResourceServer.""" + from .server import ExactTvmScheme as ExactTvmServerScheme + + scheme = ExactTvmServerScheme() + + if networks is None: + networks = list(SUPPORTED_NETWORKS) + elif isinstance(networks, str): + networks = [networks] + + for network in networks: + server.register(network, scheme) + + return server + + +def register_exact_tvm_facilitator( + facilitator: FacilitatorT, + signer: "FacilitatorTvmSigner", + networks: str | list[str], +) -> FacilitatorT: + """Register TVM exact payment schemes to x402Facilitator.""" + from ..settlement_cache import SettlementCache + from .facilitator import ExactTvmScheme as ExactTvmFacilitatorScheme + + scheme = ExactTvmFacilitatorScheme(signer, SettlementCache()) + + if isinstance(networks, str): + networks = [networks] + facilitator.register(networks, scheme) + return facilitator diff --git a/python/x402/mechanisms/tvm/exact/server.py b/python/x402/mechanisms/tvm/exact/server.py new file mode 100644 index 0000000000..df30d7552c --- /dev/null +++ b/python/x402/mechanisms/tvm/exact/server.py @@ -0,0 +1,148 @@ +"""TVM server implementation for the Exact payment scheme (V2).""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from decimal import Decimal + +from ....schemas import AssetAmount, Network, PaymentRequirements, Price, SupportedKind +from ..codecs.common import ( + encode_base64_boc, + make_zero_bit_cell, + normalize_address, + parse_amount, + parse_money_to_decimal, +) +from ..constants import ( + DEFAULT_DECIMALS, + SCHEME_EXACT, + TVM_MAINNET, + TVM_TESTNET, + USDT_MAINNET_MINTER, + USDT_TESTNET_MINTER, +) + +MoneyParser = Callable[[float, str], AssetAmount | None] + + +class ExactTvmScheme: + """TVM server implementation for the Exact payment scheme (V2).""" + + scheme = SCHEME_EXACT + + def __init__(self) -> None: + self._money_parsers: list[MoneyParser] = [] + + def register_money_parser(self, parser: MoneyParser) -> ExactTvmScheme: + """Register a custom money parser.""" + self._money_parsers.append(parser) + return self + + def parse_price(self, price: Price, network: Network) -> AssetAmount: + """Parse price into a normalized AssetAmount.""" + if isinstance(price, dict) and "amount" in price: + if not price.get("asset"): + raise ValueError(f"Asset address required for AssetAmount on {network}") + return AssetAmount( + amount=price["amount"], + asset=normalize_address(price["asset"]), + extra=price.get("extra", {}), + ) + + if isinstance(price, AssetAmount): + if not price.asset: + raise ValueError(f"Asset address required for AssetAmount on {network}") + return AssetAmount( + amount=price.amount, + asset=normalize_address(price.asset), + extra=price.extra, + ) + + if isinstance(price, int): + exact_decimal_amount = Decimal(price) + elif isinstance(price, float): + exact_decimal_amount = Decimal(str(price)) + else: + clean = price.strip() + clean = clean.lstrip("$") + clean = re.sub(r"\s*(USD|USDT|usd|usdt)\s*$", "", clean) + exact_decimal_amount = Decimal(clean.strip()) + + decimal_amount = parse_money_to_decimal(price) + for parser in self._money_parsers: + result = parser(decimal_amount, str(network)) + if result is not None: + return result + + return self._default_money_conversion(exact_decimal_amount, str(network)) + + def enhance_payment_requirements( + self, + requirements: PaymentRequirements, + supported_kind: SupportedKind, + extension_keys: list[str], + ) -> PaymentRequirements: + """Add TVM-specific fields to payment requirements.""" + _ = extension_keys + + if not requirements.asset: + requirements.asset = self._get_default_asset(str(requirements.network)) + requirements.asset = normalize_address(requirements.asset) + requirements.pay_to = normalize_address(requirements.pay_to) + + if "." in requirements.amount: + requirements.amount = str( + parse_amount(requirements.amount, self._get_asset_decimals(requirements)) + ) + + if requirements.extra is None: + requirements.extra = {} + if ( + "responseDestination" in requirements.extra + and requirements.extra["responseDestination"] is not None + ): + requirements.extra["responseDestination"] = normalize_address( + requirements.extra["responseDestination"] + ) + if "areFeesSponsored" not in requirements.extra: + requirements.extra["areFeesSponsored"] = (supported_kind.extra or {}).get( + "areFeesSponsored", + True, + ) + + return requirements + + def _default_money_conversion(self, amount: Decimal, network: str) -> AssetAmount: + return AssetAmount( + amount=str(parse_amount(format(amount, "f"), DEFAULT_DECIMALS)), + asset=self._get_default_asset(network), + extra={ + "areFeesSponsored": True, + "forwardPayload": encode_base64_boc(make_zero_bit_cell()), + "forwardTonAmount": "0", + }, + ) + + def _get_default_asset(self, network: str) -> str: + if network == TVM_MAINNET: + return USDT_MAINNET_MINTER + if network == TVM_TESTNET: + return USDT_TESTNET_MINTER + raise ValueError( + f"No default stablecoin configured for network {network}; specify an explicit asset" + ) + + def _get_asset_decimals(self, requirements: PaymentRequirements) -> int: + extra = requirements.extra or {} + if "decimals" in extra: + return int(extra["decimals"]) + if normalize_address(requirements.asset) in { + USDT_MAINNET_MINTER, + USDT_TESTNET_MINTER, + }: + return DEFAULT_DECIMALS + raise ValueError( + f"Token {requirements.asset} is not a registered asset for network " + f"{requirements.network}; provide amount in atomic units or extra.decimals" + ) diff --git a/python/x402/mechanisms/tvm/exact/settlement_batcher.py b/python/x402/mechanisms/tvm/exact/settlement_batcher.py new file mode 100644 index 0000000000..ca1603af14 --- /dev/null +++ b/python/x402/mechanisms/tvm/exact/settlement_batcher.py @@ -0,0 +1,220 @@ +"""Internal batching helpers for TVM exact settlement relay.""" + +from __future__ import annotations + +import queue +import threading +import time +from collections.abc import Callable +from dataclasses import dataclass, field + +from ..constants import ( + DEFAULT_SETTLEMENT_BATCH_MAX_SIZE, + DEFAULT_SETTLEMENT_CONFIRMATION_WORKERS, + ERR_EXACT_TVM_SIMULATION_FAILED, + ERR_EXACT_TVM_TRANSACTION_FAILED, +) +from ..settlement_cache import SettlementCache +from ..signer import FacilitatorTvmSigner +from ..types import ParsedTvmSettlement, TvmRelayRequest + + +@dataclass +class _BatchResult: + success: bool + transaction: str = "" + error_reason: str | None = None + error_message: str | None = None + + +@dataclass +class _QueuedSettlement: + network: str + settlement_hash: str + settlement: ParsedTvmSettlement + relay_request: TvmRelayRequest + completed: threading.Event = field(default_factory=threading.Event) + result: _BatchResult | None = None + + +@dataclass +class _PendingConfirmation: + network: str + batch: list[_QueuedSettlement] + trace_external_hash_norm: str + + +class _SettlementBatcher: + def __init__( + self, + signer: FacilitatorTvmSigner, + settlement_cache: SettlementCache, + *, + flush_interval_seconds: float, + batch_flush_size: int, + confirmation_workers: int = DEFAULT_SETTLEMENT_CONFIRMATION_WORKERS, + confirmation_timeout_seconds: float, + settlement_verifier: Callable[[dict[str, object], ParsedTvmSettlement], str], + ) -> None: + self._signer = signer + self._settlement_cache = settlement_cache + self._flush_interval_seconds = flush_interval_seconds + self._batch_flush_size = batch_flush_size + self._max_batch_size = DEFAULT_SETTLEMENT_BATCH_MAX_SIZE + self._confirmation_timeout_seconds = confirmation_timeout_seconds + self._settlement_verifier = settlement_verifier + if confirmation_workers < 1: + raise ValueError("confirmation_workers must be at least 1") + self._lock = threading.Lock() + self._condition = threading.Condition(self._lock) + self._confirmation_queue: queue.SimpleQueue[_PendingConfirmation] = queue.SimpleQueue() + self._queues: dict[str, list[_QueuedSettlement]] = {} + self._deadlines: dict[str, float] = {} + self._worker = threading.Thread( + target=self._run, name="tvm-settlement-batcher", daemon=True + ) + self._worker.start() + self._confirmation_workers = [ + threading.Thread( + target=self._run_confirmation_worker, + name=f"tvm-settlement-confirmation-{idx}", + daemon=True, + ) + for idx in range(confirmation_workers) + ] + for worker in self._confirmation_workers: + worker.start() + + def enqueue(self, queued_settlement: _QueuedSettlement) -> _BatchResult: + with self._condition: + queue = self._queues.setdefault(queued_settlement.network, []) + queue.append(queued_settlement) + if len(queue) == 1: + self._deadlines[queued_settlement.network] = ( + time.monotonic() + self._flush_interval_seconds + ) + elif len(queue) >= self._batch_flush_size: + self._deadlines[queued_settlement.network] = time.monotonic() + self._condition.notify_all() + + queued_settlement.completed.wait() + assert queued_settlement.result is not None + return queued_settlement.result + + def _run(self) -> None: + while True: + with self._condition: + network, batch = self._wait_for_ready_batch_locked() + self._flush_batch(network, batch) + + def _wait_for_ready_batch_locked(self) -> tuple[str, list[_QueuedSettlement]]: + while True: + now = time.monotonic() + for network, deadline in list(self._deadlines.items()): + queue = self._queues.get(network) + if queue and deadline <= now: + batch_size = min(len(queue), self._max_batch_size) + batch = queue[:batch_size] + del queue[:batch_size] + if queue: + self._deadlines[network] = ( + now + if len(queue) >= self._batch_flush_size + else now + self._flush_interval_seconds + ) + self._condition.notify_all() + else: + self._queues.pop(network, None) + self._deadlines.pop(network, None) + return network, batch + self._condition.wait(timeout=self._next_wait_timeout_locked()) + + def _next_wait_timeout_locked(self) -> float | None: + if not self._deadlines: + return None + return max(0.0, min(self._deadlines.values()) - time.monotonic()) + + def _flush_batch(self, network: str, batch: list[_QueuedSettlement]) -> None: + try: + external_boc = self._signer.build_relay_external_boc_batch( + network, + [queued.relay_request for queued in batch], + ) + trace_external_hash_norm = self._signer.send_external_message(network, external_boc) + except Exception as exc: + for queued in batch: + self._fail_queued_settlement( + queued, + error_reason=( + ERR_EXACT_TVM_SIMULATION_FAILED + if isinstance(exc, ValueError) + else ERR_EXACT_TVM_TRANSACTION_FAILED + ), + error_message=str(exc), + ) + return + + self._confirmation_queue.put( + _PendingConfirmation( + network=network, + batch=batch, + trace_external_hash_norm=trace_external_hash_norm, + ) + ) + + def _run_confirmation_worker(self) -> None: + while True: + pending = self._confirmation_queue.get() + try: + finalized_trace = self._signer.wait_for_trace_confirmation( + pending.network, + pending.trace_external_hash_norm, + timeout_seconds=self._confirmation_timeout_seconds, + ) + except Exception as exc: + for queued in pending.batch: + self._fail_queued_settlement( + queued, + error_reason=( + ERR_EXACT_TVM_SIMULATION_FAILED + if isinstance(exc, ValueError) + else ERR_EXACT_TVM_TRANSACTION_FAILED + ), + error_message=str(exc), + ) + continue + + for queued in pending.batch: + try: + transaction_hash = self._settlement_verifier( + finalized_trace, + queued.settlement, + ) + queued.result = _BatchResult( + success=True, + transaction=transaction_hash, + ) + except Exception as exc: + self._fail_queued_settlement( + queued, + error_reason=ERR_EXACT_TVM_TRANSACTION_FAILED, + error_message=str(exc), + ) + continue + queued.completed.set() + + def _fail_queued_settlement( + self, + queued: _QueuedSettlement, + *, + error_reason: str, + error_message: str, + ) -> None: + queued.result = _BatchResult( + success=False, + transaction="", + error_reason=error_reason, + error_message=error_message, + ) + queued.completed.set() + self._settlement_cache.release(queued.settlement_hash) diff --git a/python/x402/mechanisms/tvm/provider.py b/python/x402/mechanisms/tvm/provider.py new file mode 100644 index 0000000000..e707e21134 --- /dev/null +++ b/python/x402/mechanisms/tvm/provider.py @@ -0,0 +1,273 @@ +"""Toncenter-backed TVM RPC client.""" + +from __future__ import annotations + +import base64 +import logging +import time +from typing import Any + +from .codecs.common import address_to_stack_item, normalize_address +from .constants import ( + DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS, + DEFAULT_TONCENTER_TIMEOUT_SECONDS, + TONCENTER_MAINNET_BASE_URL, + TONCENTER_TESTNET_BASE_URL, + TVM_MAINNET, + TVM_TESTNET, +) +from .types import TvmAccountState, TvmJettonWalletData + +try: + import httpx + from pytoniq_core import Cell + from pytoniq_core.tlb.account import StateInit +except ImportError as e: + raise ImportError( + "TVM mechanism requires pytoniq packages and httpx. Install with: pip install x402[tvm,httpx]" + ) from e + +logger = logging.getLogger(__name__) + +_MAX_LOGGED_RESPONSE_BODY_LENGTH = 512 + + +class ToncenterRestClient: + """Minimal Toncenter v3 client used by the TVM mechanism.""" + + def __init__( + self, + network: str, + *, + api_key: str | None = None, + base_url: str | None = None, + timeout: float = DEFAULT_TONCENTER_TIMEOUT_SECONDS, + ) -> None: + root_url = (base_url or _default_base_url(network)).rstrip("/") + headers = {"Accept": "application/json"} + if api_key: + headers["X-Api-Key"] = api_key + + self._client = httpx.Client(base_url=root_url, headers=headers, timeout=timeout) + + def get_account_state(self, address: str) -> TvmAccountState: + address = normalize_address(address) + response = self._request( + "GET", + "/api/v3/accountStates", + params={"address": [address], "include_boc": "true"}, + ) + accounts = response.get("accounts") or [] + if not accounts: + return TvmAccountState( + address=address, + balance=0, + is_active=False, + is_uninitialized=True, + is_frozen=False, + state_init=None, + ) + + account = accounts[0] + status = str(account.get("status") or "") + state_init = None + code_boc = account.get("code_boc") + data_boc = account.get("data_boc") + if status == "active" and isinstance(code_boc, str) and isinstance(data_boc, str): + state_init = StateInit( + code=Cell.one_from_boc(base64.b64decode(code_boc)), + data=Cell.one_from_boc(base64.b64decode(data_boc)), + ) + + return TvmAccountState( + address=address, + balance=int(account.get("balance") or 0), + is_active=status == "active", + is_uninitialized=status in {"uninit", "nonexist"}, + is_frozen=status == "frozen", + state_init=state_init, + ) + + def close(self) -> None: + """Close the underlying HTTP client.""" + self._client.close() + + def get_jetton_wallet(self, asset: str, owner: str) -> str: + result = self.run_get_method(asset, "get_wallet_address", [address_to_stack_item(owner)]) + return self._parse_stack_address(result[0]) + + def get_jetton_wallet_data(self, address: str) -> TvmJettonWalletData: + result = self.run_get_method(address, "get_wallet_data", []) + if len(result) < 3: + raise RuntimeError("Toncenter get_wallet_data returned an incomplete stack") + + return TvmJettonWalletData( + address=normalize_address(address), + balance=self._parse_stack_num(result[0]), + owner=self._parse_stack_address(result[1]), + jetton_minter=self._parse_stack_address(result[2]), + ) + + def send_message(self, boc: bytes) -> str: + response = self._request( + "POST", + "/api/v3/message", + json={"boc": base64.b64encode(boc).decode("utf-8")}, + ) + return str(response.get("message_hash_norm") or response["message_hash"]) + + def emulate_trace( + self, + boc: bytes, + *, + ignore_chksig: bool = False, + timeout: float = DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS, + ) -> dict[str, Any]: + response = self._request( + "POST", + "/api/emulate/v1/emulateTrace", + json={ + "boc": base64.b64encode(boc).decode("utf-8"), + "ignore_chksig": ignore_chksig, + "with_actions": True, + }, + timeout=timeout, + ) + if not isinstance(response, dict): + raise RuntimeError("Toncenter returned an invalid emulateTrace response") + return response + + def get_trace_by_message_hash(self, message_hash: str) -> dict[str, Any]: + response = self._request( + "GET", + "/api/v3/traces", + params={ + "msg_hash": [message_hash], + "limit": 1, + "sort": "desc", + }, + ) + traces = response.get("traces") + if not isinstance(traces, list): + raise RuntimeError("Toncenter returned an invalid traces response") + for trace in traces: + if isinstance(trace, dict): + return trace + raise RuntimeError(f"Toncenter returned no trace for message hash {message_hash}") + + def run_get_method( + self, + address: str, + method: str, + stack: list[dict[str, object]], + ) -> list[dict[str, object]]: + response = self._request( + "POST", + "/api/v3/runGetMethod", + json={ + "address": normalize_address(address), + "method": method, + "stack": stack, + }, + ) + if int(response.get("exit_code", 0)) != 0: + raise RuntimeError( + f"Toncenter get-method {method} failed with exit code {response['exit_code']}" + ) + + result = response.get("stack") + if not isinstance(result, list): + raise RuntimeError(f"Toncenter returned an invalid stack for get-method {method}") + return [item for item in result if isinstance(item, dict)] + + def _parse_stack_address(self, item: dict[str, object]) -> str: + cell = self._parse_stack_cell(item) + address = cell.begin_parse().load_address() + return normalize_address(address) + + def _parse_stack_cell(self, item: dict[str, object]) -> Cell: + value = item.get("value") + if not value: + raise RuntimeError(f"Can't parse cell stack value") + return Cell.one_from_boc(base64.b64decode(value)) + + def _parse_stack_num(self, item: dict[str, object]) -> int: + value = item.get("value") + return int(value, 0) + + def _request(self, method: str, path: str, **kwargs: object) -> dict[str, Any]: + attempts = 5 + backoff_seconds = 0.25 + last_error: Exception | None = None + for attempt in range(attempts): + try: + response = self._client.request(method, path, **kwargs) + response.raise_for_status() + data = response.json() + if not isinstance(data, dict): + raise RuntimeError(f"Toncenter returned a non-object response for {path}") + return data + except httpx.HTTPStatusError as exc: + last_error = exc + retryable = exc.response.status_code in {429, 500, 502, 503, 504} + logger.warning( + "Toncenter request failed: method=%s path=%s url=%s status=%s " + "attempt=%s/%s retryable=%s body=%r", + method, + path, + str(exc.request.url), + exc.response.status_code, + attempt + 1, + attempts, + retryable, + _truncate_response_body(exc.response.text), + ) + if not retryable or attempt == attempts - 1: + raise + retry_after = exc.response.headers.get("Retry-After") + if retry_after: + logger.info( + "Toncenter request backing off per Retry-After: method=%s path=%s " + "retry_after=%s attempt=%s/%s", + method, + path, + retry_after, + attempt + 1, + attempts, + ) + try: + time.sleep(float(retry_after)) + continue + except ValueError: + pass + except httpx.RequestError as exc: + last_error = exc + logger.warning( + "Toncenter request transport error: method=%s path=%s attempt=%s/%s error=%r", + method, + path, + attempt + 1, + attempts, + exc, + ) + if attempt == attempts - 1: + raise + time.sleep(backoff_seconds * (attempt + 1)) + + if last_error is not None: + raise last_error + raise RuntimeError(f"Toncenter request for {path} failed without an exception") + + +def _default_base_url(network: str) -> str: + if network == TVM_MAINNET: + return TONCENTER_MAINNET_BASE_URL + if network == TVM_TESTNET: + return TONCENTER_TESTNET_BASE_URL + raise ValueError(f"Unsupported TVM network: {network}") + + +def _truncate_response_body(body: str) -> str: + if len(body) <= _MAX_LOGGED_RESPONSE_BODY_LENGTH: + return body + return body[:_MAX_LOGGED_RESPONSE_BODY_LENGTH] + "..." diff --git a/python/x402/mechanisms/tvm/settlement_cache.py b/python/x402/mechanisms/tvm/settlement_cache.py new file mode 100644 index 0000000000..26c1b25851 --- /dev/null +++ b/python/x402/mechanisms/tvm/settlement_cache.py @@ -0,0 +1,45 @@ +"""Thread-safe in-memory cache for deduplicating concurrent settlement requests.""" + +from __future__ import annotations + +import threading +import time + + +class SettlementCache: + """In-memory cache for deduplicating concurrent settlement requests. + + Each entry carries its own TTL because TVM settlement validity depends on the + request-specific timeout window. + """ + + def __init__(self) -> None: + self._entries: dict[str, float] = {} + self._lock = threading.Lock() + + def is_duplicate(self, key: str, ttl_seconds: float) -> bool: + """Return ``True`` if *key* is already pending settlement (duplicate). + + When ``False`` the key is recorded as newly pending. + Callers should reject the settlement when this returns ``True``. + """ + now = time.monotonic() + expires_at = now + max(0.0, ttl_seconds) + with self._lock: + self._prune(now) + if key in self._entries: + return True + self._entries[key] = expires_at + return False + + def release(self, key: str) -> None: + """Remove *key* from the pending settlement set.""" + now = time.monotonic() + with self._lock: + self._prune(now) + self._entries.pop(key, None) + + def _prune(self, now: float) -> None: + expired = [key for key, expires_at in self._entries.items() if expires_at <= now] + for key in expired: + del self._entries[key] diff --git a/python/x402/mechanisms/tvm/signer.py b/python/x402/mechanisms/tvm/signer.py new file mode 100644 index 0000000000..2c3eaf2cf3 --- /dev/null +++ b/python/x402/mechanisms/tvm/signer.py @@ -0,0 +1,102 @@ +"""TVM signer protocol definitions.""" + +from __future__ import annotations + +from typing import Protocol + +from .types import TvmAccountState, TvmJettonWalletData, TvmRelayRequest + +try: + from pytoniq_core.tlb.account import StateInit +except ImportError as e: + raise ImportError( + "TVM mechanism requires pytoniq packages. Install with: pip install x402[tvm]" + ) from e + + +class FacilitatorTvmSigner(Protocol): + """Facilitator-side TVM signer for verification and settlement.""" + + def get_addresses(self) -> list[str]: + """Get all facilitator wallet addresses.""" + ... + + def get_addresses_for_network(self, network: str) -> list[str]: + """Get facilitator wallet addresses available for one TVM network.""" + ... + + def get_account_state(self, address: str, network: str) -> TvmAccountState: + """Get account state for a wallet or jetton wallet.""" + ... + + def get_jetton_wallet(self, asset: str, owner: str, network: str) -> str: + """Resolve the canonical TEP-74 jetton wallet for an owner.""" + ... + + def build_relay_external_boc( + self, + network: str, + relay_request: TvmRelayRequest, + *, + for_emulation: bool = False, + ) -> bytes: + """Build a Highload V3 external message for relaying the pre-signed W5 message.""" + ... + + def build_relay_external_boc_batch( + self, + network: str, + relay_requests: list[TvmRelayRequest], + ) -> bytes: + """Build one Highload V3 external message that relays multiple requests.""" + ... + + def emulate_external_message(self, network: str, external_boc: bytes) -> dict[str, object]: + """Emulate a prepared external message through Toncenter.""" + ... + + def send_external_message(self, network: str, external_boc: bytes) -> str: + """Broadcast a prepared external message through Toncenter.""" + ... + + def wait_for_trace_confirmation( + self, + network: str, + trace_external_hash_norm: str, + *, + timeout_seconds: float, + ) -> dict[str, object]: + """Wait until a submitted trace reaches finalized and return its payload.""" + ... + + def get_jetton_wallet_data(self, address: str, network: str) -> TvmJettonWalletData: + """Read TEP-74 jetton wallet data for a wallet address.""" + ... + + +class ClientTvmSigner(Protocol): + """Client-side TVM signer for W5 exact payments.""" + + @property + def address(self) -> str: + """The signer's W5 wallet address in raw format.""" + ... + + @property + def network(self) -> str: + """The CAIP-2 TVM network this signer is configured for.""" + ... + + @property + def wallet_id(self) -> int: + """The W5 wallet_id used in signed requests.""" + ... + + @property + def state_init(self) -> StateInit: + """The wallet StateInit used for first deployment.""" + ... + + def sign_message(self, message: bytes) -> bytes: + """Sign a W5 request body hash.""" + ... diff --git a/python/x402/mechanisms/tvm/signers.py b/python/x402/mechanisms/tvm/signers.py new file mode 100644 index 0000000000..0edd5d3a72 --- /dev/null +++ b/python/x402/mechanisms/tvm/signers.py @@ -0,0 +1,626 @@ +"""Concrete TVM signer implementations.""" + +from __future__ import annotations + +import base64 +import binascii +import threading +import time +from dataclasses import dataclass +from secrets import randbelow + +from .codecs.common import normalize_address +from .codecs.highload_v3 import ( + MAX_USABLE_QUERY_SEQNO, + load_highload_query_state, + query_id_is_processed, + seqno_to_query_id, + serialize_internal_transfer, +) +from .codecs.w5 import ( + address_from_state_init, + build_w5r1_state_init, + make_w5r1_wallet_id, + serialize_out_list, + serialize_send_msg_action, +) +from .constants import ( + DEFAULT_HIGHLOAD_SUBWALLET_ID, + DEFAULT_HIGHLOAD_TIMEOUT, + DEFAULT_RELAY_AMOUNT, + DEFAULT_STREAMING_CONFIRMATION_GRACE_SECONDS, + DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS, + DEFAULT_TONCENTER_TIMEOUT_SECONDS, + DEFAULT_W5R1_SUBWALLET_NUMBER, + HIGHLOAD_V3_CODE_HASH, + HIGHLOAD_V3_CODE_HEX, + TONCENTER_MAINNET_BASE_URL, + TONCENTER_TESTNET_BASE_URL, + TVM_MAINNET, + TVM_TESTNET, +) +from .provider import ToncenterRestClient +from .streaming import ToncenterStreamingSseClient, ToncenterStreamingWatcher +from .types import TvmAccountState, TvmJettonWalletData, TvmRelayRequest + +try: + from nacl.signing import SigningKey + from pytoniq.contract.contract import Contract + from pytoniq_core import Address, Cell, begin_cell + from pytoniq_core.crypto.keys import mnemonic_to_wallet_key, private_key_to_public_key + from pytoniq_core.crypto.signature import sign_message + from pytoniq_core.tlb.account import StateInit +except ImportError as e: + raise ImportError( + "TVM mechanism requires pytoniq packages. Install with: pip install x402[tvm]" + ) from e + + +DEFAULT_TRACE_FETCH_BACKOFF_SECONDS = 0.5 + + +def _normalize_private_key_bytes(private_key: bytes) -> bytes: + """Normalize a TVM private key to the 64-byte secret key format used by pytoniq.""" + if len(private_key) == 64: + return private_key + if len(private_key) == 32: + return private_key + SigningKey(private_key).verify_key.encode() + raise ValueError("TVM private key must be 32 bytes (seed) or 64 bytes (secret key)") + + +def _parse_private_key(private_key: str | bytes) -> bytes: + """Parse a TVM private key from raw bytes, hex, or base64 text.""" + if isinstance(private_key, bytes): + return _normalize_private_key_bytes(private_key) + + value = private_key.strip() + if value.startswith("0x"): + value = value[2:] + + try: + return _normalize_private_key_bytes(bytes.fromhex(value)) + except ValueError: + pass + + try: + return _normalize_private_key_bytes(base64.b64decode(value, validate=True)) + except (ValueError, binascii.Error) as exc: + raise ValueError("TVM private key must be valid hex or base64") from exc + + +@dataclass +class HighloadV3Config: + """Configuration for one facilitator wallet on a TVM network.""" + + secret_key: bytes + api_key: str | None = None + subwallet_id: int = DEFAULT_HIGHLOAD_SUBWALLET_ID + timeout: int = DEFAULT_HIGHLOAD_TIMEOUT + relay_amount: int = DEFAULT_RELAY_AMOUNT + toncenter_base_url: str | None = None + toncenter_timeout_seconds: float = DEFAULT_TONCENTER_TIMEOUT_SECONDS + toncenter_emulation_timeout_seconds: float = DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS + workchain: int = 0 + + @classmethod + def from_mnemonic( + cls, + mnemonic: str | list[str], + *, + subwallet_id: int = DEFAULT_HIGHLOAD_SUBWALLET_ID, + timeout: int = DEFAULT_HIGHLOAD_TIMEOUT, + relay_amount: int = DEFAULT_RELAY_AMOUNT, + workchain: int = 0, + ) -> HighloadV3Config: + """Create config from a TON mnemonic.""" + if isinstance(mnemonic, str): + mnemonic = mnemonic.split() + _, secret_key = mnemonic_to_wallet_key(mnemonic) + return cls( + secret_key=secret_key, + subwallet_id=subwallet_id, + timeout=timeout, + relay_amount=relay_amount, + workchain=workchain, + ) + + @classmethod + def from_private_key( + cls, + private_key: str | bytes, + *, + subwallet_id: int = DEFAULT_HIGHLOAD_SUBWALLET_ID, + timeout: int = DEFAULT_HIGHLOAD_TIMEOUT, + relay_amount: int = DEFAULT_RELAY_AMOUNT, + workchain: int = 0, + ) -> HighloadV3Config: + """Create config from a TVM private key.""" + return cls( + secret_key=_parse_private_key(private_key), + subwallet_id=subwallet_id, + timeout=timeout, + relay_amount=relay_amount, + workchain=workchain, + ) + + +@dataclass +class WalletV5R1Config: + """Configuration for one client-side W5R1 wallet.""" + + network: str + secret_key: bytes + api_key: str | None = None + base_url: str | None = None + subwallet_number: int = DEFAULT_W5R1_SUBWALLET_NUMBER + toncenter_timeout_seconds: float = DEFAULT_TONCENTER_TIMEOUT_SECONDS + toncenter_emulation_timeout_seconds: float = DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS + workchain: int = 0 + + @classmethod + def from_mnemonic( + cls, + network: str, + mnemonic: str | list[str], + *, + subwallet_number: int = DEFAULT_W5R1_SUBWALLET_NUMBER, + workchain: int = 0, + ) -> WalletV5R1Config: + """Create config from a TON mnemonic.""" + if isinstance(mnemonic, str): + mnemonic = mnemonic.split() + _, secret_key = mnemonic_to_wallet_key(mnemonic) + return cls( + network=network, + secret_key=secret_key, + subwallet_number=subwallet_number, + workchain=workchain, + ) + + @classmethod + def from_private_key( + cls, + network: str, + private_key: str | bytes, + *, + subwallet_number: int = DEFAULT_W5R1_SUBWALLET_NUMBER, + workchain: int = 0, + ) -> WalletV5R1Config: + """Create config from a TVM private key.""" + return cls( + network=network, + secret_key=_parse_private_key(private_key), + subwallet_number=subwallet_number, + workchain=workchain, + ) + + +class WalletV5R1MnemonicSigner: + """Client signer backed by a mnemonic-derived W5R1 wallet.""" + + def __init__(self, config: WalletV5R1Config) -> None: + self._config = config + self._public_key = private_key_to_public_key(config.secret_key) + self._wallet_id = make_w5r1_wallet_id( + config.network, + workchain=config.workchain, + subwallet_number=config.subwallet_number, + ) + self._state_init = build_w5r1_state_init(self._public_key, self._wallet_id) + self._address = address_from_state_init(self._state_init, config.workchain) + + @property + def address(self) -> str: + return self._address + + @property + def network(self) -> str: + return self._config.network + + @property + def api_key(self) -> str | None: + return self._config.api_key + + @property + def base_url(self) -> str | None: + return self._config.base_url + + @property + def toncenter_timeout_seconds(self) -> float: + return self._config.toncenter_timeout_seconds + + @property + def toncenter_emulation_timeout_seconds(self) -> float: + return self._config.toncenter_emulation_timeout_seconds + + @property + def wallet_id(self) -> int: + return self._wallet_id + + @property + def state_init(self) -> StateInit: + return self._state_init + + def sign_message(self, message: bytes) -> bytes: + return sign_message(message, self._config.secret_key) + + +class FacilitatorHighloadV3Signer: + """Facilitator signer backed by a highload-wallet-contract-v3 wallet.""" + + def __init__(self, configs: dict[str, HighloadV3Config]) -> None: + self._configs = dict(configs) + self._clients: dict[str, ToncenterRestClient] = {} + self._streaming_clients: dict[str, ToncenterStreamingSseClient] = {} + self._streaming_watchers: dict[str, ToncenterStreamingWatcher] = {} + self._streaming_watcher_startups: dict[str, threading.Event] = {} + self._cached_facilitator_states: dict[str, TvmAccountState] = {} + self._facilitator_state_dirty: dict[str, bool] = {} + self._wallets: dict[str, _WalletContext] = {} + self._query_ids: dict[str, int] = {} + self._lock = threading.RLock() + + for network, config in self._configs.items(): + context = _WalletContext.from_config(config) + self._wallets[network] = context + self._query_ids[network] = randbelow(MAX_USABLE_QUERY_SEQNO + 1) + self._facilitator_state_dirty[network] = True + + def get_addresses(self) -> list[str]: + """Get all facilitator wallet addresses.""" + return [wallet.address for wallet in self._wallets.values()] + + def get_addresses_for_network(self, network: str) -> list[str]: + """Get facilitator wallet addresses for one TVM network.""" + wallet = self._wallets.get(network) + if wallet is None: + raise ValueError(f"Unsupported network: {network}") + return [wallet.address] + + def close(self) -> None: + """Close all Toncenter clients and streaming watchers owned by this signer.""" + with self._lock: + streaming_clients = list(self._streaming_clients.values()) + provider_clients = list(self._clients.values()) + self._streaming_watchers = {} + self._streaming_clients = {} + self._clients = {} + + for streaming_client in streaming_clients: + try: + streaming_client.close() + except Exception: + pass + + for provider_client in provider_clients: + try: + provider_client.close() + except Exception: + pass + + def get_account_state(self, address: str, network: str) -> TvmAccountState: + """Get current account state.""" + normalized_address = normalize_address(address) + if normalized_address == self._wallets[network].address: + return self._get_facilitator_account_state(network) + return self._client(network).get_account_state(address) + + def get_jetton_wallet(self, asset: str, owner: str, network: str) -> str: + """Resolve the canonical TEP-74 jetton wallet for an owner.""" + return self._client(network).get_jetton_wallet(asset, owner) + + def build_relay_external_boc( + self, + network: str, + relay_request: TvmRelayRequest, + *, + for_emulation: bool = False, + ) -> bytes: + """Build a Highload V3 external message for relaying the pre-signed W5 request.""" + return self.build_relay_external_boc_batch( + network, [relay_request], for_emulation=for_emulation + ) + + def build_relay_external_boc_batch( + self, + network: str, + relay_requests: list[TvmRelayRequest], + *, + for_emulation: bool = False, + ) -> bytes: + """Build one Highload V3 external message for relaying multiple W5 requests.""" + if not relay_requests: + raise ValueError("relay_requests must not be empty") + + wallet_context = self._wallets[network] + query_id = self._select_query_id(network, for_emulation) + created_at = ( + int(time.time()) - 5 + ) # workaround because lite servers often lag behind the blockchain + external_state_init = None + forward_actions: list[Cell] = [] + + for relay_request in relay_requests: + forward_value = ( + relay_request.relay_amount + if relay_request.relay_amount is not None + else wallet_context.config.relay_amount + relay_request.forward_ton_amount + ) + forward_message = Contract.create_internal_msg( + src=None, + dest=Address(relay_request.destination), + bounce=True, + value=forward_value, + state_init=relay_request.state_init, + body=relay_request.body, + ) + forward_actions.append(serialize_send_msg_action(forward_message.serialize(), mode=3)) + + message_to_send = self._pack_actions_message(wallet_context, forward_actions, query_id) + + message_inner = ( + begin_cell() + .store_uint(wallet_context.config.subwallet_id, 32) + .store_ref(message_to_send) + .store_uint(1, 8) + .store_uint(query_id, 23) + .store_uint(created_at, 64) + .store_uint(wallet_context.config.timeout, 22) + .end_cell() + ) + + external_body = ( + begin_cell() + .store_bytes(sign_message(message_inner.hash, wallet_context.config.secret_key)) + .store_ref(message_inner) + .end_cell() + ) + + if wallet_context.deployed is not True: + facilitator_account = self.get_account_state(wallet_context.address, network) + wallet_context.deployed = facilitator_account.is_active + if facilitator_account.is_uninitialized: + external_state_init = wallet_context.state_init + + external_message = Contract.create_external_msg( + dest=Address(wallet_context.address), + state_init=external_state_init, + body=external_body, + ) + return external_message.serialize().to_boc() + + def emulate_external_message(self, network: str, external_boc: bytes) -> dict[str, object]: + """Emulate a prepared external message via Toncenter.""" + return self._client(network).emulate_trace( + external_boc, + timeout=self._configs[network].toncenter_emulation_timeout_seconds, + ) + + def send_external_message(self, network: str, external_boc: bytes) -> str: + """Broadcast a prepared external message via Toncenter.""" + self._ensure_streaming_watcher(network) + return self._client(network).send_message(external_boc) + + def wait_for_trace_confirmation( + self, + network: str, + trace_external_hash_norm: str, + *, + timeout_seconds: float, + ) -> dict[str, object]: + """Wait until the submitted trace reaches finalized.""" + deadline = time.monotonic() + timeout_seconds + + if self._ensure_streaming_watcher(network): + try: + remaining = min( + DEFAULT_STREAMING_CONFIRMATION_GRACE_SECONDS, + max(0.0, deadline - time.monotonic()), + ) + self._streaming_client(network).wait_for_trace_confirmation( + trace_external_hash_norm=trace_external_hash_norm, + timeout_seconds=remaining, + ) + except Exception: + pass + + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + trace = self._client(network).get_trace_by_message_hash(trace_external_hash_norm) + if not trace.get("is_incomplete", False): + return trace + except Exception as exc: + last_error = exc + time.sleep(DEFAULT_TRACE_FETCH_BACKOFF_SECONDS) + + if last_error is not None: + raise last_error + raise RuntimeError(f"Timed out waiting for complete trace {trace_external_hash_norm}") + + def get_jetton_wallet_data(self, address: str, network: str) -> TvmJettonWalletData: + """Read TEP-74 jetton wallet data.""" + return self._client(network).get_jetton_wallet_data(address) + + def _client(self, network: str) -> ToncenterRestClient: + client = self._clients.get(network) + if client is not None: + return client + + with self._lock: + client = self._clients.get(network) + if client is None: + config = self._configs[network] + client = ToncenterRestClient( + network, + api_key=config.api_key, + base_url=config.toncenter_base_url, + timeout=config.toncenter_timeout_seconds, + ) + self._clients[network] = client + return client + + def _streaming_client(self, network: str) -> ToncenterStreamingSseClient: + client = self._streaming_clients.get(network) + if client is not None: + return client + + with self._lock: + client = self._streaming_clients.get(network) + if client is None: + config = self._configs[network] + client = ToncenterStreamingSseClient( + base_url=(config.toncenter_base_url or _default_streaming_base_url(network)), + api_key=config.api_key, + ) + self._streaming_clients[network] = client + return client + + def _get_facilitator_account_state(self, network: str) -> TvmAccountState: + """We cache the account state of the facilitator. The cache is invalidated when an event arrives from the StreamingAPI.""" + facilitator_address = self._wallets[network].address + + self._ensure_streaming_watcher(network) + with self._lock: + cached_state = self._cached_facilitator_states.get(network) + is_dirty = self._facilitator_state_dirty.get(network, True) + if cached_state is not None and not is_dirty: + return cached_state + + refreshed_state = self._client(network).get_account_state(facilitator_address) + with self._lock: + self._cached_facilitator_states[network] = refreshed_state + self._facilitator_state_dirty[network] = False + return refreshed_state + + def _ensure_streaming_watcher(self, network: str) -> bool: + startup_event: threading.Event | None = None + should_start = False + with self._lock: + existing_watcher = self._streaming_watchers.get(network) + if existing_watcher is not None and existing_watcher.is_alive(): + return True + if existing_watcher is not None: + self._streaming_watchers.pop(network, None) + startup_event = self._streaming_watcher_startups.get(network) + if startup_event is None: + startup_event = threading.Event() + self._streaming_watcher_startups[network] = startup_event + should_start = True + + if not should_start: + assert startup_event is not None + startup_event.wait() + with self._lock: + existing_watcher = self._streaming_watchers.get(network) + return existing_watcher is not None and existing_watcher.is_alive() + + watcher: ToncenterStreamingWatcher | None = None + try: + watcher = self._streaming_client(network).start_account_state_watcher( + address=self._wallets[network].address, + on_invalidate=lambda: self._mark_facilitator_state_dirty(network), + ) + except Exception: + watcher = None + finally: + assert startup_event is not None + with self._lock: + if watcher is not None: + self._streaming_watchers[network] = watcher + self._streaming_watcher_startups.pop(network, None) + startup_event.set() + + return watcher is not None + + def _mark_facilitator_state_dirty(self, network: str) -> None: + with self._lock: + self._facilitator_state_dirty[network] = True + + def _pack_actions_message( + self, + wallet_context: _WalletContext, + actions: list[Cell], + query_id: int, + ) -> Cell: + batch_actions = list(actions) + if len(batch_actions) > 254: + nested_message = self._pack_actions_message( + wallet_context, batch_actions[253:], query_id + ) + batch_actions = batch_actions[:253] + [ + serialize_send_msg_action(nested_message, mode=3) + ] + + return Contract.create_internal_msg( + src=None, + dest=Address(wallet_context.address), + bounce=True, + value=10**9, + body=serialize_internal_transfer(serialize_out_list(batch_actions), query_id), + ).serialize() + + def _select_query_id(self, network: str, for_emulation: bool) -> int: + """Pick a free HighloadV3 QueryID from the local monotonic seqno cursor.""" + wallet_context = self._wallets[network] + query_state = load_highload_query_state( + self.get_account_state(wallet_context.address, network), + expected_code_hash=HIGHLOAD_V3_CODE_HASH, + ) + with self._lock: + wallet_context = self._wallets[network] + wallet_context.deployed = query_state is not None + attempts = MAX_USABLE_QUERY_SEQNO + 1 + next_seqno = self._query_ids[network] + for _ in range(attempts): + seqno = next_seqno + next_seqno = (next_seqno + 1) % (MAX_USABLE_QUERY_SEQNO + 1) + query_id = seqno_to_query_id(seqno) + if query_state is None or not query_id_is_processed(query_state, query_id): + if not for_emulation: + self._query_ids[network] = next_seqno + return query_id + raise RuntimeError("No free Highload V3 query_id available") + + +@dataclass +class _WalletContext: + config: HighloadV3Config + public_key: bytes + address: str + state_init: StateInit + deployed: bool | None = None + + @classmethod + def from_config(cls, config: HighloadV3Config) -> _WalletContext: + # TON-specific: highload v3 wallet address is derived from its fixed code and data layout. + public_key = private_key_to_public_key(config.secret_key) + code = Cell.one_from_boc(bytes.fromhex(HIGHLOAD_V3_CODE_HEX)) + if code.hash.hex() != HIGHLOAD_V3_CODE_HASH: + raise ValueError("Unexpected highload-wallet-contract-v3 code hash") + + data = ( + begin_cell() + .store_bytes(public_key) + .store_uint(config.subwallet_id, 32) + .store_uint(0, 66) + .store_uint(config.timeout, 22) + .end_cell() + ) + state_init = StateInit(code=code, data=data) + address = normalize_address(Address((config.workchain, state_init.serialize().hash))) + return cls( + config=config, + public_key=public_key, + address=address, + state_init=state_init, + deployed=None, + ) + + +def _default_streaming_base_url(network: str) -> str: + if network == TVM_MAINNET: + return TONCENTER_MAINNET_BASE_URL + if network == TVM_TESTNET: + return TONCENTER_TESTNET_BASE_URL + raise ValueError(f"Unsupported TVM network: {network}") diff --git a/python/x402/mechanisms/tvm/streaming.py b/python/x402/mechanisms/tvm/streaming.py new file mode 100644 index 0000000000..311182edb4 --- /dev/null +++ b/python/x402/mechanisms/tvm/streaming.py @@ -0,0 +1,484 @@ +"""Toncenter Streaming API helpers for the TVM mechanism.""" + +from __future__ import annotations + +import json +import queue +import threading +import time +from collections.abc import Callable, Iterable, Iterator +from dataclasses import dataclass, field +from typing import Any + +from .codecs.common import normalize_address + +try: + import httpx +except ImportError as e: + raise ImportError( + "TVM mechanism requires httpx. Install with: pip install x402[tvm,httpx]" + ) from e + + +DEFAULT_STREAMING_READ_TIMEOUT_SECONDS = 20.0 +DEFAULT_STREAMING_RECONNECT_BACKOFF_SECONDS = 1.0 +DEFAULT_STREAMING_MAX_CONSECUTIVE_FAILURES = 3 +DEFAULT_RECENT_TRACE_RESULT_TTL_SECONDS = 60.0 +DEFAULT_STREAMING_START_TIMEOUT_SECONDS = 2.0 + + +def _account_stream_subscription(normalized_address: str) -> dict[str, object]: + return { + "addresses": [normalized_address], + "types": ["account_state_change", "transactions"], + "min_finality": "finalized", + } + + +def _iter_sse_payloads(lines: Iterable[str]) -> Iterator[str]: + """Yield SSE event payloads from an iterable of text lines.""" + data_lines: list[str] = [] + + for line in lines: + if line == "": + if data_lines: + yield "\n".join(data_lines) + data_lines = [] + continue + if line.startswith(":"): + continue + if line.startswith("data:"): + data_lines.append(line[5:].lstrip()) + continue + if line.startswith(("event:", "id:", "retry:")): + continue + data_lines.append(line) + + +def _iter_sse_json_events(lines: Iterable[str]) -> Iterator[dict[str, Any]]: + """Parse SSE JSON object payloads from text lines.""" + for payload in _iter_sse_payloads(lines): + try: + event = json.loads(payload) + except json.JSONDecodeError as exc: + raise RuntimeError("Toncenter SSE emitted malformed JSON") from exc + if not isinstance(event, dict): + raise RuntimeError("Toncenter SSE emitted a non-object event") + yield event + + +@dataclass +class _StreamResources: + lock: threading.Lock + client: httpx.Client | None = None + response: httpx.Response | None = None + + def attach(self, client: httpx.Client, response: httpx.Response) -> None: + with self.lock: + self.client = client + self.response = response + + def detach(self, client: httpx.Client, response: httpx.Response | None) -> None: + with self.lock: + if self.response is response: + self.response = None + if self.client is client: + self.client = None + + def close(self) -> None: + with self.lock: + response = self.response + client = self.client + self.response = None + self.client = None + + if response is not None: + try: + response.close() + except Exception: + pass + if client is not None: + try: + client.close() + except Exception: + pass + + +@dataclass +class _RecentTraceResult: + completed_at: float + payload: dict[str, Any] | None + error: Exception | None + + +@dataclass +class _StartupState: + ready_event: threading.Event = field(default_factory=threading.Event) + error: Exception | None = None + + def mark_ready(self) -> None: + self.ready_event.set() + + def fail(self, exc: Exception) -> None: + self.error = exc + self.ready_event.set() + + +class ToncenterStreamingWatcher: + """Handle for a long-lived facilitator-account streaming watcher.""" + + def __init__( + self, + thread: threading.Thread, + stop_event: threading.Event, + close_stream: Callable[[], None], + ) -> None: + self._thread = thread + self._stop_event = stop_event + self._close_stream = close_stream + + def close(self) -> None: + self._stop_event.set() + self._close_stream() + self._thread.join(timeout=1.0) + + def is_alive(self) -> bool: + """Report whether the watcher thread is still running.""" + return self._thread.is_alive() + + def is_current_thread(self) -> bool: + """Report whether the caller runs on the watcher thread.""" + return self._thread is threading.current_thread() + + +class ToncenterStreamingSseClient: + """Shared SSE client for Toncenter Streaming API v2.""" + + def __init__( + self, + *, + base_url: str, + api_key: str | None = None, + ) -> None: + self._base_url = base_url.rstrip("/") + self._headers = { + "Accept": "text/event-stream", + "Content-Type": "application/json", + } + if api_key: + self._headers["X-Api-Key"] = api_key + + self._lock = threading.Lock() + self._stream_resources = _StreamResources(lock=threading.Lock()) + self._watcher: ToncenterStreamingWatcher | None = None + self._watched_address: str | None = None + self._pending_trace_waiters: dict[str, list[queue.Queue[dict[str, Any] | Exception]]] = {} + self._recent_trace_results: dict[str, _RecentTraceResult] = {} + + def close(self) -> None: + """Close the watcher and any underlying stream resources.""" + with self._lock: + watcher = self._watcher + self._watcher = None + self._watched_address = None + pending_waiters = self._pending_trace_waiters + self._pending_trace_waiters = {} + self._recent_trace_results = {} + + self._stream_resources.close() + if watcher is not None: + watcher.close() + + error = RuntimeError("Toncenter facilitator account stream closed") + for waiters in pending_waiters.values(): + self._notify_waiters(waiters, error) + + def start_account_state_watcher( + self, + *, + address: str, + on_invalidate: Callable[[], None], + ) -> ToncenterStreamingWatcher: + """Start one shared stream on the facilitator address.""" + normalized_address = normalize_address(address) + with self._lock: + if self._watcher is not None and not self._watcher.is_alive(): + self._watcher = None + self._watched_address = None + if self._watcher is not None: + if self._watched_address != normalized_address: + raise RuntimeError( + "ToncenterStreamingSseClient already watches a different address" + ) + return self._watcher + + stop_event = threading.Event() + startup_state = _StartupState() + thread = threading.Thread( + target=self._run_account_stream, + args=(stop_event, normalized_address, on_invalidate, startup_state), + name="toncenter-account-stream", + daemon=True, + ) + watcher = ToncenterStreamingWatcher( + thread, + stop_event, + close_stream=self._stream_resources.close, + ) + self._watcher = watcher + self._watched_address = normalized_address + + thread.start() + if not startup_state.ready_event.wait(timeout=DEFAULT_STREAMING_START_TIMEOUT_SECONDS): + watcher.close() + with self._lock: + if self._watcher is watcher: + self._watcher = None + self._watched_address = None + raise RuntimeError("Toncenter facilitator account stream did not become ready in time") + if startup_state.error is not None: + watcher.close() + with self._lock: + if self._watcher is watcher: + self._watcher = None + self._watched_address = None + raise RuntimeError( + "Toncenter facilitator account stream failed to start" + ) from startup_state.error + return watcher + + def wait_for_trace_confirmation( + self, + *, + trace_external_hash_norm: str, + timeout_seconds: float, + ) -> dict[str, Any]: + """Block until the shared account stream observes the finalized trace.""" + trace_waiter: queue.Queue[dict[str, Any] | Exception] | None = None + recent_result: _RecentTraceResult | None = None + + with self._lock: + self._prune_recent_trace_results_locked() + if self._watcher is None: + raise RuntimeError("Toncenter facilitator account stream has not been started") + recent_result = self._recent_trace_results.get(trace_external_hash_norm) + if recent_result is None: + trace_waiter = queue.Queue(maxsize=1) + self._pending_trace_waiters.setdefault(trace_external_hash_norm, []).append( + trace_waiter + ) + + if recent_result is not None: + if recent_result.error is not None: + raise recent_result.error + if recent_result.payload is None: + raise RuntimeError( + f"Toncenter did not cache finalized trace payload for {trace_external_hash_norm}" + ) + return recent_result.payload + + assert trace_waiter is not None + try: + result = trace_waiter.get(timeout=timeout_seconds) + except queue.Empty as exc: + self._remove_pending_waiter(trace_external_hash_norm, trace_waiter) + raise RuntimeError( + f"Timed out waiting for finalized trace {trace_external_hash_norm}" + ) from exc + + if isinstance(result, Exception): + raise result + return result + + def _run_account_stream( + self, + stop_event: threading.Event, + normalized_address: str, + on_invalidate: Callable[[], None], + startup_state: _StartupState, + ) -> None: + consecutive_failures = 0 + + def on_subscribed() -> None: + nonlocal consecutive_failures + consecutive_failures = 0 + startup_state.mark_ready() + + try: + while not stop_event.is_set(): + try: + self._consume_stream( + subscription=_account_stream_subscription(normalized_address), + stop_event=stop_event, + on_event=lambda event: self._handle_stream_event( + event, + normalized_address=normalized_address, + on_invalidate=on_invalidate, + on_subscribed=on_subscribed, + ), + resources=self._stream_resources, + ) + except Exception as exc: + if not startup_state.ready_event.is_set(): + startup_state.fail(exc) + break + if stop_event.is_set(): + break + + on_invalidate() + consecutive_failures += 1 + if consecutive_failures >= DEFAULT_STREAMING_MAX_CONSECUTIVE_FAILURES: + self._fail_pending_waiters(exc) + break + + stop_event.wait(DEFAULT_STREAMING_RECONNECT_BACKOFF_SECONDS) + finally: + with self._lock: + if self._watcher is not None and self._watcher.is_current_thread(): + self._watcher = None + self._watched_address = None + + def _consume_stream( + self, + *, + subscription: dict[str, object], + stop_event: threading.Event, + on_event: Callable[[dict[str, Any]], None], + resources: _StreamResources | None = None, + ) -> None: + client = httpx.Client( + base_url=self._base_url, + headers=self._headers, + timeout=httpx.Timeout( + connect=DEFAULT_STREAMING_READ_TIMEOUT_SECONDS, + write=DEFAULT_STREAMING_READ_TIMEOUT_SECONDS, + pool=DEFAULT_STREAMING_READ_TIMEOUT_SECONDS, + read=DEFAULT_STREAMING_READ_TIMEOUT_SECONDS, + ), + ) + response: httpx.Response | None = None + try: + with client: + with client.stream("POST", "/api/streaming/v2/sse", json=subscription) as response: + response.raise_for_status() + if resources is not None: + resources.attach(client, response) + + for event in _iter_sse_json_events(response.iter_lines()): + if stop_event.is_set(): + return + on_event(event) + if stop_event.is_set(): + return + + if stop_event.is_set(): + return + raise RuntimeError("Toncenter SSE stream terminated unexpectedly") + finally: + if resources is not None: + resources.detach(client, response) + + def _handle_stream_event( + self, + event: dict[str, Any], + *, + normalized_address: str, + on_invalidate: Callable[[], None], + on_subscribed: Callable[[], None], + ) -> None: + if event.get("status") == "subscribed": + on_subscribed() + return + + if event.get("type") == "account_state_change": + account = event.get("account") + if isinstance(account, str) and normalize_address(account) == normalized_address: + on_invalidate() + return + + trace_result = self._trace_result_from_event(event) + if trace_result is None: + return + + self._publish_trace_result(*trace_result) + + def _publish_trace_result( + self, + trace_external_hash_norm: str, + payload: dict[str, Any] | None, + error: Exception | None, + ) -> None: + with self._lock: + self._prune_recent_trace_results_locked() + self._recent_trace_results[trace_external_hash_norm] = _RecentTraceResult( + completed_at=time.monotonic(), + payload=payload, + error=error, + ) + waiters = self._pending_trace_waiters.pop(trace_external_hash_norm, []) + + self._notify_waiters(waiters, error if error is not None else payload) + + def _fail_pending_waiters(self, exc: Exception) -> None: + error = RuntimeError( + f"Toncenter facilitator account stream failed before confirmation: {exc}" + ) + with self._lock: + pending_trace_waiters = self._pending_trace_waiters + self._pending_trace_waiters = {} + + for waiters in pending_trace_waiters.values(): + self._notify_waiters(waiters, error) + + def _remove_pending_waiter( + self, + trace_external_hash_norm: str, + trace_waiter: queue.Queue[dict[str, Any] | Exception], + ) -> None: + with self._lock: + waiters = self._pending_trace_waiters.get(trace_external_hash_norm) + if waiters is None: + return + self._pending_trace_waiters[trace_external_hash_norm] = [ + waiter for waiter in waiters if waiter is not trace_waiter + ] + if not self._pending_trace_waiters[trace_external_hash_norm]: + self._pending_trace_waiters.pop(trace_external_hash_norm, None) + + def _prune_recent_trace_results_locked(self) -> None: + cutoff = time.monotonic() - DEFAULT_RECENT_TRACE_RESULT_TTL_SECONDS + stale_hashes = [ + trace_external_hash_norm + for trace_external_hash_norm, recent_result in self._recent_trace_results.items() + if recent_result.completed_at < cutoff + ] + for trace_external_hash_norm in stale_hashes: + self._recent_trace_results.pop(trace_external_hash_norm, None) + + def _notify_waiters( + self, + waiters: Iterable[queue.Queue[dict[str, Any] | Exception]], + result: dict[str, Any] | Exception | None, + ) -> None: + for trace_waiter in waiters: + try: + if result is None: + continue + trace_waiter.put_nowait(result) + except queue.Full: + continue + + def _trace_result_from_event( + self, + event: dict[str, Any], + ) -> tuple[str, dict[str, Any] | None, Exception | None] | None: + event_type = event.get("type") + if event_type not in {"trace", "transactions"}: + return None + + trace_external_hash_norm = event.get("trace_external_hash_norm") + if not isinstance(trace_external_hash_norm, str): + return None + if event.get("finality") != "finalized": + return None + return trace_external_hash_norm, event, None diff --git a/python/x402/mechanisms/tvm/trace_utils.py b/python/x402/mechanisms/tvm/trace_utils.py new file mode 100644 index 0000000000..07be7bd141 --- /dev/null +++ b/python/x402/mechanisms/tvm/trace_utils.py @@ -0,0 +1,126 @@ +"""Helpers for extracting TVM execution fees from Toncenter traces.""" + +from __future__ import annotations + +import base64 + + +def parse_trace_transactions(trace_data: dict[str, object]) -> list[dict[str, object]]: + """Return transaction objects from a Toncenter trace payload.""" + try: + transactions = trace_data["transactions"] + return list(transactions.values()) + except (KeyError, AttributeError, TypeError) as exc: + raise ValueError("Toncenter trace did not return transactions dict") from exc + + +def transaction_succeeded(transaction: dict[str, object]) -> bool: + """Return True when a traced transaction completed successfully.""" + description = _transaction_phases(transaction) + if description.get("aborted") is True: + return False + + compute_phase: dict = description["compute_ph"] + if compute_phase.get("skipped") is True or compute_phase.get("success") is not True: + return False + + action_phase = description.get("action") + if action_phase is not None and action_phase.get("success") is not True: + return False + + return True + + +def body_hash_to_base64(raw_hash: bytes) -> str: + """Encode a raw TVM cell hash to the Toncenter base64 representation.""" + return base64.b64encode(raw_hash).decode("ascii") + + +def trace_transaction_hash_to_hex(encoded_hash: str) -> str: + """Convert a Toncenter transaction hash from base64 to lowercase hex.""" + return base64.b64decode(encoded_hash).hex() + + +def message_body_hash_matches(message: dict[str, object], expected_hash: bytes) -> bool: + """Check whether a trace message matches a known TVM body hash.""" + return message.get("message_content", {}).get("hash") == body_hash_to_base64(expected_hash) + + +def trace_transaction_fwd_fees( + transaction: dict[str, object], + *, + expected_count: int | None = None, +) -> int: + """Extract the total forward fees paid by a transaction.""" + exact_fees = [ + parsed_fee + for out_message in transaction.get("out_msgs", []) + for parsed_fee in [_parse_int(out_message.get("fwd_fee"))] + if parsed_fee is not None + ] + if exact_fees: + return sum(exact_fees) + + action_phase: dict = _transaction_phases(transaction).get("action") + if action_phase is not None: + total_fwd_fees = _parse_int(action_phase.get("total_fwd_fees")) + if total_fwd_fees is not None: + return total_fwd_fees + + fwd_fee = _parse_int(action_phase.get("fwd_fee")) + if fwd_fee is not None: + return fwd_fee * expected_count if expected_count is not None else fwd_fee + + return 0 + + +def trace_transaction_compute_fees(transaction: dict[str, object]) -> int: + """Extract compute gas fees from a transaction description.""" + compute_phase: dict = _transaction_phases(transaction)["compute_ph"] + return _parse_int(compute_phase.get("gas_fees")) or 0 + + +def trace_transaction_storage_fees(transaction: dict[str, object]) -> int: + """Extract storage fees from a transaction description.""" + storage_phase: dict = _transaction_phases(transaction)["storage_ph"] + return (_parse_int(storage_phase.get("storage_fees_collected")) or 0) + ( + _parse_int(storage_phase.get("storage_fees_due")) or 0 + ) + + +def trace_transaction_balance_before(transaction: dict[str, object]) -> int: + """Extract the account balance before transaction execution.""" + before_state: dict = transaction.get("account_state_before") + if before_state is not None: + balance = _parse_int(before_state.get("balance")) + if balance is not None: + return balance + + account_state: dict = transaction.get("account_state") + if account_state is not None: + balance = _parse_int(account_state.get("balance")) + if balance is not None: + return balance + + balance = _parse_int(transaction.get("balance")) + if balance is not None: + return balance + + raise ValueError("Trace transaction is missing account_state_before balance") + + +def _transaction_phases(transaction: dict[str, object]) -> dict[str, object]: + return transaction.get("description", transaction) + + +def _parse_int(value: object) -> int | None: + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value, 0) + except ValueError: + return None + return None diff --git a/python/x402/mechanisms/tvm/types.py b/python/x402/mechanisms/tvm/types.py new file mode 100644 index 0000000000..d8353eda58 --- /dev/null +++ b/python/x402/mechanisms/tvm/types.py @@ -0,0 +1,117 @@ +"""TVM-specific payload and parsed data types.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +try: + from pytoniq_core import Cell + from pytoniq_core.tlb.account import StateInit +except ImportError as e: + raise ImportError( + "TVM mechanism requires pytoniq packages. Install with: pip install x402[tvm]" + ) from e + + +@dataclass +class ExactTvmPayload: + """Exact payment payload for TVM networks.""" + + settlement_boc: str + asset: str + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + "settlementBoc": self.settlement_boc, + "asset": self.asset, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ExactTvmPayload: + """Create from dictionary.""" + settlement_boc = data.get("settlementBoc") + if not isinstance(settlement_boc, str) or not settlement_boc.strip(): + raise ValueError("Exact TVM payload field 'settlementBoc' is required") + + asset = data.get("asset") + if not isinstance(asset, str) or not asset.strip(): + raise ValueError("Exact TVM payload field 'asset' is required") + + return cls( + settlement_boc=settlement_boc, + asset=asset, + ) + + +@dataclass +class ParsedJettonTransfer: + """Jetton transfer extracted from a W5 action.""" + + source_wallet: str + destination: str + response_destination: str | None + jetton_amount: int + attached_ton_amount: int + forward_ton_amount: int + forward_payload: Cell + body_hash: bytes | None = None + + +@dataclass +class ParsedTvmSettlement: + """Parsed TON settlement payload.""" + + payer: str + wallet_id: int + valid_until: int + seqno: int + settlement_hash: str + body: Cell + signed_slice_hash: bytes + signature: bytes + state_init: StateInit | None + transfer: ParsedJettonTransfer + + +@dataclass +class TvmAccountState: + """Subset of account state needed by the facilitator.""" + + address: str + balance: int + is_active: bool + is_uninitialized: bool + is_frozen: bool + state_init: StateInit | None + + +@dataclass +class TvmJettonWalletData: + """Data returned by TEP-74 get_wallet_data().""" + + address: str + balance: int + owner: str + jetton_minter: str + + +@dataclass +class TvmRelayRequest: + """One relay request forwarded by the facilitator highload wallet.""" + + destination: str + body: Cell + state_init: StateInit | None + forward_ton_amount: int = 0 + relay_amount: int | None = None + + +@dataclass +class W5InitData: + signature_allowed: bool + seqno: int + wallet_id: int + public_key: bytes + extensions_dict: Cell | None diff --git a/python/x402/mechanisms/tvm/utils.py b/python/x402/mechanisms/tvm/utils.py new file mode 100644 index 0000000000..05f6cbc5b6 --- /dev/null +++ b/python/x402/mechanisms/tvm/utils.py @@ -0,0 +1,65 @@ +"""Backward-compatible TVM utility facade. + +This module keeps the historical import surface while the actual codec logic +is split across ``tvm.codecs`` and ``tvm.exact.codec``. +""" + +from __future__ import annotations + +try: + from pytoniq_core.crypto.signature import verify_sign +except ImportError as e: + raise ImportError( + "TVM mechanism requires pytoniq packages. Install with: pip install x402[tvm]" + ) from e + +from .codecs.common import ( + address_to_stack_item, + get_network_global_id, + normalize_address, + parse_amount, + parse_money_to_decimal, +) +from .codecs.jetton import ( + build_jetton_transfer_body, + build_jetton_transfer_body_fields, + parse_jetton_transfer, +) +from .codecs.w5 import ( + address_from_state_init, + build_w5_signed_body, + build_w5r1_state_init, + get_w5_seqno, + make_w5r1_wallet_id, + parse_active_w5_account_state, + parse_out_list, + parse_w5_init_data, + serialize_out_list, + serialize_send_msg_action, + verify_w5_signature, +) +from .exact.codec import parse_exact_tvm_payload + +__all__ = [ + "address_from_state_init", + "address_to_stack_item", + "build_jetton_transfer_body", + "build_jetton_transfer_body_fields", + "build_w5_signed_body", + "build_w5r1_state_init", + "get_network_global_id", + "get_w5_seqno", + "make_w5r1_wallet_id", + "normalize_address", + "parse_active_w5_account_state", + "parse_amount", + "parse_exact_tvm_payload", + "parse_jetton_transfer", + "parse_money_to_decimal", + "parse_out_list", + "parse_w5_init_data", + "serialize_out_list", + "serialize_send_msg_action", + "verify_sign", + "verify_w5_signature", +] diff --git a/python/x402/pyproject.toml b/python/x402/pyproject.toml index 3f78b64e7b..4a4ef85272 100644 --- a/python/x402/pyproject.toml +++ b/python/x402/pyproject.toml @@ -46,6 +46,12 @@ svm = [ "solders>=0.27.0", "solana>=0.36.0", ] +tvm = [ + "httpx>=0.28.1", + "pytoniq>=0.1.39", + "pytoniq-core>=0.1.36", + "PyNaCl>=1.5.0", +] # MCP (Model Context Protocol) integration mcp = ["mcp>=1.0.0"] @@ -56,8 +62,8 @@ extensions = ["jsonschema>=4.0.0"] # Convenience bundles clients = ["x402[httpx,requests]"] servers = ["x402[flask,fastapi]"] -mechanisms = ["x402[evm,svm]"] -all = ["x402[httpx,requests,flask,fastapi,evm,svm,mcp,extensions]"] +mechanisms = ["x402[evm,svm,tvm]"] +all = ["x402[httpx,requests,flask,fastapi,evm,svm,tvm,mcp,extensions]"] [dependency-groups] dev = [ @@ -82,6 +88,10 @@ dev = [ # SVM dependencies "solders>=0.27.0", "solana>=0.36.0", + # TVM dependencies + "pytoniq>=0.1.39", + "pytoniq-core>=0.1.36", + "PyNaCl>=1.5.0", # MCP dependencies "mcp>=1.26.0", "nest-asyncio>=1.6.0", diff --git a/python/x402/tests/integrations/test_tvm.py b/python/x402/tests/integrations/test_tvm.py new file mode 100644 index 0000000000..b658a74b3f --- /dev/null +++ b/python/x402/tests/integrations/test_tvm.py @@ -0,0 +1,676 @@ +"""TVM integration tests for x402ClientSync, x402ResourceServerSync, and x402FacilitatorSync. + +These tests perform REAL blockchain transactions on TON testnet using sync classes. + +Required environment variables: +- TVM_CLIENT_PRIVATE_KEY: TON private key used for the payer W5 wallet +- TVM_FACILITATOR_PRIVATE_KEY: TON private key used for the facilitator highload wallet +- TONCENTER_API_KEY: Toncenter API key for TON + +For backward compatibility, if the split variables are not set the tests fall back to +`TVM_PRIVATE_KEY` for both roles. + +Optional environment variables: +- TVM_SECOND_CLIENT_PRIVATE_KEY: second funded W5 client used by the live batch-settlement test + +These must correspond to funded testnet wallets with TON and USDT. +""" + +from __future__ import annotations + +import os +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +import pytest + +pytest.importorskip("pytoniq_core") + +from x402 import x402ClientSync, x402FacilitatorSync, x402ResourceServerSync +from x402.mechanisms.tvm import ( + SCHEME_EXACT, + TVM_TESTNET, + USDT_TESTNET_MINTER, + ExactTvmPayload, + FacilitatorHighloadV3Signer, + HighloadV3Config, + ToncenterRestClient, + WalletV5R1Config, + WalletV5R1MnemonicSigner, + parse_exact_tvm_payload, +) +from x402.mechanisms.tvm.constants import ( + ERR_EXACT_TVM_DUPLICATE_SETTLEMENT as ERR_DUPLICATE_SETTLEMENT, + ERR_EXACT_TVM_INVALID_AMOUNT, + ERR_EXACT_TVM_INVALID_RECIPIENT, + ERR_EXACT_TVM_INVALID_SEQNO as ERR_INVALID_SEQNO, +) +from x402.mechanisms.tvm.exact import ( + ExactTvmClientScheme, + ExactTvmFacilitatorScheme, + ExactTvmServerScheme, +) +from x402.schemas import ( + PaymentPayload, + PaymentRequirements, + ResourceConfig, + ResourceInfo, + SettleResponse, + SupportedResponse, + VerifyResponse, +) + +TVM_PRIVATE_KEY = os.environ.get("TVM_PRIVATE_KEY") +TVM_CLIENT_PRIVATE_KEY = os.environ.get("TVM_CLIENT_PRIVATE_KEY", TVM_PRIVATE_KEY) +TVM_SECOND_CLIENT_PRIVATE_KEY = os.environ.get("TVM_SECOND_CLIENT_PRIVATE_KEY") +TVM_FACILITATOR_PRIVATE_KEY = os.environ.get("TVM_FACILITATOR_PRIVATE_KEY", TVM_PRIVATE_KEY) +TONCENTER_API_KEY = os.environ.get("TONCENTER_API_KEY") +TONCENTER_BASE_URL = os.environ.get("TONCENTER_BASE_URL") + +TEST_PAYMENT_AMOUNT = "1000" # 0.001 USDT with 6 decimals +MIN_FACILITATOR_TON_BALANCE = 1_000_000_000 +MIN_CLIENT_USDT_BALANCE = int(TEST_PAYMENT_AMOUNT) + +pytestmark = pytest.mark.skipif( + not TVM_CLIENT_PRIVATE_KEY or not TVM_FACILITATOR_PRIVATE_KEY or not TONCENTER_API_KEY, + reason=( + "TVM_CLIENT_PRIVATE_KEY (or TVM_PRIVATE_KEY), TVM_FACILITATOR_PRIVATE_KEY " + "(or TVM_PRIVATE_KEY), and TONCENTER_API_KEY are required for TVM integration tests" + ), +) + + +class TvmFacilitatorClientSync: + """Facilitator client wrapper for x402ResourceServerSync.""" + + scheme = SCHEME_EXACT + network = TVM_TESTNET + x402_version = 2 + + def __init__(self, facilitator: x402FacilitatorSync): + self._facilitator = facilitator + + def verify( + self, + payload: PaymentPayload, + requirements: PaymentRequirements, + ) -> VerifyResponse: + return self._facilitator.verify(payload, requirements) + + def settle( + self, + payload: PaymentPayload, + requirements: PaymentRequirements, + ) -> SettleResponse: + return self._facilitator.settle(payload, requirements) + + def get_supported(self) -> SupportedResponse: + return self._facilitator.get_supported() + + +def build_tvm_payment_requirements( + pay_to: str, + amount: str, + network: str = TVM_TESTNET, + asset: str = USDT_TESTNET_MINTER, +) -> PaymentRequirements: + """Build TVM payment requirements for testing.""" + return PaymentRequirements( + scheme=SCHEME_EXACT, + network=network, + asset=asset, + amount=amount, + pay_to=pay_to, + max_timeout_seconds=300, + extra={ + "decimals": 6, + "areFeesSponsored": True, + }, + ) + + +def _wait_for_jetton_balance_at_least( + provider: ToncenterRestClient, + jetton_wallet: str, + expected_balance: int, + *, + timeout_seconds: float = 20.0, +) -> int: + """Wait until Toncenter reflects a target jetton balance.""" + deadline = time.monotonic() + timeout_seconds + last_balance = 0 + last_error: Exception | None = None + + while time.monotonic() < deadline: + try: + last_balance = provider.get_jetton_wallet_data(jetton_wallet).balance + if last_balance >= expected_balance: + return last_balance + except Exception as exc: # pragma: no cover - retry path for flaky RPC + last_error = exc + time.sleep(1.0) + + if last_error is not None: + raise AssertionError( + f"Timed out waiting for jetton balance update for {jetton_wallet}: {last_error}" + ) from last_error + raise AssertionError( + f"Timed out waiting for jetton balance {expected_balance}, last balance {last_balance}" + ) + + +class TestTvmIntegrationV2: + """Integration tests for TVM V2 payment flow with REAL blockchain transactions.""" + + def setup_method(self) -> None: + client_config = WalletV5R1Config.from_private_key(TVM_TESTNET, TVM_CLIENT_PRIVATE_KEY) + client_config.api_key = TONCENTER_API_KEY + client_config.base_url = TONCENTER_BASE_URL + self.client_signer = WalletV5R1MnemonicSigner(client_config) + + facilitator_config = HighloadV3Config.from_private_key(TVM_FACILITATOR_PRIVATE_KEY) + facilitator_config.api_key = TONCENTER_API_KEY + facilitator_config.toncenter_base_url = TONCENTER_BASE_URL + self.facilitator_signer = FacilitatorHighloadV3Signer({TVM_TESTNET: facilitator_config}) + + self.provider = ToncenterRestClient( + TVM_TESTNET, + api_key=TONCENTER_API_KEY, + base_url=TONCENTER_BASE_URL, + ) + + self.client_address = self.client_signer.address + self.facilitator_address = self.facilitator_signer.get_addresses()[0] + self.client_jetton_wallet = self.provider.get_jetton_wallet( + USDT_TESTNET_MINTER, + self.client_address, + ) + self.facilitator_jetton_wallet = self.provider.get_jetton_wallet( + USDT_TESTNET_MINTER, + self.facilitator_address, + ) + + self.client = x402ClientSync().register( + TVM_TESTNET, + ExactTvmClientScheme(self.client_signer), + ) + self.facilitator = x402FacilitatorSync().register( + [TVM_TESTNET], + ExactTvmFacilitatorScheme( + self.facilitator_signer, + batch_flush_interval_seconds=0.05, + batch_flush_size=1, + ), + ) + + facilitator_client = TvmFacilitatorClientSync(self.facilitator) + self.server = x402ResourceServerSync(facilitator_client) + self.server.register(TVM_TESTNET, ExactTvmServerScheme()) + self.server.initialize() + + def teardown_method(self) -> None: + self.facilitator_signer.close() + self.provider.close() + + def _require_live_balances(self) -> None: + facilitator_state = self.provider.get_account_state(self.facilitator_address) + client_jetton_balance = self.provider.get_jetton_wallet_data( + self.client_jetton_wallet + ).balance + + if facilitator_state.balance < MIN_FACILITATOR_TON_BALANCE: + pytest.skip( + "Facilitator wallet " + f"{self.facilitator_address} needs at least {MIN_FACILITATOR_TON_BALANCE} nanotons" + ) + if client_jetton_balance < MIN_CLIENT_USDT_BALANCE: + pytest.skip( + f"Client jetton wallet {self.client_jetton_wallet} needs at least {MIN_CLIENT_USDT_BALANCE} USDT units" + ) + + def _require_client_balances(self, address: str, jetton_wallet: str) -> None: + client_jetton_balance = self.provider.get_jetton_wallet_data(jetton_wallet).balance + + if client_jetton_balance < MIN_CLIENT_USDT_BALANCE: + pytest.skip( + f"Client jetton wallet {jetton_wallet} needs at least {MIN_CLIENT_USDT_BALANCE} USDT units" + ) + + def test_server_should_successfully_verify_and_settle_tvm_payment_from_client( + self, + ) -> None: + """Test the complete TVM V2 payment flow with REAL blockchain transactions.""" + self._require_live_balances() + + recipient_balance_before = self.provider.get_jetton_wallet_data( + self.facilitator_jetton_wallet + ).balance + + accepts = [ + build_tvm_payment_requirements( + self.facilitator_address, + TEST_PAYMENT_AMOUNT, + ) + ] + resource = ResourceInfo( + url="https://api.example.com/premium", + description="Premium API Access", + mime_type="application/json", + ) + payment_required = self.server.create_payment_required_response(accepts, resource) + + assert payment_required.x402_version == 2 + + payment_payload = self.client.create_payment_payload(payment_required) + + assert payment_payload.x402_version == 2 + assert payment_payload.accepted.scheme == SCHEME_EXACT + assert payment_payload.accepted.network == TVM_TESTNET + assert "settlementBoc" in payment_payload.payload + assert "asset" in payment_payload.payload + + accepted = self.server.find_matching_requirements(accepts, payment_payload) + assert accepted is not None + + verify_response = self.server.verify_payment(payment_payload, accepted) + if not verify_response.is_valid: + print(f"❌ Verification failed: {verify_response.invalid_reason}") + print(f"Payer: {verify_response.payer}") + print(f"Client address: {self.client_address}") + + assert verify_response.is_valid is True + assert verify_response.payer == self.client_address + + settle_response = self.server.settle_payment(payment_payload, accepted) + if not settle_response.success: + print(f"❌ Settlement failed: {settle_response.error_reason}") + if settle_response.transaction: + print(f"📋 Trace message hash: {settle_response.transaction}") + + assert settle_response.success is True + assert settle_response.network == TVM_TESTNET + assert settle_response.transaction != "" + assert settle_response.payer == self.client_address + + recipient_balance_after = _wait_for_jetton_balance_at_least( + self.provider, + self.facilitator_jetton_wallet, + recipient_balance_before + int(TEST_PAYMENT_AMOUNT), + ) + assert recipient_balance_after >= recipient_balance_before + int(TEST_PAYMENT_AMOUNT) + + def test_server_should_batch_two_tvm_settlements_into_one_external_message(self, monkeypatch): + """Test live facilitator batching with two funded W5 clients. + + TVM W5 wallets require each signed settlement to use the wallet's current on-chain seqno. + Because of that, a live batch cannot use two distinct settlements from the same payer wallet: + the second relay would hit a seqno mismatch after the first one executes. This test uses two + funded payer wallets to exercise the facilitator's batch-relay path on TON testnet. + """ + self._require_live_balances() + if not TVM_SECOND_CLIENT_PRIVATE_KEY: + pytest.skip("TVM_SECOND_CLIENT_PRIVATE_KEY is required for the live TVM batch test") + + second_client_config = WalletV5R1Config.from_private_key( + TVM_TESTNET, + TVM_SECOND_CLIENT_PRIVATE_KEY, + ) + second_client_config.api_key = TONCENTER_API_KEY + second_client_config.base_url = TONCENTER_BASE_URL + second_client_signer = WalletV5R1MnemonicSigner(second_client_config) + second_client = x402ClientSync().register( + TVM_TESTNET, + ExactTvmClientScheme(second_client_signer), + ) + second_client_address = second_client_signer.address + if second_client_address == self.client_address: + pytest.skip( + "TVM_SECOND_CLIENT_PRIVATE_KEY must point to a different funded W5 client wallet" + ) + second_client_jetton_wallet = self.provider.get_jetton_wallet( + USDT_TESTNET_MINTER, + second_client_address, + ) + self._require_client_balances(second_client_address, second_client_jetton_wallet) + + facilitator = x402FacilitatorSync().register( + [TVM_TESTNET], + ExactTvmFacilitatorScheme( + self.facilitator_signer, + batch_flush_interval_seconds=5.0, + batch_flush_size=2, + ), + ) + facilitator_client = TvmFacilitatorClientSync(facilitator) + server = x402ResourceServerSync(facilitator_client) + server.register(TVM_TESTNET, ExactTvmServerScheme()) + server.initialize() + + batch_build_calls: list[tuple[str, list[str], bool]] = [] + send_calls: list[str] = [] + original_build_batch = self.facilitator_signer.build_relay_external_boc_batch + original_send_message = self.facilitator_signer.send_external_message + + def build_batch_spy(network: str, relay_requests: list, *, for_emulation: bool = False): + batch_build_calls.append( + ( + network, + [relay_request.destination for relay_request in relay_requests], + for_emulation, + ) + ) + return original_build_batch( + network, + relay_requests, + for_emulation=for_emulation, + ) + + def send_message_spy(network: str, external_boc: bytes) -> str: + send_calls.append(network) + return original_send_message(network, external_boc) + + monkeypatch.setattr( + self.facilitator_signer, + "build_relay_external_boc_batch", + build_batch_spy, + ) + monkeypatch.setattr( + self.facilitator_signer, + "send_external_message", + send_message_spy, + ) + + recipient_balance_before = self.provider.get_jetton_wallet_data( + self.facilitator_jetton_wallet + ).balance + + accepts = [ + build_tvm_payment_requirements( + self.facilitator_address, + TEST_PAYMENT_AMOUNT, + ) + ] + payment_required = server.create_payment_required_response(accepts) + first_payload = self.client.create_payment_payload(payment_required) + second_payload = second_client.create_payment_payload(payment_required) + + first_accepted = server.find_matching_requirements(accepts, first_payload) + second_accepted = server.find_matching_requirements(accepts, second_payload) + assert first_accepted is not None + assert second_accepted is not None + + first_verify = server.verify_payment(first_payload, first_accepted) + second_verify = server.verify_payment(second_payload, second_accepted) + assert first_verify.is_valid is True + assert first_verify.payer == self.client_address + assert second_verify.is_valid is True + assert second_verify.payer == second_client_address + + start_barrier = threading.Barrier(2) + + def settle_payment( + payload: PaymentPayload, accepted: PaymentRequirements + ) -> SettleResponse: + start_barrier.wait(timeout=15.0) + return server.settle_payment(payload, accepted) + + with ThreadPoolExecutor(max_workers=2) as executor: + first_future = executor.submit(settle_payment, first_payload, first_accepted) + second_future = executor.submit(settle_payment, second_payload, second_accepted) + first_settle = first_future.result(timeout=180.0) + second_settle = second_future.result(timeout=180.0) + + assert first_settle.success is True + assert first_settle.transaction != "" + assert first_settle.payer == self.client_address + assert second_settle.success is True + assert second_settle.transaction != "" + assert second_settle.payer == second_client_address + + settlement_batch_build_calls = [ + (network, destinations) + for network, destinations, for_emulation in batch_build_calls + if for_emulation is False + ] + assert len(settlement_batch_build_calls) == 1 + assert settlement_batch_build_calls[0][0] == TVM_TESTNET + assert len(settlement_batch_build_calls[0][1]) == 2 + assert set(settlement_batch_build_calls[0][1]) == { + self.client_address, + second_client_address, + } + assert send_calls == [TVM_TESTNET] + + recipient_balance_after = _wait_for_jetton_balance_at_least( + self.provider, + self.facilitator_jetton_wallet, + recipient_balance_before + (2 * int(TEST_PAYMENT_AMOUNT)), + ) + assert recipient_balance_after >= recipient_balance_before + (2 * int(TEST_PAYMENT_AMOUNT)) + + def test_client_creates_valid_tvm_payment_payload(self) -> None: + """Test that client creates properly structured TVM payload.""" + self._require_live_balances() + + accepts = [ + build_tvm_payment_requirements( + self.facilitator_address, + "5000000", + ) + ] + payment_required = self.server.create_payment_required_response(accepts) + + payload = self.client.create_payment_payload(payment_required) + + assert payload.x402_version == 2 + assert payload.accepted.scheme == SCHEME_EXACT + assert payload.accepted.amount == "5000000" + assert payload.accepted.network == TVM_TESTNET + + tvm_payload = ExactTvmPayload.from_dict(payload.payload) + settlement = parse_exact_tvm_payload(tvm_payload.settlement_boc) + + assert tvm_payload.asset == USDT_TESTNET_MINTER + assert settlement.payer == self.client_address + assert settlement.state_init is None + assert settlement.transfer.destination == self.facilitator_address + assert settlement.transfer.response_destination is None + assert settlement.transfer.jetton_amount == 5_000_000 + assert settlement.transfer.forward_ton_amount == 0 + assert settlement.transfer.source_wallet == self.client_jetton_wallet + + def test_invalid_recipient_fails_verification(self) -> None: + """Test that mismatched recipient fails verification.""" + self._require_live_balances() + + accepts = [ + build_tvm_payment_requirements( + self.facilitator_address, + TEST_PAYMENT_AMOUNT, + ) + ] + payment_required = self.server.create_payment_required_response(accepts) + payload = self.client.create_payment_payload(payment_required) + + different_accepts = [ + build_tvm_payment_requirements( + self.client_address, + TEST_PAYMENT_AMOUNT, + ) + ] + + verify_response = self.server.verify_payment(payload, different_accepts[0]) + assert verify_response.is_valid is False + assert verify_response.invalid_reason == ERR_EXACT_TVM_INVALID_RECIPIENT + + def test_insufficient_amount_fails_verification(self) -> None: + """Test that insufficient amount fails verification.""" + self._require_live_balances() + + accepts = [ + build_tvm_payment_requirements( + self.facilitator_address, + TEST_PAYMENT_AMOUNT, + ) + ] + payment_required = self.server.create_payment_required_response(accepts) + payload = self.client.create_payment_payload(payment_required) + + higher_accepts = [ + build_tvm_payment_requirements( + self.facilitator_address, + "2000", + ) + ] + + verify_response = self.server.verify_payment(payload, higher_accepts[0]) + assert verify_response.is_valid is False + assert verify_response.invalid_reason == ERR_EXACT_TVM_INVALID_AMOUNT + + def test_duplicate_settlement_fails_on_second_attempt(self) -> None: + """Test that settling the same payload twice is rejected as duplicate.""" + self._require_live_balances() + + accepts = [ + build_tvm_payment_requirements( + self.facilitator_address, + TEST_PAYMENT_AMOUNT, + ) + ] + payment_required = self.server.create_payment_required_response(accepts) + payload = self.client.create_payment_payload(payment_required) + accepted = self.server.find_matching_requirements(accepts, payload) + assert accepted is not None + + first_settle = self.server.settle_payment(payload, accepted) + assert first_settle.success is True + + second_settle = self.server.settle_payment(payload, accepted) + assert second_settle.success is False + assert second_settle.error_reason in { + ERR_DUPLICATE_SETTLEMENT, + ERR_INVALID_SEQNO, + } + + def test_facilitator_get_supported(self) -> None: + """Test that facilitator returns supported kinds.""" + supported = self.facilitator.get_supported() + + tvm_support = None + for kind in supported.kinds: + if kind.network == TVM_TESTNET and kind.scheme == SCHEME_EXACT: + tvm_support = kind + break + + assert tvm_support is not None + assert tvm_support.x402_version == 2 + assert tvm_support.extra is not None + assert tvm_support.extra.get("areFeesSponsored") is True + + +class TestTvmPriceParsing: + """Tests for TVM server price parsing via resource-server integration.""" + + def setup_method(self) -> None: + facilitator_config = HighloadV3Config.from_private_key(TVM_FACILITATOR_PRIVATE_KEY) + facilitator_config.api_key = TONCENTER_API_KEY + facilitator_config.toncenter_base_url = TONCENTER_BASE_URL + self.facilitator_signer = FacilitatorHighloadV3Signer({TVM_TESTNET: facilitator_config}) + self.facilitator_address = self.facilitator_signer.get_addresses()[0] + + self.facilitator = x402FacilitatorSync().register( + [TVM_TESTNET], + ExactTvmFacilitatorScheme(self.facilitator_signer), + ) + + facilitator_client = TvmFacilitatorClientSync(self.facilitator) + self.server = x402ResourceServerSync(facilitator_client) + self.tvm_server = ExactTvmServerScheme() + self.server.register(TVM_TESTNET, self.tvm_server) + self.server.initialize() + + def teardown_method(self) -> None: + self.facilitator_signer.close() + + def test_parse_money_formats(self) -> None: + test_cases = [ + ("$1.00", "1000000"), + ("1.50", "1500000"), + (2.5, "2500000"), + ("$0.001", "1000"), + ] + + for input_price, expected_amount in test_cases: + config = ResourceConfig( + scheme=SCHEME_EXACT, + pay_to=self.facilitator_address, + price=input_price, + network=TVM_TESTNET, + ) + requirements = self.server.build_payment_requirements(config) + + assert len(requirements) == 1 + assert requirements[0].amount == expected_amount + assert requirements[0].asset == USDT_TESTNET_MINTER + assert requirements[0].extra.get("areFeesSponsored") is True + + def test_asset_amount_passthrough(self) -> None: + from x402.schemas import AssetAmount + + custom_asset = AssetAmount( + amount="5000000", + asset="0:" + "a" * 64, + extra={"foo": "bar"}, + ) + + config = ResourceConfig( + scheme=SCHEME_EXACT, + pay_to=self.facilitator_address, + price=custom_asset, + network=TVM_TESTNET, + ) + requirements = self.server.build_payment_requirements(config) + + assert len(requirements) == 1 + assert requirements[0].amount == "5000000" + assert requirements[0].asset == "0:" + "a" * 64 + assert requirements[0].extra == { + "foo": "bar", + "areFeesSponsored": True, + } + def test_custom_money_parser(self) -> None: + from x402.schemas import AssetAmount + + def large_amount_parser(amount: float, network: str): + if amount > 100: + return AssetAmount( + amount=str(int(amount * 1_000_000_000)), + asset="0:" + "b" * 64, + extra={"token": "LARGE", "tier": "large"}, + ) + return None + + self.tvm_server.register_money_parser(large_amount_parser) + + large_config = ResourceConfig( + scheme=SCHEME_EXACT, + pay_to=self.facilitator_address, + price=150, + network=TVM_TESTNET, + ) + large_req = self.server.build_payment_requirements(large_config) + + assert large_req[0].asset == "0:" + "b" * 64 + assert large_req[0].extra.get("token") == "LARGE" + assert large_req[0].extra.get("tier") == "large" + + small_config = ResourceConfig( + scheme=SCHEME_EXACT, + pay_to=self.facilitator_address, + price=50, + network=TVM_TESTNET, + ) + small_req = self.server.build_payment_requirements(small_config) + + assert small_req[0].asset == USDT_TESTNET_MINTER diff --git a/python/x402/tests/unit/mechanisms/tvm/__init__.py b/python/x402/tests/unit/mechanisms/tvm/__init__.py new file mode 100644 index 0000000000..d1e5a3000b --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the TVM mechanism.""" diff --git a/python/x402/tests/unit/mechanisms/tvm/builders.py b/python/x402/tests/unit/mechanisms/tvm/builders.py new file mode 100644 index 0000000000..d8f1078957 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/builders.py @@ -0,0 +1,128 @@ +"""Test data builders for TVM mechanism unit tests.""" + +from __future__ import annotations + +import base64 +import time +from dataclasses import dataclass + +from pytoniq_core import begin_cell +from pytoniq_core.crypto.keys import mnemonic_to_wallet_key + +from x402.mechanisms.tvm import TVM_TESTNET, ParsedJettonTransfer, ParsedTvmSettlement +from x402.schemas import PaymentPayload, PaymentRequirements, ResourceInfo + +PAYER = "0:" + "1" * 64 +MERCHANT = "0:" + "2" * 64 +ASSET = "0:" + "3" * 64 +SOURCE_WALLET = "0:" + "4" * 64 +RECEIVER_WALLET = "0:" + "5" * 64 +RESPONSE_DESTINATION = "0:" + "6" * 64 +FACILITATOR = "0:" + "f" * 64 + +TEST_MNEMONIC = ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +) + +EMPTY_FORWARD_PAYLOAD = begin_cell().store_bit(0).end_cell() +EMPTY_FORWARD_PAYLOAD_B64 = base64.b64encode(EMPTY_FORWARD_PAYLOAD.to_boc()).decode("ascii") +SPONSORED_FORWARDING_EXTRA = { + "areFeesSponsored": True, + "forwardPayload": EMPTY_FORWARD_PAYLOAD_B64, + "forwardTonAmount": "0", +} +SPONSORED_EXTRA = { + "areFeesSponsored": True, + "forwardTonAmount": "0", +} + + +def derive_test_secret_key() -> bytes: + """Return a deterministic secret key used across signer tests.""" + _, secret_key = mnemonic_to_wallet_key(TEST_MNEMONIC.split()) + return secret_key + + +def make_tvm_requirements( + *, + default_extra: dict[str, object] | None = None, + extra: dict[str, object] | None = None, + **overrides: object, +) -> PaymentRequirements: + merged_extra = dict(default_extra or {}) + merged_extra.update(extra or {}) + return PaymentRequirements( + scheme="exact", + network=overrides.pop("network", TVM_TESTNET), + asset=overrides.pop("asset", ASSET), + amount=overrides.pop("amount", "100"), + pay_to=overrides.pop("pay_to", MERCHANT), + max_timeout_seconds=overrides.pop("max_timeout_seconds", 300), + extra=merged_extra, + **overrides, + ) + + +def make_tvm_payload( + *, + default_accepted_extra: dict[str, object] | None = None, + accepted_extra: dict[str, object] | None = None, + payload: dict[str, object] | None = None, + **overrides: object, +) -> PaymentPayload: + merged_extra = dict(default_accepted_extra or {}) + merged_extra.update(accepted_extra or {}) + resolved_payload = payload or { + "settlementBoc": overrides.pop("settlement_boc", "base64-boc=="), + "asset": overrides.pop("payload_asset", ASSET), + } + return PaymentPayload( + x402_version=overrides.pop("x402_version", 2), + resource=ResourceInfo( + url="http://example.com/protected", + description="Test resource", + mime_type="application/json", + ), + accepted=PaymentRequirements( + scheme=overrides.pop("accepted_scheme", "exact"), + network=overrides.pop("accepted_network", TVM_TESTNET), + asset=overrides.pop("accepted_asset", ASSET), + amount=overrides.pop("accepted_amount", "100"), + pay_to=overrides.pop("accepted_pay_to", MERCHANT), + max_timeout_seconds=overrides.pop("accepted_max_timeout_seconds", 300), + extra=merged_extra, + ), + payload=resolved_payload, + **overrides, + ) + + +@dataclass +class FakeCell: + hash: bytes + + +def make_tvm_settlement(**overrides: object) -> ParsedTvmSettlement: + transfer = ParsedJettonTransfer( + source_wallet=overrides.pop("source_wallet", SOURCE_WALLET), + destination=overrides.pop("destination", MERCHANT), + response_destination=overrides.pop("response_destination", None), + jetton_amount=overrides.pop("jetton_amount", 100), + attached_ton_amount=overrides.pop("attached_ton_amount", 500_000), + forward_ton_amount=overrides.pop("forward_ton_amount", 0), + forward_payload=overrides.pop("forward_payload", EMPTY_FORWARD_PAYLOAD), + body_hash=overrides.pop("body_hash", b"transfer-body-hash"), + ) + return ParsedTvmSettlement( + payer=overrides.pop("payer", PAYER), + wallet_id=overrides.pop("wallet_id", 777), + valid_until=overrides.pop("valid_until", int(time.time()) + 120), + seqno=overrides.pop("seqno", 12), + settlement_hash=overrides.pop("settlement_hash", "settlement-hash-1"), + body=overrides.pop("body", FakeCell(b"body-hash")), + signed_slice_hash=overrides.pop("signed_slice_hash", b"signed-slice"), + signature=overrides.pop("signature", b"signature"), + state_init=overrides.pop("state_init", None), + transfer=transfer, + **overrides, + ) diff --git a/python/x402/tests/unit/mechanisms/tvm/fakes.py b/python/x402/tests/unit/mechanisms/tvm/fakes.py new file mode 100644 index 0000000000..13e1d586b6 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/fakes.py @@ -0,0 +1,275 @@ +"""Test doubles for TVM mechanism unit tests.""" + +from __future__ import annotations + +from typing import Any + +from x402.mechanisms.tvm import ( + TVM_TESTNET, + TvmAccountState, + TvmJettonWalletData, + TvmRelayRequest, + address_from_state_init, + build_w5r1_state_init, +) +from x402.mechanisms.tvm.constants import ( + DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS, + DEFAULT_TVM_EMULATION_ADDRESS, +) + +from .builders import ASSET, FACILITATOR, PAYER, RECEIVER_WALLET, SOURCE_WALLET + + +class ClientSignerStub: + def __init__(self) -> None: + self._wallet_id = 7 + self._state_init = build_w5r1_state_init(b"\x11" * 32, self._wallet_id) + self._address = address_from_state_init(self._state_init, 0) + + @property + def address(self) -> str: + return self._address + + @property + def network(self) -> str: + return TVM_TESTNET + + @property + def wallet_id(self) -> int: + return self._wallet_id + + @property + def state_init(self): + return self._state_init + + @property + def toncenter_emulation_timeout_seconds(self) -> float: + return DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS + + def sign_message(self, message: bytes) -> bytes: + assert message + return b"\x00" * 64 + + +class ToncenterClientStub: + def __init__( + self, + *, + is_active: bool = False, + source_wallet_balance: int = 0, + source_wallet_fwd_fees: list[int] | None = None, + source_wallet_compute_fee: int = 0, + receiver_wallet_compute_fee: int = 0, + source_wallet_storage_fee: int = 0, + omit_receiver_tx: bool = False, + source_action_total_fwd_fees: int | None = None, + signer: ClientSignerStub | None = None, + ) -> None: + self._is_active = is_active + self._source_wallet_balance = source_wallet_balance + self._source_wallet_fwd_fees = source_wallet_fwd_fees or [0] + self._source_wallet_compute_fee = source_wallet_compute_fee + self._receiver_wallet_compute_fee = receiver_wallet_compute_fee + self._source_wallet_storage_fee = source_wallet_storage_fee + self._omit_receiver_tx = omit_receiver_tx + self._source_action_total_fwd_fees = source_action_total_fwd_fees + self._signer = signer or ClientSignerStub() + self.get_account_state_calls = 0 + self.get_jetton_wallet_calls: list[tuple[str, str]] = [] + self.emulate_trace_calls: list[dict[str, object]] = [] + self.close_calls = 0 + + def get_account_state(self, address: str) -> TvmAccountState: + self.get_account_state_calls += 1 + return TvmAccountState( + address=address, + balance=0, + is_active=self._is_active, + is_uninitialized=not self._is_active, + is_frozen=False, + state_init=self._signer.state_init if self._is_active else None, + ) + + def get_jetton_wallet(self, asset: str, owner: str) -> str: + self.get_jetton_wallet_calls.append((asset, owner)) + if owner == self._signer.address: + return SOURCE_WALLET + return RECEIVER_WALLET + + def emulate_trace( + self, + boc: bytes, + *, + ignore_chksig: bool = False, + timeout: float = DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS, + ) -> dict[str, object]: + _ = boc + self.emulate_trace_calls.append({"ignore_chksig": ignore_chksig, "timeout": timeout}) + payer = self._signer.address + emulation_address = DEFAULT_TVM_EMULATION_ADDRESS + relay_out_hash = "relay-out-hash" + payer_out_hash = "payer-out-hash" + source_out_hash = "source-out-hash" + transactions: dict[str, Any] = { + "payer": { + "account": payer, + "description": { + "aborted": False, + "action": {"success": True}, + "compute_ph": {"success": True, "skipped": False}, + }, + "in_msg": ( + { + "hash": relay_out_hash, + "hash_norm": relay_out_hash, + "source": emulation_address, + "destination": payer, + "decoded_opcode": "w5_internal_signed_request", + } + if ignore_chksig + else {"decoded_opcode": "w5_external_signed_request"} + ), + "out_msgs": [{"hash": payer_out_hash, "hash_norm": payer_out_hash}], + }, + "source": { + "account": SOURCE_WALLET, + "account_state_before": {"balance": str(self._source_wallet_balance)}, + "description": { + "aborted": False, + "action": { + "success": True, + **( + {"total_fwd_fees": str(self._source_action_total_fwd_fees)} + if self._source_action_total_fwd_fees is not None + else {} + ), + }, + "compute_ph": { + "success": True, + "skipped": False, + "gas_fees": str(self._source_wallet_compute_fee), + }, + "storage_ph": {"storage_fees_collected": str(self._source_wallet_storage_fee)}, + }, + "in_msg": { + "hash": payer_out_hash, + "hash_norm": payer_out_hash, + "source": payer, + "destination": SOURCE_WALLET, + "decoded_opcode": "jetton_transfer", + }, + "out_msgs": [ + { + "hash": source_out_hash, + "hash_norm": source_out_hash, + "source": SOURCE_WALLET, + "destination": RECEIVER_WALLET, + "decoded_opcode": "jetton_internal_transfer", + "message_content": { + "decoded": { + "@type": "jetton_internal_transfer", + } + }, + **({"fwd_fee": str(fee)} if fee is not None else {}), + } + for fee in self._source_wallet_fwd_fees + ], + }, + } + if not self._omit_receiver_tx: + transactions["receiver"] = { + "account": RECEIVER_WALLET, + "description": { + "aborted": False, + "action": {"success": True}, + "compute_ph": { + "success": True, + "skipped": False, + "gas_fees": str(self._receiver_wallet_compute_fee), + }, + }, + "in_msg": { + "hash": source_out_hash, + "hash_norm": source_out_hash, + "source": SOURCE_WALLET, + "destination": RECEIVER_WALLET, + "decoded_opcode": "jetton_internal_transfer", + }, + } + if ignore_chksig: + transactions["emulation"] = { + "account": emulation_address, + "description": { + "aborted": False, + "action": {"success": True}, + "compute_ph": {"success": True, "skipped": False}, + }, + "in_msg": {"decoded_opcode": "w5_external_signed_request"}, + "out_msgs": [ + { + "hash": relay_out_hash, + "hash_norm": relay_out_hash, + "source": emulation_address, + "destination": payer, + } + ], + } + return {"transactions": transactions} + + def close(self) -> None: + self.close_calls += 1 + + +class FacilitatorSignerStub: + def __init__(self) -> None: + self.account_state = TvmAccountState( + address=PAYER, + balance=0, + is_active=True, + is_frozen=False, + is_uninitialized=False, + state_init=None, + ) + self.jetton_wallet_data = TvmJettonWalletData( + address=SOURCE_WALLET, + balance=1_000_000, + owner=PAYER, + jetton_minter=ASSET, + ) + self.last_relay_request: TvmRelayRequest | None = None + + def get_addresses(self) -> list[str]: + return [FACILITATOR] + + def get_addresses_for_network(self, network: str) -> list[str]: + assert network == TVM_TESTNET + return [FACILITATOR] + + def get_account_state(self, address: str, network: str) -> TvmAccountState: + assert address == PAYER + assert network == TVM_TESTNET + return self.account_state + + def get_jetton_wallet(self, asset: str, owner: str, network: str) -> str: + assert asset == ASSET + assert owner == PAYER + assert network == TVM_TESTNET + return SOURCE_WALLET + + def get_jetton_wallet_data(self, address: str, network: str) -> TvmJettonWalletData: + assert address == SOURCE_WALLET + assert network == TVM_TESTNET + return self.jetton_wallet_data + + def build_relay_external_boc( + self, network: str, relay_request: TvmRelayRequest, *, for_emulation: bool = False + ) -> bytes: + assert network == TVM_TESTNET + assert for_emulation is True + self.last_relay_request = relay_request + return b"external-boc" + + def emulate_external_message(self, network: str, external_boc: bytes) -> dict[str, object]: + assert network == TVM_TESTNET + assert external_boc == b"external-boc" + return {"transactions": {}} diff --git a/python/x402/tests/unit/mechanisms/tvm/helpers.py b/python/x402/tests/unit/mechanisms/tvm/helpers.py new file mode 100644 index 0000000000..d4b7de0533 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/helpers.py @@ -0,0 +1,45 @@ +"""Generic test helpers for TVM mechanism unit tests.""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass + + +@dataclass +class ThreadCapture: + thread: threading.Thread + holder: dict[str, object] + + def join(self, timeout: float = 1.0) -> None: + self.thread.join(timeout=timeout) + assert self.thread.is_alive() is False + + @property + def result(self) -> object: + if "error" in self.holder: + raise AssertionError(f"Thread raised unexpectedly: {self.holder['error']!r}") + return self.holder["result"] + + @property + def error(self) -> BaseException: + error = self.holder.get("error") + assert isinstance(error, BaseException) + return error + + +def start_captured_thread(target, *, timeout: float | None = None) -> ThreadCapture: + """Start a thread and capture either its return value or its terminal exception.""" + holder: dict[str, object] = {} + + def runner() -> None: + try: + holder["result"] = target() + except BaseException as exc: # pragma: no cover - exercised via tests + holder["error"] = exc + + thread = threading.Thread(target=runner) + thread.start() + if timeout is not None: + thread.join(timeout=timeout) + return ThreadCapture(thread=thread, holder=holder) diff --git a/python/x402/tests/unit/mechanisms/tvm/test_client.py b/python/x402/tests/unit/mechanisms/tvm/test_client.py new file mode 100644 index 0000000000..e93dfa86fc --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/test_client.py @@ -0,0 +1,304 @@ +"""Tests for the exact TVM client scheme.""" + +from __future__ import annotations + +import base64 + +import pytest + +pytest.importorskip("pytoniq_core") + +from pytoniq.contract.contract import Contract +from pytoniq_core import Address, begin_cell + +from x402.mechanisms.tvm import ( + TVM_TESTNET, +) +from x402.mechanisms.tvm.constants import ( + DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS, + DEFAULT_TVM_EMULATION_ADDRESS, + DEFAULT_TVM_INNER_GAS_BUFFER, +) +from x402.mechanisms.tvm.exact import ExactTvmClientScheme +from x402.mechanisms.tvm.exact.codec import parse_exact_tvm_payload +from .builders import ( + ASSET, + MERCHANT, + RESPONSE_DESTINATION, + SOURCE_WALLET, + SPONSORED_EXTRA, + make_tvm_requirements, +) +from .fakes import ClientSignerStub, ToncenterClientStub + + +def _make_requirements(**overrides): + return make_tvm_requirements(default_extra=SPONSORED_EXTRA, **overrides) + + +class TestExactTvmClientSchemeConstructor: + def test_should_create_instance_with_correct_scheme(self): + signer = ClientSignerStub() + + client = ExactTvmClientScheme(signer) + + assert client.scheme == "exact" + + def test_should_store_signer_reference(self): + signer = ClientSignerStub() + + client = ExactTvmClientScheme(signer) + + assert client._signer is signer + + def test_close_should_close_cached_toncenter_clients(self): + client = ExactTvmClientScheme(ClientSignerStub()) + testnet_client = ToncenterClientStub() + mainnet_client = ToncenterClientStub() + client._clients = { + TVM_TESTNET: testnet_client, + "tvm:-239": mainnet_client, + } + + client.close() + + assert testnet_client.close_calls == 1 + assert mainnet_client.close_calls == 1 + assert client._clients == {} + + def test_close_should_be_idempotent(self): + client = ExactTvmClientScheme(ClientSignerStub()) + + client.close() + client.close() + + +class TestCreatePaymentPayload: + def test_should_have_create_payment_payload_method(self): + client = ExactTvmClientScheme(ClientSignerStub()) + + assert hasattr(client, "create_payment_payload") + assert callable(client.create_payment_payload) + + def test_should_reject_unsupported_network(self): + client = ExactTvmClientScheme(ClientSignerStub()) + + with pytest.raises(ValueError, match="Unsupported TVM network"): + client.create_payment_payload(_make_requirements(network="tvm:999")) + + def test_should_reject_requirements_for_different_signer_network(self): + client = ExactTvmClientScheme(ClientSignerStub()) + + with pytest.raises(ValueError, match="Signer network .* does not match requirements"): + client.create_payment_payload(_make_requirements(network="tvm:-239")) + + def test_should_require_fee_sponsorship(self): + client = ExactTvmClientScheme(ClientSignerStub()) + + with pytest.raises(ValueError, match="requires extra.areFeesSponsored to be true"): + client.create_payment_payload(_make_requirements(extra={"areFeesSponsored": False})) + + def test_should_create_payload_with_forward_defaults(self, monkeypatch): + signer = ClientSignerStub() + client = ExactTvmClientScheme(signer) + toncenter = ToncenterClientStub( + is_active=True, + source_wallet_balance=1_000_000, + source_wallet_fwd_fees=[200_000], + source_wallet_compute_fee=300_000, + receiver_wallet_compute_fee=400_000, + source_wallet_storage_fee=500_000, + signer=signer, + ) + monkeypatch.setattr(client, "_get_client", lambda network: toncenter) + + payload = client.create_payment_payload(_make_requirements()) + settlement = parse_exact_tvm_payload(payload["settlementBoc"]) + + assert payload["asset"] == ASSET + assert settlement.transfer.destination == MERCHANT + assert settlement.transfer.source_wallet == SOURCE_WALLET + assert settlement.transfer.response_destination is None + assert settlement.transfer.forward_ton_amount == 0 + assert settlement.transfer.forward_payload.hash == begin_cell().store_bit(0).end_cell().hash + assert settlement.transfer.attached_ton_amount == ( + 200_000 + 300_000 + 400_000 + 500_000 + DEFAULT_TVM_INNER_GAS_BUFFER + ) + assert toncenter.get_account_state_calls == 1 + assert toncenter.get_jetton_wallet_calls == [(ASSET, signer.address)] + assert toncenter.emulate_trace_calls == [ + { + "ignore_chksig": True, + "timeout": DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS, + } + ] + + def test_should_use_default_emulation_wallet_to_relay_into_undeployed_payer(self, monkeypatch): + signer = ClientSignerStub() + client = ExactTvmClientScheme(signer) + toncenter = ToncenterClientStub( + is_active=False, + source_wallet_balance=1_000_000, + source_wallet_fwd_fees=[200_000], + source_wallet_compute_fee=300_000, + receiver_wallet_compute_fee=400_000, + source_wallet_storage_fee=500_000, + signer=signer, + ) + monkeypatch.setattr(client, "_get_client", lambda network: toncenter) + + payload = client.create_payment_payload(_make_requirements()) + settlement = parse_exact_tvm_payload(payload["settlementBoc"]) + + assert settlement.transfer.source_wallet == SOURCE_WALLET + assert toncenter.get_account_state_calls == 1 + assert toncenter.get_jetton_wallet_calls == [(ASSET, signer.address)] + assert toncenter.emulate_trace_calls == [ + { + "ignore_chksig": True, + "timeout": DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS, + } + ] + + def test_should_use_signer_emulation_timeout_override(self, monkeypatch): + class _CustomTimeoutSigner(ClientSignerStub): + @property + def toncenter_emulation_timeout_seconds(self) -> float: + return 14.0 + + client = ExactTvmClientScheme(_CustomTimeoutSigner()) + toncenter = ToncenterClientStub( + is_active=True, + source_wallet_balance=1_000_000, + source_wallet_fwd_fees=[200_000], + source_wallet_compute_fee=300_000, + receiver_wallet_compute_fee=400_000, + source_wallet_storage_fee=500_000, + signer=client._signer, + ) + monkeypatch.setattr(client, "_get_client", lambda network: toncenter) + + client.create_payment_payload(_make_requirements()) + + assert toncenter.emulate_trace_calls == [{"ignore_chksig": True, "timeout": 14.0}] + + def test_should_create_payload_with_custom_forward_settings(self, monkeypatch): + signer = ClientSignerStub() + client = ExactTvmClientScheme(signer) + toncenter = ToncenterClientStub( + is_active=True, + source_wallet_balance=1_000_000, + source_wallet_fwd_fees=[200_000, 250_000], + source_wallet_compute_fee=300_000, + receiver_wallet_compute_fee=400_000, + source_wallet_storage_fee=500_000, + signer=signer, + ) + monkeypatch.setattr(client, "_get_client", lambda network: toncenter) + forward_payload = begin_cell().store_uint(0xABCD, 16).end_cell() + + payload = client.create_payment_payload( + _make_requirements( + extra={ + "areFeesSponsored": True, + "responseDestination": RESPONSE_DESTINATION, + "forwardTonAmount": "50000000", + "forwardPayload": base64.b64encode(forward_payload.to_boc()).decode("ascii"), + } + ) + ) + settlement = parse_exact_tvm_payload(payload["settlementBoc"]) + + assert settlement.transfer.response_destination == RESPONSE_DESTINATION + assert settlement.transfer.forward_ton_amount == 50_000_000 + assert settlement.transfer.forward_payload.hash == forward_payload.hash + assert settlement.transfer.attached_ton_amount == 58_750_000 + + def test_should_raise_when_trace_does_not_include_destination_wallet_transfer( + self, monkeypatch + ): + signer = ClientSignerStub() + client = ExactTvmClientScheme(signer) + monkeypatch.setattr( + client, + "_get_client", + lambda network: ToncenterClientStub( + is_active=True, + source_wallet_balance=1_000_000, + source_wallet_fwd_fees=[200_000], + source_wallet_compute_fee=300_000, + receiver_wallet_compute_fee=400_000, + source_wallet_storage_fee=500_000, + omit_receiver_tx=True, + signer=signer, + ), + ) + + with pytest.raises( + ValueError, + match="Trace does not contain the expected destination jetton wallet transaction", + ): + client.create_payment_payload(_make_requirements()) + + def test_build_transfer_body_should_reject_negative_forward_ton_amount(self): + client = ExactTvmClientScheme(ClientSignerStub()) + + with pytest.raises(ValueError, match="Forward ton amount should be >= 0"): + client._build_transfer_body(_make_requirements(extra={"forwardTonAmount": "-1"})) + + def test_build_transfer_body_should_store_explicit_forward_payload(self): + client = ExactTvmClientScheme(ClientSignerStub()) + forward_payload = begin_cell().store_uint(0xABCD, 16).end_cell() + + transfer_body = client._build_transfer_body( + _make_requirements( + extra={ + "forwardTonAmount": "777", + "forwardPayload": base64.b64encode(forward_payload.to_boc()).decode("ascii"), + } + ) + ) + transfer = parse_exact_tvm_payload( + base64.b64encode( + Contract.create_internal_msg( + src=None, + dest=Address(ClientSignerStub().address), + bounce=True, + value=0, + body=client._build_signed_body( + source_wallet=SOURCE_WALLET, + transfer_body=transfer_body, + seqno=1, + valid_until=2, + attached_amount=3, + ), + ) + .serialize() + .to_boc() + ).decode("ascii") + ) + + assert transfer.transfer.forward_ton_amount == 777 + assert transfer.transfer.forward_payload.hash == forward_payload.hash + + def test_estimate_required_inner_value_should_fallback_to_action_phase_fees(self, monkeypatch): + signer = ClientSignerStub() + client = ExactTvmClientScheme(signer) + toncenter = ToncenterClientStub( + is_active=True, + source_wallet_balance=1_000_000, + source_wallet_fwd_fees=[None], + source_wallet_compute_fee=300_000, + receiver_wallet_compute_fee=400_000, + source_wallet_storage_fee=500_000, + source_action_total_fwd_fees=200_000, + signer=signer, + ) + monkeypatch.setattr(client, "_get_client", lambda network: toncenter) + + payload = client.create_payment_payload(_make_requirements()) + settlement = parse_exact_tvm_payload(payload["settlementBoc"]) + + assert settlement.transfer.attached_ton_amount == ( + 200_000 + 300_000 + 400_000 + 500_000 + DEFAULT_TVM_INNER_GAS_BUFFER + ) diff --git a/python/x402/tests/unit/mechanisms/tvm/test_codec.py b/python/x402/tests/unit/mechanisms/tvm/test_codec.py new file mode 100644 index 0000000000..c711272188 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/test_codec.py @@ -0,0 +1,213 @@ +"""Focused tests for the exact TVM settlement payload codec.""" + +from __future__ import annotations + +import base64 + +import pytest + +pytest.importorskip("pytoniq_core") + +from pytoniq.contract.contract import Contract +from pytoniq_core import Address, Cell, begin_cell + +from x402.mechanisms.tvm.constants import ( + ERR_EXACT_TVM_INVALID_SETTLEMENT_BOC, + ERR_EXACT_TVM_INVALID_W5_ACTIONS, + ERR_EXACT_TVM_INVALID_W5_MESSAGE, + JETTON_TRANSFER_OPCODE, + SEND_MODE_IGNORE_ERRORS, + SEND_MODE_PAY_FEES_SEPARATELY, + W5_INTERNAL_SIGNED_OPCODE, +) +from x402.mechanisms.tvm.exact.codec import parse_exact_tvm_payload +from x402.mechanisms.tvm.codecs.w5 import serialize_out_list, serialize_send_msg_action + +PAYER = "0:" + "1" * 64 +SOURCE_WALLET = "0:" + "2" * 64 +DESTINATION = "0:" + "3" * 64 +RESPONSE_DESTINATION = "0:" + "4" * 64 + + +def _make_transfer_body( + *, + destination: str = DESTINATION, + response_destination: str | None = RESPONSE_DESTINATION, + amount: int = 123, + forward_ton_amount: int = 456, + forward_payload: Cell | None = None, +) -> Cell: + builder = ( + begin_cell() + .store_uint(JETTON_TRANSFER_OPCODE, 32) + .store_uint(0, 64) + .store_coins(amount) + .store_address(Address(destination)) + .store_address(Address(response_destination) if response_destination else None) + .store_bit(0) + .store_coins(forward_ton_amount) + ) + if forward_payload is None: + builder = builder.store_bit(0) + else: + builder = builder.store_bit(1).store_ref(forward_payload) + return builder.end_cell() + + +def _make_signed_body( + *, + action_cell: Cell | None = None, + opcode: int = W5_INTERNAL_SIGNED_OPCODE, + wallet_id: int = 7, + valid_until: int = 111, + seqno: int = 9, + has_actions: bool = True, + has_extra_actions: bool = False, + signature: bytes = b"\xaa" * 64, + trailing_bit: bool = False, +) -> Cell: + if action_cell is None: + out_msg = Contract.create_internal_msg( + src=None, + dest=Address(SOURCE_WALLET), + bounce=True, + value=999, + body=_make_transfer_body(forward_payload=begin_cell().store_uint(0xAB, 8).end_cell()), + ).serialize() + action_cell = serialize_send_msg_action(out_msg, SEND_MODE_PAY_FEES_SEPARATELY) + + builder = ( + begin_cell() + .store_uint(opcode, 32) + .store_uint(wallet_id, 32) + .store_uint(valid_until, 32) + .store_uint(seqno, 32) + ) + if has_actions: + builder = builder.store_bit(1).store_ref(serialize_out_list([action_cell])) + else: + builder = builder.store_bit(0) + builder = builder.store_bit(1 if has_extra_actions else 0).store_bytes(signature) + if trailing_bit: + builder = builder.store_bit(1) + return builder.end_cell() + + +def _make_settlement_boc( + *, + body: Cell | None = None, + bounce: bool = True, + internal: bool = True, +) -> str: + body = body or _make_signed_body() + if internal: + message = Contract.create_internal_msg( + src=None, + dest=Address(PAYER), + bounce=bounce, + value=0, + body=body, + ) + else: + message = Contract.create_external_msg( + dest=Address(PAYER), + body=body, + ) + return base64.b64encode(message.serialize().to_boc()).decode("ascii") + + +class TestParseExactTvmPayload: + def test_should_reject_malformed_boc(self): + with pytest.raises(ValueError, match=ERR_EXACT_TVM_INVALID_SETTLEMENT_BOC): + parse_exact_tvm_payload("not-base64") + + def test_should_reject_non_internal_message(self): + with pytest.raises(ValueError, match=ERR_EXACT_TVM_INVALID_SETTLEMENT_BOC): + parse_exact_tvm_payload(_make_settlement_boc(internal=False)) + + def test_should_accept_non_bounceable_settlement_wrapper(self): + payload = parse_exact_tvm_payload(_make_settlement_boc(bounce=False)) + + assert payload.payer == PAYER + assert payload.transfer.source_wallet == SOURCE_WALLET + + def test_should_reject_wrong_w5_opcode(self): + with pytest.raises(ValueError, match=ERR_EXACT_TVM_INVALID_W5_MESSAGE): + parse_exact_tvm_payload(_make_settlement_boc(body=_make_signed_body(opcode=0xDEADBEEF))) + + def test_should_reject_extra_actions_flag(self): + with pytest.raises(ValueError, match=ERR_EXACT_TVM_INVALID_W5_ACTIONS): + parse_exact_tvm_payload( + _make_settlement_boc(body=_make_signed_body(has_extra_actions=True)) + ) + + def test_should_reject_wrong_action_count(self): + with pytest.raises(ValueError, match=ERR_EXACT_TVM_INVALID_W5_ACTIONS): + parse_exact_tvm_payload(_make_settlement_boc(body=_make_signed_body(has_actions=False))) + + def test_should_reject_invalid_action_type(self): + invalid_action = ( + begin_cell() + .store_uint(0xDEADBEEF, 32) + .store_uint(0, 8) + .store_ref(Cell.empty()) + .end_cell() + ) + + with pytest.raises(ValueError, match=ERR_EXACT_TVM_INVALID_W5_ACTIONS): + parse_exact_tvm_payload( + _make_settlement_boc(body=_make_signed_body(action_cell=invalid_action)) + ) + + def test_should_reject_wrong_send_mode(self): + out_msg = Contract.create_internal_msg( + src=None, + dest=Address(SOURCE_WALLET), + bounce=True, + value=999, + body=_make_transfer_body(), + ).serialize() + wrong_mode_action = serialize_send_msg_action(out_msg, SEND_MODE_PAY_FEES_SEPARATELY + 1) + + with pytest.raises(ValueError, match=ERR_EXACT_TVM_INVALID_W5_ACTIONS): + parse_exact_tvm_payload( + _make_settlement_boc(body=_make_signed_body(action_cell=wrong_mode_action)) + ) + + def test_should_accept_ignore_errors_send_mode(self): + out_msg = Contract.create_internal_msg( + src=None, + dest=Address(SOURCE_WALLET), + bounce=True, + value=999, + body=_make_transfer_body(), + ).serialize() + acceptable_action = serialize_send_msg_action( + out_msg, + SEND_MODE_PAY_FEES_SEPARATELY + SEND_MODE_IGNORE_ERRORS, + ) + + payload = parse_exact_tvm_payload( + _make_settlement_boc(body=_make_signed_body(action_cell=acceptable_action)) + ) + + assert payload.transfer.source_wallet == SOURCE_WALLET + + def test_should_reject_trailing_bits_after_signature(self): + with pytest.raises(ValueError, match=ERR_EXACT_TVM_INVALID_W5_MESSAGE): + parse_exact_tvm_payload(_make_settlement_boc(body=_make_signed_body(trailing_bit=True))) + + def test_should_parse_valid_settlement_boc(self): + payload = parse_exact_tvm_payload(_make_settlement_boc()) + + assert payload.payer == PAYER + assert payload.wallet_id == 7 + assert payload.valid_until == 111 + assert payload.seqno == 9 + assert payload.transfer.source_wallet == SOURCE_WALLET + assert payload.transfer.destination == DESTINATION + assert payload.transfer.response_destination == RESPONSE_DESTINATION + assert payload.transfer.jetton_amount == 123 + assert payload.transfer.attached_ton_amount == 999 + assert payload.transfer.forward_ton_amount == 456 + assert payload.signature == b"\xaa" * 64 diff --git a/python/x402/tests/unit/mechanisms/tvm/test_constants.py b/python/x402/tests/unit/mechanisms/tvm/test_constants.py new file mode 100644 index 0000000000..e13d2e4096 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/test_constants.py @@ -0,0 +1,66 @@ +"""Tests for TVM error constant exports.""" + +from __future__ import annotations + +from x402.mechanisms.tvm import ( + ERR_EXACT_TVM_ACCOUNT_FROZEN, + ERR_EXACT_TVM_DUPLICATE_SETTLEMENT, + ERR_EXACT_TVM_INSUFFICIENT_BALANCE, + ERR_EXACT_TVM_INVALID_AMOUNT, + ERR_EXACT_TVM_INVALID_ASSET, + ERR_EXACT_TVM_INVALID_CODE_HASH, + ERR_EXACT_TVM_INVALID_EXTENSIONS_DICT, + ERR_EXACT_TVM_INVALID_JETTON_TRANSFER, + ERR_EXACT_TVM_INVALID_PAYLOAD, + ERR_EXACT_TVM_INVALID_RECIPIENT, + ERR_EXACT_TVM_INVALID_SEQNO, + ERR_EXACT_TVM_INVALID_SETTLEMENT_BOC, + ERR_EXACT_TVM_INVALID_SIGNATURE, + ERR_EXACT_TVM_INVALID_SIGNATURE_MODE, + ERR_EXACT_TVM_INVALID_UNTIL_EXPIRED, + ERR_EXACT_TVM_INVALID_W5_ACTIONS, + ERR_EXACT_TVM_INVALID_W5_MESSAGE, + ERR_EXACT_TVM_INVALID_WALLET_ID, + ERR_EXACT_TVM_NETWORK_MISMATCH, + ERR_EXACT_TVM_SIMULATION_FAILED, + ERR_EXACT_TVM_TRANSACTION_FAILED, + ERR_EXACT_TVM_UNSUPPORTED_NETWORK, + ERR_EXACT_TVM_UNSUPPORTED_SCHEME, + ERR_EXACT_TVM_UNSUPPORTED_VERSION, + ERR_EXACT_TVM_VALID_UNTIL_TOO_FAR, +) + + +def test_should_export_canonical_tvm_error_constants() -> None: + assert ERR_EXACT_TVM_UNSUPPORTED_SCHEME == "unsupported_scheme" + assert ERR_EXACT_TVM_UNSUPPORTED_VERSION == "unsupported_version" + assert ERR_EXACT_TVM_UNSUPPORTED_NETWORK == "unsupported_network" + assert ERR_EXACT_TVM_NETWORK_MISMATCH == "network_mismatch" + assert ERR_EXACT_TVM_INVALID_PAYLOAD == "invalid_exact_tvm_payload" + assert ERR_EXACT_TVM_INVALID_SETTLEMENT_BOC == "invalid_exact_tvm_payload_settlement_boc" + assert ( + ERR_EXACT_TVM_INVALID_W5_MESSAGE == "invalid_exact_tvm_payload_w5_internal_signed_request" + ) + assert ERR_EXACT_TVM_INVALID_W5_ACTIONS == "invalid_exact_tvm_payload_w5_actions" + assert ERR_EXACT_TVM_INVALID_JETTON_TRANSFER == "invalid_exact_tvm_payload_jetton_transfer" + assert ERR_EXACT_TVM_INVALID_SIGNATURE == "invalid_exact_tvm_payload_invalid_signature" + assert ERR_EXACT_TVM_INVALID_CODE_HASH == "invalid_exact_tvm_payload_invalid_code_hash" + assert ERR_EXACT_TVM_INVALID_ASSET == "invalid_exact_tvm_payload_asset_mismatch" + assert ERR_EXACT_TVM_INVALID_RECIPIENT == "invalid_exact_tvm_payload_recipient_mismatch" + assert ERR_EXACT_TVM_INVALID_AMOUNT == "invalid_exact_tvm_payload_amount_mismatch" + assert ( + ERR_EXACT_TVM_INVALID_SIGNATURE_MODE == "invalid_exact_tvm_payload_signature_mode_mismatch" + ) + assert ERR_EXACT_TVM_INVALID_SEQNO == "invalid_exact_tvm_payload_seqno_mismatch" + assert ERR_EXACT_TVM_INVALID_WALLET_ID == "invalid_exact_tvm_payload_wallet_id_mismatch" + assert ( + ERR_EXACT_TVM_INVALID_EXTENSIONS_DICT + == "invalid_exact_tvm_payload_extensions_dict_mismatch" + ) + assert ERR_EXACT_TVM_ACCOUNT_FROZEN == "account_frozen" + assert ERR_EXACT_TVM_INVALID_UNTIL_EXPIRED == "invalid_exact_tvm_payload_valid_until_expired" + assert ERR_EXACT_TVM_VALID_UNTIL_TOO_FAR == "invalid_exact_tvm_payload_valid_until_too_far" + assert ERR_EXACT_TVM_INSUFFICIENT_BALANCE == "insufficient_balance" + assert ERR_EXACT_TVM_DUPLICATE_SETTLEMENT == "duplicate_settlement" + assert ERR_EXACT_TVM_SIMULATION_FAILED == "simulation_failed" + assert ERR_EXACT_TVM_TRANSACTION_FAILED == "transaction_failed" diff --git a/python/x402/tests/unit/mechanisms/tvm/test_facilitator.py b/python/x402/tests/unit/mechanisms/tvm/test_facilitator.py new file mode 100644 index 0000000000..e00cda7ca0 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/test_facilitator.py @@ -0,0 +1,627 @@ +"""Tests for the exact TVM facilitator scheme.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass + +import pytest + +pytest.importorskip("pytoniq_core") + +from pytoniq_core import begin_cell + +import x402.mechanisms.tvm.exact.facilitator as facilitator_module +from x402.mechanisms.tvm import ( + DEFAULT_SETTLEMENT_CONFIRMATION_WORKERS, + TVM_TESTNET, + SettlementCache, + TvmAccountState, +) +from x402.mechanisms.tvm.constants import ( + DEFAULT_TVM_OUTER_GAS_BUFFER, + ERR_EXACT_TVM_ACCOUNT_FROZEN, + ERR_EXACT_TVM_DUPLICATE_SETTLEMENT, + ERR_EXACT_TVM_INVALID_AMOUNT, + ERR_EXACT_TVM_INVALID_ASSET, + ERR_EXACT_TVM_INVALID_JETTON_TRANSFER, + ERR_EXACT_TVM_INVALID_PAYLOAD, + ERR_EXACT_TVM_INVALID_RECIPIENT, + ERR_EXACT_TVM_INVALID_SIGNATURE, + ERR_EXACT_TVM_INVALID_UNTIL_EXPIRED, + ERR_EXACT_TVM_NETWORK_MISMATCH, + ERR_EXACT_TVM_SIMULATION_FAILED, + ERR_EXACT_TVM_TRANSACTION_FAILED, + ERR_EXACT_TVM_UNSUPPORTED_NETWORK, + ERR_EXACT_TVM_UNSUPPORTED_SCHEME, + ERR_EXACT_TVM_UNSUPPORTED_VERSION, + ERR_EXACT_TVM_VALID_UNTIL_TOO_FAR, +) +from x402.mechanisms.tvm.exact import ExactTvmFacilitatorScheme +from x402.mechanisms.tvm.trace_utils import body_hash_to_base64 +from .builders import ( + ASSET, + FACILITATOR, + MERCHANT, + PAYER, + SOURCE_WALLET, + EMPTY_FORWARD_PAYLOAD, + EMPTY_FORWARD_PAYLOAD_B64, + SPONSORED_FORWARDING_EXTRA, + make_tvm_payload, + make_tvm_requirements, + make_tvm_settlement, +) +from .fakes import FacilitatorSignerStub + + +class _FakeBatcher: + def __init__( + self, + signer, + settlement_cache, + *, + flush_interval_seconds: float, + batch_flush_size: int, + confirmation_workers: int, + confirmation_timeout_seconds: float, + settlement_verifier, + ) -> None: + _ = signer, settlement_cache, flush_interval_seconds, batch_flush_size + _ = confirmation_timeout_seconds, settlement_verifier + self.confirmation_workers = confirmation_workers + self.enqueued: list[object] = [] + self.result = facilitator_module._BatchResult(success=True, transaction="trace-tx-hash") + self.error: Exception | None = None + + def enqueue(self, queued_settlement): + self.enqueued.append(queued_settlement) + if self.error is not None: + raise self.error + return self.result + + +def _assert_invalid_verify(result, reason: str, *, message: str | None = None) -> None: + assert result.is_valid is False + assert result.invalid_reason == reason + if message is not None: + assert result.invalid_message == message + + +def _assert_failed_settlement(result, reason: str, *, message: str | None = None) -> None: + assert result.success is False + assert result.error_reason == reason + assert result.transaction == "" + assert result.network == TVM_TESTNET + if message is not None: + assert result.error_message == message + + +def _make_requirements(**overrides): + return make_tvm_requirements(default_extra=SPONSORED_FORWARDING_EXTRA, **overrides) + + +def _make_payload(**overrides): + return make_tvm_payload(default_accepted_extra=SPONSORED_FORWARDING_EXTRA, **overrides) + + +@dataclass +class _FacilitatorEnv: + facilitator: ExactTvmFacilitatorScheme + signer: FacilitatorSignerStub + settlement: object + batchers: list[_FakeBatcher] + + def batcher(self) -> _FakeBatcher: + return self.batchers[-1] + + +@pytest.fixture +def facilitator_env(monkeypatch): + batchers: list[_FakeBatcher] = [] + + def _batcher_factory(*args, **kwargs): + batcher = _FakeBatcher(*args, **kwargs) + batchers.append(batcher) + return batcher + + signer = FacilitatorSignerStub() + settlement = make_tvm_settlement() + monkeypatch.setattr(facilitator_module, "_SettlementBatcher", _batcher_factory) + monkeypatch.setattr(facilitator_module, "parse_exact_tvm_payload", lambda boc: settlement) + monkeypatch.setattr( + facilitator_module, + "parse_active_w5_account_state", + lambda account: facilitator_module.W5InitData( + signature_allowed=True, + seqno=settlement.seqno, + wallet_id=settlement.wallet_id, + public_key=b"\x01" * 32, + extensions_dict=None, + ), + ) + monkeypatch.setattr(facilitator_module, "verify_w5_signature", lambda *args: True) + monkeypatch.setattr( + facilitator_module, + "trace_transaction_storage_fees", + lambda tx: 10_000, + ) + monkeypatch.setattr( + facilitator_module, + "trace_transaction_compute_fees", + lambda tx: 20_000, + ) + monkeypatch.setattr( + facilitator_module, + "trace_transaction_fwd_fees", + lambda tx: 30_000, + ) + monkeypatch.setattr( + ExactTvmFacilitatorScheme, + "_verify_finalized_trace_settlement", + staticmethod(lambda *args, **kwargs: {"hash": "payer-tx"}), + ) + + facilitator = ExactTvmFacilitatorScheme(signer, SettlementCache()) + return _FacilitatorEnv( + facilitator=facilitator, + signer=signer, + settlement=settlement, + batchers=batchers, + ) + + +class TestExactTvmFacilitatorSchemeConstructor: + def test_should_create_instance_with_correct_scheme(self, facilitator_env): + facilitator = facilitator_env.facilitator + + assert facilitator.scheme == "exact" + assert facilitator.caip_family == "tvm:*" + + def test_should_return_supported_extra_and_signers(self, facilitator_env): + facilitator = facilitator_env.facilitator + + assert facilitator.get_extra(TVM_TESTNET) == {"areFeesSponsored": True} + assert facilitator.get_extra("tvm:123") is None + assert facilitator.get_signers(TVM_TESTNET) == [FACILITATOR] + + def test_should_use_default_confirmation_worker_count(self, facilitator_env): + batcher = facilitator_env.batcher() + + assert batcher.confirmation_workers == DEFAULT_SETTLEMENT_CONFIRMATION_WORKERS + + +class TestVerify: + @pytest.mark.parametrize( + ("payload_overrides", "requirements_overrides", "expected_reason"), + [ + pytest.param( + {"x402_version": 1}, + {}, + ERR_EXACT_TVM_UNSUPPORTED_VERSION, + id="unsupported-x402-version", + ), + pytest.param( + {"accepted_scheme": "wrong"}, + {}, + ERR_EXACT_TVM_UNSUPPORTED_SCHEME, + id="wrong-scheme", + ), + pytest.param( + {"accepted_network": "tvm:123"}, + {"network": "tvm:123"}, + ERR_EXACT_TVM_UNSUPPORTED_NETWORK, + id="unsupported-network", + ), + pytest.param( + {"accepted_network": "tvm:-239"}, + {"network": TVM_TESTNET}, + ERR_EXACT_TVM_NETWORK_MISMATCH, + id="network-mismatch", + ), + pytest.param( + {"accepted_amount": "101"}, + {"amount": "100"}, + ERR_EXACT_TVM_INVALID_AMOUNT, + id="amount-mismatch", + ), + pytest.param( + {"payload_asset": "0:" + "9" * 64}, + {}, + ERR_EXACT_TVM_INVALID_ASSET, + id="asset-mismatch", + ), + pytest.param( + {"accepted_pay_to": "0:" + "8" * 64}, + {}, + ERR_EXACT_TVM_INVALID_RECIPIENT, + id="payee-mismatch", + ), + pytest.param( + {"accepted_extra": {"forwardTonAmount": "1"}}, + {}, + ERR_EXACT_TVM_INVALID_JETTON_TRANSFER, + id="forward-amount-mismatch", + ), + ], + ) + def test_should_reject_invalid_payment_metadata( + self, + facilitator_env, + payload_overrides, + requirements_overrides, + expected_reason, + ): + facilitator = facilitator_env.facilitator + + result = facilitator.verify( + _make_payload(**payload_overrides), + _make_requirements(**requirements_overrides), + ) + + _assert_invalid_verify(result, expected_reason) + + def test_should_reject_forward_payload_mismatch(self, facilitator_env, monkeypatch): + facilitator = facilitator_env.facilitator + monkeypatch.setattr( + facilitator_module, + "parse_exact_tvm_payload", + lambda boc: make_tvm_settlement( + forward_payload=begin_cell().store_uint(0xABCD, 16).end_cell() + ), + ) + + result = facilitator.verify(_make_payload(), _make_requirements()) + + _assert_invalid_verify(result, ERR_EXACT_TVM_INVALID_JETTON_TRANSFER) + + def test_should_reject_payload_missing_settlement_boc(self, facilitator_env): + facilitator = facilitator_env.facilitator + + result = facilitator.verify( + _make_payload(payload={"asset": ASSET}), + _make_requirements(), + ) + + _assert_invalid_verify( + result, + ERR_EXACT_TVM_INVALID_PAYLOAD, + message="Exact TVM payload field 'settlementBoc' is required", + ) + + def test_should_reject_expired_settlement(self, facilitator_env, monkeypatch): + facilitator = facilitator_env.facilitator + settlement = facilitator_env.settlement + monkeypatch.setattr( + facilitator_module, + "parse_exact_tvm_payload", + lambda boc: make_tvm_settlement( + valid_until=int(time.time()) - 1, + seqno=settlement.seqno, + ), + ) + + result = facilitator.verify(_make_payload(), _make_requirements()) + + _assert_invalid_verify(result, ERR_EXACT_TVM_INVALID_UNTIL_EXPIRED) + + def test_should_reject_valid_until_beyond_timeout(self, facilitator_env, monkeypatch): + facilitator = facilitator_env.facilitator + monkeypatch.setattr( + facilitator_module, + "parse_exact_tvm_payload", + lambda boc: make_tvm_settlement(valid_until=int(time.time()) + 600), + ) + + result = facilitator.verify(_make_payload(), _make_requirements(max_timeout_seconds=300)) + + _assert_invalid_verify(result, ERR_EXACT_TVM_VALID_UNTIL_TOO_FAR) + + def test_should_reject_invalid_signature(self, facilitator_env, monkeypatch): + facilitator = facilitator_env.facilitator + monkeypatch.setattr(facilitator_module, "verify_w5_signature", lambda *args: False) + + result = facilitator.verify(_make_payload(), _make_requirements()) + + _assert_invalid_verify(result, ERR_EXACT_TVM_INVALID_SIGNATURE) + + def test_should_reject_frozen_account_state(self, facilitator_env): + facilitator = facilitator_env.facilitator + signer = facilitator_env.signer + signer.account_state = TvmAccountState( + address=PAYER, + balance=0, + is_active=False, + is_frozen=True, + is_uninitialized=False, + state_init=None, + ) + + result = facilitator.verify(_make_payload(), _make_requirements()) + + _assert_invalid_verify(result, ERR_EXACT_TVM_ACCOUNT_FROZEN) + + def test_should_ignore_settlement_state_init_for_active_account( + self, facilitator_env, monkeypatch + ): + facilitator = facilitator_env.facilitator + signer = facilitator_env.signer + parse_active_calls: list[TvmAccountState] = [] + + def _parse_active(account: TvmAccountState) -> facilitator_module.W5InitData: + parse_active_calls.append(account) + return facilitator_module.W5InitData( + signature_allowed=True, + seqno=12, + wallet_id=777, + public_key=b"\x01" * 32, + extensions_dict=None, + ) + + monkeypatch.setattr(facilitator_module, "parse_active_w5_account_state", _parse_active) + monkeypatch.setattr( + facilitator_module, + "parse_w5_init_data", + lambda state_init: pytest.fail("active accounts should use on-chain state"), + ) + monkeypatch.setattr( + facilitator_module, + "parse_exact_tvm_payload", + lambda boc: make_tvm_settlement(state_init=object()), + ) + + result = facilitator.verify(_make_payload(), _make_requirements()) + + assert result.is_valid is True + assert parse_active_calls == [signer.account_state] + + def test_should_return_valid_response_for_matching_payload(self, facilitator_env): + facilitator = facilitator_env.facilitator + + result = facilitator.verify(_make_payload(), _make_requirements()) + + assert result.is_valid is True + assert result.payer == PAYER + + +class TestSettle: + @pytest.mark.parametrize( + ("payload_overrides", "requirements_overrides", "expected_reason"), + [ + pytest.param( + {"x402_version": 1}, + {}, + ERR_EXACT_TVM_UNSUPPORTED_VERSION, + id="unsupported-x402-version", + ), + pytest.param( + {"accepted_scheme": "wrong"}, + {}, + ERR_EXACT_TVM_UNSUPPORTED_SCHEME, + id="verification-fails", + ), + ], + ) + def test_should_fail_settlement_for_invalid_payment_metadata( + self, + facilitator_env, + payload_overrides, + requirements_overrides, + expected_reason, + ): + facilitator = facilitator_env.facilitator + + result = facilitator.settle( + _make_payload(**payload_overrides), + _make_requirements(**requirements_overrides), + ) + + _assert_failed_settlement(result, expected_reason) + + def test_should_fail_settlement_when_payload_is_missing_required_field(self, facilitator_env): + facilitator = facilitator_env.facilitator + + result = facilitator.settle( + _make_payload(payload={"settlementBoc": "base64-boc=="}), + _make_requirements(), + ) + + _assert_failed_settlement( + result, + ERR_EXACT_TVM_INVALID_PAYLOAD, + message="Exact TVM payload field 'asset' is required", + ) + + def test_should_reject_duplicate_settlement(self, facilitator_env): + facilitator = facilitator_env.facilitator + + first = facilitator.settle(_make_payload(), _make_requirements()) + second = facilitator.settle(_make_payload(), _make_requirements()) + + assert first.success is True + _assert_failed_settlement(second, ERR_EXACT_TVM_DUPLICATE_SETTLEMENT) + assert second.payer == PAYER + + def test_should_return_successful_settlement_response(self, facilitator_env): + facilitator = facilitator_env.facilitator + batcher = facilitator_env.batcher() + + result = facilitator.settle(_make_payload(), _make_requirements()) + + assert result.success is True + assert result.transaction == "trace-tx-hash" + assert result.payer == PAYER + assert result.network == TVM_TESTNET + assert len(batcher.enqueued) == 1 + queued = batcher.enqueued[0] + assert queued.network == TVM_TESTNET + assert queued.settlement_hash == "settlement-hash-1" + assert queued.relay_request.destination == PAYER + assert queued.relay_request.relay_amount == ( + 500_000 + 10_000 + 20_000 + 30_000 + DEFAULT_TVM_OUTER_GAS_BUFFER + ) + + def test_should_convert_batcher_exceptions_into_transaction_failed(self, facilitator_env): + facilitator = facilitator_env.facilitator + batcher = facilitator_env.batcher() + batcher.error = RuntimeError("boom") + + result = facilitator.settle(_make_payload(), _make_requirements()) + + _assert_failed_settlement(result, ERR_EXACT_TVM_TRANSACTION_FAILED, message="boom") + + def test_should_map_unexpected_verification_exception_to_simulation_failed( + self, facilitator_env, monkeypatch + ): + facilitator = facilitator_env.facilitator + monkeypatch.setattr( + facilitator_module, + "parse_exact_tvm_payload", + lambda boc: (_ for _ in ()).throw(RuntimeError("decode crashed")), + ) + + result = facilitator.settle(_make_payload(), _make_requirements()) + + _assert_failed_settlement( + result, + ERR_EXACT_TVM_SIMULATION_FAILED, + message="decode crashed", + ) + + +def _make_trace( + *, + include_payer_tx: bool = True, + payer_tx_success: bool = True, + include_matching_payer_out_msg: bool = True, + include_source_wallet_tx: bool = True, + payer_hash: str | None = "q6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6s=", + payer_hash_norm: str | None = "payer-tx-hash-norm", +): + settlement = make_tvm_settlement() + transactions: dict[str, object] = {} + if include_payer_tx: + transactions["payer"] = { + "hash": payer_hash, + **({"hash_norm": payer_hash_norm} if payer_hash_norm is not None else {}), + "account": PAYER, + "description": { + "aborted": not payer_tx_success, + "compute_ph": {"success": payer_tx_success, "skipped": False}, + "action": {"success": payer_tx_success}, + }, + "in_msg": { + "message_content": {"hash": body_hash_to_base64(settlement.body.hash)}, + }, + "out_msgs": ( + [ + { + "hash": "payer-out-hash", + "destination": SOURCE_WALLET, + "message_content": { + "hash": body_hash_to_base64(settlement.transfer.body_hash or b""), + }, + } + ] + if include_matching_payer_out_msg + else [] + ), + } + if include_source_wallet_tx: + transactions["source"] = { + "hash": "source-tx-hash", + "account": SOURCE_WALLET, + "description": { + "aborted": False, + "compute_ph": {"success": True, "skipped": False}, + "action": {"success": True}, + }, + "in_msg": { + "hash": "payer-out-hash", + }, + } + return settlement, {"transactions": transactions} + + +class TestVerifyFinalizedTraceSettlement: + def test_should_fail_when_trace_has_no_matching_payer_wallet_transaction(self): + settlement, trace = _make_trace(include_payer_tx=False) + + with pytest.raises(ValueError, match="expected payer wallet transaction"): + ExactTvmFacilitatorScheme._verify_finalized_trace_settlement( + trace, + settlement=settlement, + ) + + def test_should_ignore_failed_payer_wallet_transactions(self): + settlement, trace = _make_trace(payer_tx_success=False) + + with pytest.raises(ValueError, match="expected payer wallet transaction"): + ExactTvmFacilitatorScheme._verify_finalized_trace_settlement( + trace, + settlement=settlement, + ) + + def test_should_fail_when_payer_wallet_transaction_has_no_matching_out_message(self): + settlement, trace = _make_trace(include_matching_payer_out_msg=False) + + with pytest.raises(ValueError, match="missing out message hash"): + ExactTvmFacilitatorScheme._verify_finalized_trace_settlement( + trace, + settlement=settlement, + ) + + def test_should_fail_when_trace_has_no_matching_source_wallet_transaction(self): + settlement, trace = _make_trace(include_source_wallet_tx=False) + + with pytest.raises(ValueError, match="expected source jetton wallet transaction"): + ExactTvmFacilitatorScheme._verify_finalized_trace_settlement( + trace, + settlement=settlement, + ) + + def test_should_fail_when_payer_wallet_transaction_has_no_hash(self): + settlement, trace = _make_trace(payer_hash="", payer_hash_norm="") + + with pytest.raises(ValueError, match="missing transaction hash"): + ExactTvmFacilitatorScheme._verify_finalized_trace_settlement( + trace, + settlement=settlement, + ) + + def test_should_return_payer_transaction_hash_for_valid_trace(self): + settlement, trace = _make_trace( + payer_hash="q6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6s=", + payer_hash_norm="q6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6s=", + ) + + result = ExactTvmFacilitatorScheme._verify_finalized_trace_settlement( + trace, + settlement=settlement, + ) + + assert result == "ab" * 32 + + def test_should_prefer_normalized_payer_transaction_hash_when_present(self): + settlement, trace = _make_trace( + payer_hash="q6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6s=", + payer_hash_norm="zMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMw=", + ) + + result = ExactTvmFacilitatorScheme._verify_finalized_trace_settlement( + trace, + settlement=settlement, + ) + + assert result == "cc" * 32 + + def test_should_return_payer_transaction_object_when_requested(self): + settlement, trace = _make_trace(payer_hash_norm=None) + + result = ExactTvmFacilitatorScheme._verify_finalized_trace_settlement( + trace, + settlement=settlement, + return_transaction=True, + ) + + assert result["hash"] == "q6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6s=" diff --git a/python/x402/tests/unit/mechanisms/tvm/test_highload_v3.py b/python/x402/tests/unit/mechanisms/tvm/test_highload_v3.py new file mode 100644 index 0000000000..17ebb8a78d --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/test_highload_v3.py @@ -0,0 +1,17 @@ +"""Focused tests for Highload V3 codecs.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("pytoniq_core") + +from pytoniq_core import begin_cell + +from x402.mechanisms.tvm.codecs.highload_v3 import _bitmap_contains + + +def test_bitmap_contains_should_return_false_for_out_of_range_bit_number(): + bitmap = begin_cell().store_uint(0b101, 3).end_cell() + + assert _bitmap_contains(bitmap, 3) is False diff --git a/python/x402/tests/unit/mechanisms/tvm/test_index.py b/python/x402/tests/unit/mechanisms/tvm/test_index.py new file mode 100644 index 0000000000..b18ffb1bd0 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/test_index.py @@ -0,0 +1,146 @@ +"""Tests for TVM mechanism exports and registration helpers.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("pytoniq_core") + +import x402.mechanisms.tvm.exact.facilitator as facilitator_module +from x402 import x402ClientSync, x402FacilitatorSync, x402ResourceServerSync +from x402.mechanisms.tvm import ( + SCHEME_EXACT, + SUPPORTED_NETWORKS, + TVM_MAINNET, + TVM_TESTNET, + ClientTvmSigner, + ExactTvmPayload, + FacilitatorHighloadV3Signer, + FacilitatorTvmSigner, + SettlementCache, + ToncenterRestClient, + WalletV5R1MnemonicSigner, + get_network_global_id, + normalize_address, + parse_amount, + parse_money_to_decimal, +) +from x402.mechanisms.tvm.exact import ( + ExactTvmClientScheme, + ExactTvmFacilitatorScheme, + ExactTvmScheme, + ExactTvmServerScheme, + register_exact_tvm_client, + register_exact_tvm_facilitator, + register_exact_tvm_server, +) + + +class _ClientSignerStub: + address = "0:" + "1" * 64 + network = TVM_TESTNET + wallet_id = 1 + state_init = object() + + def sign_message(self, message: bytes) -> bytes: + return b"\x00" * 64 + + +class _FacilitatorSignerStub: + def get_addresses(self) -> list[str]: + return ["0:" + "f" * 64] + + def get_addresses_for_network(self, network: str) -> list[str]: + return ["0:" + "f" * 64] + + +class _FakeBatcher: + def __init__(self, *args, **kwargs) -> None: + pass + + +class TestExports: + def test_should_export_main_classes(self): + assert ExactTvmScheme is ExactTvmClientScheme + assert ExactTvmClientScheme is not None + assert ExactTvmServerScheme is not None + assert ExactTvmFacilitatorScheme is not None + + def test_should_export_signer_protocols_and_implementations(self): + assert ClientTvmSigner is not None + assert FacilitatorTvmSigner is not None + assert WalletV5R1MnemonicSigner is not None + assert FacilitatorHighloadV3Signer is not None + + def test_should_export_provider_and_payload_types(self): + assert ToncenterRestClient is not None + assert ExactTvmPayload is not None + assert SettlementCache is not None + + +class TestNetworkUtilities: + def test_should_export_supported_networks(self): + assert SUPPORTED_NETWORKS == {TVM_MAINNET, TVM_TESTNET} + + def test_should_extract_global_id_from_caip2_network(self): + assert get_network_global_id(TVM_MAINNET) == -239 + assert get_network_global_id(TVM_TESTNET) == -3 + + def test_should_export_scheme_exact(self): + assert SCHEME_EXACT == "exact" + + +class TestAmountUtilities: + def test_should_parse_amount_using_decimals(self): + assert parse_amount("0.001", 6) == 1000 + assert parse_amount("1", 6) == 1000000 + + def test_should_parse_money_strings_without_currency_noise(self): + assert parse_money_to_decimal("$0.10") == 0.1 + assert parse_money_to_decimal("2.5 USDT") == 2.5 + + def test_should_normalize_raw_addresses(self): + raw = "0:" + "1" * 64 + + assert normalize_address(raw) == raw + + +class TestRegisterHelpers: + def test_register_exact_tvm_client_should_register_on_signer_network_and_policies(self): + client = x402ClientSync() + policy = lambda version, requirements: requirements + + result = register_exact_tvm_client(client, _ClientSignerStub(), policies=[policy]) + + assert result is client + assert TVM_TESTNET in client._schemes + assert client._schemes[TVM_TESTNET]["exact"].scheme == "exact" + assert client._policies == [policy] + + def test_register_exact_tvm_server_should_register_all_supported_networks_by_default(self): + server = x402ResourceServerSync() + + result = register_exact_tvm_server(server) + + assert result is server + for network in SUPPORTED_NETWORKS: + assert network in server._schemes + assert server._schemes[network]["exact"].scheme == "exact" + + def test_register_exact_tvm_facilitator_should_register_one_scheme_for_requested_networks( + self, monkeypatch + ): + facilitator = x402FacilitatorSync() + monkeypatch.setattr(facilitator_module, "_SettlementBatcher", _FakeBatcher) + + result = register_exact_tvm_facilitator( + facilitator, + _FacilitatorSignerStub(), + [TVM_TESTNET, TVM_MAINNET], + ) + + assert result is facilitator + assert len(facilitator._schemes) == 1 + scheme_data = facilitator._schemes[0] + assert scheme_data.networks == {TVM_TESTNET, TVM_MAINNET} + assert scheme_data.facilitator.scheme == "exact" diff --git a/python/x402/tests/unit/mechanisms/tvm/test_provider.py b/python/x402/tests/unit/mechanisms/tvm/test_provider.py new file mode 100644 index 0000000000..8844b97aaf --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/test_provider.py @@ -0,0 +1,329 @@ +"""Focused tests for the Toncenter TVM provider client.""" + +from __future__ import annotations + +import base64 +import json + +import httpx +import pytest + +pytest.importorskip("pytoniq_core") + +from pytoniq_core import Address, begin_cell + +import x402.mechanisms.tvm.provider as provider_module +from x402.mechanisms.tvm import ( + DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS, + TVM_MAINNET, + TVM_TESTNET, +) +from x402.mechanisms.tvm.provider import ToncenterRestClient, _default_base_url + + +def _cell_b64(value: int) -> str: + return base64.b64encode(begin_cell().store_uint(value, 8).end_cell().to_boc()).decode("ascii") + + +def _address_cell_b64(address: str) -> str: + return base64.b64encode( + begin_cell().store_address(Address(address)).end_cell().to_boc() + ).decode("ascii") + + +class _FakeHttpClient: + def __init__(self, responses): + self.responses = list(responses) + self.calls: list[tuple[str, str, object]] = [] + self.closed = False + + def request(self, method: str, path: str, **kwargs): + self.calls.append((method, path, kwargs)) + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + def close(self): + self.closed = True + + +def _json_response( + status_code: int, + data, + *, + path: str = "/api/test", + headers: dict[str, str] | None = None, + text: str = "", +): + request = httpx.Request("GET", f"https://toncenter.example{path}") + return httpx.Response( + status_code, + content=json.dumps(data).encode("utf-8"), + request=request, + headers={"Content-Type": "application/json", **(headers or {})}, + ) + + +class TestDefaultBaseUrl: + def test_should_select_default_base_url_for_supported_networks(self): + assert _default_base_url(TVM_MAINNET) == "https://toncenter.com" + assert _default_base_url(TVM_TESTNET) == "https://testnet.toncenter.com" + + def test_should_reject_unsupported_network(self): + with pytest.raises(ValueError, match="Unsupported TVM network"): + _default_base_url("tvm:123") + + +class TestToncenterRestClientParsing: + def test_emulate_trace_should_forward_ignore_chksig_flag(self): + client = ToncenterRestClient(TVM_TESTNET) + fake_http = _FakeHttpClient( + [_json_response(200, {"transactions": {}}, path="/api/emulate/v1/emulateTrace")] + ) + client._client = fake_http + + client.emulate_trace(b"boc-bytes", ignore_chksig=True) + + assert len(fake_http.calls) == 1 + method, path, kwargs = fake_http.calls[0] + assert method == "POST" + assert path == "/api/emulate/v1/emulateTrace" + assert kwargs["json"]["ignore_chksig"] is True + assert kwargs["json"]["with_actions"] is True + assert kwargs["timeout"] == DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS + + def test_emulate_trace_should_allow_custom_timeout(self): + client = ToncenterRestClient(TVM_TESTNET) + fake_http = _FakeHttpClient( + [_json_response(200, {"transactions": {}}, path="/api/emulate/v1/emulateTrace")] + ) + client._client = fake_http + + client.emulate_trace(b"boc-bytes", timeout=9.5) + + assert len(fake_http.calls) == 1 + _, _, kwargs = fake_http.calls[0] + assert kwargs["timeout"] == 9.5 + + def test_get_account_state_should_decode_active_state_init(self): + client = ToncenterRestClient(TVM_TESTNET) + client._client = _FakeHttpClient( + [ + _json_response( + 200, + { + "accounts": [ + { + "address": "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c", + "balance": "123", + "status": "active", + "code_boc": _cell_b64(1), + "data_boc": _cell_b64(2), + } + ] + }, + path="/api/v3/accountStates", + ) + ] + ) + + account = client.get_account_state("0:" + "0" * 64) + + assert account.address == "0:" + "0" * 64 + assert account.balance == 123 + assert account.is_active is True + assert account.is_uninitialized is False + assert account.state_init is not None + + def test_get_account_state_should_decode_uninitialized_account_without_state_init(self): + client = ToncenterRestClient(TVM_TESTNET) + client._client = _FakeHttpClient( + [ + _json_response( + 200, + { + "accounts": [ + { + "address": "0:" + "1" * 64, + "balance": "0", + "status": "uninit", + } + ] + }, + path="/api/v3/accountStates", + ) + ] + ) + + account = client.get_account_state("0:" + "1" * 64) + + assert account.is_active is False + assert account.is_uninitialized is True + assert account.state_init is None + + def test_get_account_state_should_return_synthetic_uninitialized_state_for_empty_accounts(self): + client = ToncenterRestClient(TVM_TESTNET) + client._client = _FakeHttpClient( + [ + _json_response( + 200, + {"accounts": []}, + path="/api/v3/accountStates", + ) + ] + ) + + account = client.get_account_state("0:" + "2" * 64) + + assert account.address == "0:" + "2" * 64 + assert account.balance == 0 + assert account.is_active is False + assert account.is_uninitialized is True + assert account.is_frozen is False + assert account.state_init is None + + def test_run_get_method_should_reject_non_zero_exit_code(self): + client = ToncenterRestClient(TVM_TESTNET) + client._client = _FakeHttpClient( + [_json_response(200, {"exit_code": 1, "stack": []}, path="/api/v3/runGetMethod")] + ) + + with pytest.raises(RuntimeError, match="failed with exit code 1"): + client.run_get_method("0:" + "1" * 64, "method", []) + + def test_run_get_method_should_reject_non_list_stack(self): + client = ToncenterRestClient(TVM_TESTNET) + client._client = _FakeHttpClient( + [_json_response(200, {"exit_code": 0, "stack": {}}, path="/api/v3/runGetMethod")] + ) + + with pytest.raises(RuntimeError, match="invalid stack"): + client.run_get_method("0:" + "1" * 64, "method", []) + + def test_should_parse_stack_helpers_and_jetton_wallet_data(self): + owner = "0:" + "2" * 64 + minter = "0:" + "3" * 64 + client = ToncenterRestClient(TVM_TESTNET) + client._client = _FakeHttpClient( + [ + _json_response( + 200, + { + "exit_code": 0, + "stack": [ + {"value": "123"}, + {"value": _address_cell_b64(owner)}, + {"value": _address_cell_b64(minter)}, + ], + }, + path="/api/v3/runGetMethod", + ) + ] + ) + + data = client.get_jetton_wallet_data("0:" + "4" * 64) + + assert data.balance == 123 + assert data.owner == owner + assert data.jetton_minter == minter + + def test_get_trace_by_message_hash_should_reject_malformed_response(self): + client = ToncenterRestClient(TVM_TESTNET) + client._client = _FakeHttpClient( + [_json_response(200, {"traces": {}}, path="/api/v3/traces")] + ) + + with pytest.raises(RuntimeError, match="invalid traces response"): + client.get_trace_by_message_hash("hash-1") + + def test_get_trace_by_message_hash_should_reject_empty_traces(self): + client = ToncenterRestClient(TVM_TESTNET) + client._client = _FakeHttpClient( + [_json_response(200, {"traces": []}, path="/api/v3/traces")] + ) + + with pytest.raises(RuntimeError, match="returned no trace"): + client.get_trace_by_message_hash("hash-1") + + +class TestToncenterRequestRetries: + def test_should_retry_retryable_http_statuses(self, monkeypatch): + client = ToncenterRestClient(TVM_TESTNET) + fake_client = _FakeHttpClient( + [ + _json_response(500, {"error": "boom"}, path="/api/test", text="boom"), + _json_response(200, {"ok": True}, path="/api/test"), + ] + ) + client._client = fake_client + sleeps: list[float] = [] + monkeypatch.setattr(provider_module.time, "sleep", lambda seconds: sleeps.append(seconds)) + + result = client._request("GET", "/api/test") + + assert result == {"ok": True} + assert len(fake_client.calls) == 2 + assert sleeps == [0.25] + + def test_should_honor_retry_after_header(self, monkeypatch): + client = ToncenterRestClient(TVM_TESTNET) + fake_client = _FakeHttpClient( + [ + _json_response( + 429, + {"error": "busy"}, + path="/api/test", + headers={"Retry-After": "1.5"}, + text="busy", + ), + _json_response(200, {"ok": True}, path="/api/test"), + ] + ) + client._client = fake_client + sleeps: list[float] = [] + monkeypatch.setattr(provider_module.time, "sleep", lambda seconds: sleeps.append(seconds)) + + result = client._request("GET", "/api/test") + + assert result == {"ok": True} + assert sleeps == [1.5] + + def test_should_not_retry_non_retryable_http_statuses(self): + client = ToncenterRestClient(TVM_TESTNET) + fake_client = _FakeHttpClient( + [_json_response(400, {"error": "bad"}, path="/api/test", text="bad")] + ) + client._client = fake_client + + with pytest.raises(httpx.HTTPStatusError): + client._request("GET", "/api/test") + + assert len(fake_client.calls) == 1 + + def test_should_retry_transport_errors_then_raise_last_error(self, monkeypatch): + client = ToncenterRestClient(TVM_TESTNET) + fake_client = _FakeHttpClient( + [ + httpx.RequestError( + "boom", request=httpx.Request("GET", "https://toncenter.example/api/test") + ) + ] + * 5 + ) + client._client = fake_client + monkeypatch.setattr(provider_module.time, "sleep", lambda seconds: None) + + with pytest.raises(httpx.RequestError, match="boom"): + client._request("GET", "/api/test") + + assert len(fake_client.calls) == 5 + + def test_should_reject_non_object_json_payloads(self): + client = ToncenterRestClient(TVM_TESTNET) + client._client = _FakeHttpClient( + [_json_response(200, ["not", "an", "object"], path="/api/test")] + ) + + with pytest.raises(RuntimeError, match="non-object response"): + client._request("GET", "/api/test") diff --git a/python/x402/tests/unit/mechanisms/tvm/test_server.py b/python/x402/tests/unit/mechanisms/tvm/test_server.py new file mode 100644 index 0000000000..f47d78c6f6 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/test_server.py @@ -0,0 +1,258 @@ +"""Tests for the exact TVM server scheme.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("pytoniq_core") + +from x402.mechanisms.tvm import ( + TVM_MAINNET, + TVM_TESTNET, + USDT_MAINNET_MINTER, + USDT_TESTNET_MINTER, +) +from x402.mechanisms.tvm.exact import ExactTvmServerScheme +from x402.schemas import AssetAmount, SupportedKind + +from .builders import EMPTY_FORWARD_PAYLOAD_B64, make_tvm_requirements + +ZERO_BIT_PAYLOAD_B64 = EMPTY_FORWARD_PAYLOAD_B64 + + +def _make_requirements(**overrides): + return make_tvm_requirements( + asset=overrides.pop("asset", USDT_TESTNET_MINTER), + pay_to=overrides.pop("pay_to", USDT_TESTNET_MINTER), + **overrides, + ) + + +class TestParsePrice: + """Test parse_price.""" + + class TestMainnetNetwork: + @pytest.mark.parametrize( + ("price", "amount"), + [ + pytest.param("$0.10", "100000", id="dollar-string"), + pytest.param("0.10", "100000", id="plain-string"), + pytest.param(0.1, "100000", id="float"), + pytest.param(1, "1000000", id="int"), + ], + ) + def test_should_parse_prices_against_default_mainnet_usdt(self, price, amount): + server = ExactTvmServerScheme() + result = server.parse_price(price, TVM_MAINNET) + + assert result.amount == amount + assert result.asset == USDT_MAINNET_MINTER + assert result.extra == { + "areFeesSponsored": True, + "forwardPayload": ZERO_BIT_PAYLOAD_B64, + "forwardTonAmount": "0", + } + + class TestTestnetNetwork: + def test_should_use_testnet_default_asset(self): + server = ExactTvmServerScheme() + + result = server.parse_price("$0.001", TVM_TESTNET) + + assert result.amount == "1000" + assert result.asset == USDT_TESTNET_MINTER + + class TestPreParsedPriceObjects: + def test_should_preserve_preparsed_dict_price(self): + server = ExactTvmServerScheme() + + result = server.parse_price( + { + "amount": "123456", + "asset": "0:" + "1" * 64, + "extra": {"foo": "bar"}, + }, + TVM_MAINNET, + ) + + assert result.amount == "123456" + assert result.asset == "0:" + "1" * 64 + assert result.extra == {"foo": "bar"} + + def test_should_preserve_asset_amount_instance(self): + server = ExactTvmServerScheme() + + result = server.parse_price( + AssetAmount( + amount="42", + asset="0:" + "2" * 64, + extra={"token": "CUSTOM"}, + ), + TVM_TESTNET, + ) + + assert result.amount == "42" + assert result.asset == "0:" + "2" * 64 + assert result.extra == {"token": "CUSTOM"} + + @pytest.mark.parametrize("price", [{"amount": "1"}, AssetAmount(amount="1", asset="")]) + def test_should_reject_passthrough_price_without_asset(self, price): + server = ExactTvmServerScheme() + + with pytest.raises(ValueError, match="Asset address required"): + server.parse_price(price, TVM_MAINNET) + + class TestCustomMoneyParsers: + def test_should_use_custom_money_parser_before_default_conversion(self): + server = ExactTvmServerScheme() + + def custom_parser(amount: float, network: str) -> AssetAmount | None: + assert network == TVM_MAINNET + if amount >= 100: + return AssetAmount( + amount="999", + asset="0:" + "9" * 64, + extra={"tier": "large"}, + ) + return None + + server.register_money_parser(custom_parser) + + large = server.parse_price(100, TVM_MAINNET) + small = server.parse_price(1, TVM_MAINNET) + + assert large.amount == "999" + assert large.asset == "0:" + "9" * 64 + assert large.extra == {"tier": "large"} + assert small.asset == USDT_MAINNET_MINTER + assert small.amount == "1000000" + + def test_should_not_call_custom_parser_for_passthrough_price_objects(self): + server = ExactTvmServerScheme() + parser_called = False + + def tracking_parser(amount: float, network: str) -> AssetAmount | None: + nonlocal parser_called + parser_called = True + return None + + server.register_money_parser(tracking_parser) + + server.parse_price( + AssetAmount(amount="123", asset="0:" + "4" * 64), + TVM_MAINNET, + ) + + assert parser_called is False + + class TestErrorCases: + def test_should_raise_when_network_has_no_default_asset(self): + server = ExactTvmServerScheme() + + with pytest.raises(ValueError, match="No default stablecoin configured"): + server.parse_price("1.00", "tvm:123") + + +class TestEnhancePaymentRequirements: + """Test enhance_payment_requirements.""" + + class TestDefaultAssetNormalization: + def test_should_set_default_asset_and_normalize_decimal_amount(self): + server = ExactTvmServerScheme() + + result = server.enhance_payment_requirements( + _make_requirements( + asset="", + amount="1.5", + pay_to="EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c", + extra={}, + ), + SupportedKind( + x402_version=2, + scheme="exact", + network=TVM_TESTNET, + extra={"areFeesSponsored": False}, + ), + [], + ) + + assert result.asset == USDT_TESTNET_MINTER + assert result.amount == "1500000" + assert result.pay_to == "0:" + "0" * 64 + assert result.extra == {"areFeesSponsored": False} + + def test_should_preserve_existing_extra_fields_and_normalize_response_destination(self): + server = ExactTvmServerScheme() + + result = server.enhance_payment_requirements( + _make_requirements( + extra={ + "custom": "value", + "areFeesSponsored": True, + "responseDestination": "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c", + } + ), + SupportedKind(x402_version=2, scheme="exact", network=TVM_TESTNET), + [], + ) + + assert result.extra == { + "custom": "value", + "areFeesSponsored": True, + "responseDestination": "0:" + "0" * 64, + } + + class TestCustomAssets: + def test_should_raise_when_custom_asset_decimal_amount_has_no_decimals_metadata(self): + server = ExactTvmServerScheme() + + with pytest.raises( + ValueError, match="provide amount in atomic units or extra.decimals" + ): + server.enhance_payment_requirements( + _make_requirements( + asset="0:" + "8" * 64, + amount="1.25", + extra={}, + ), + SupportedKind(x402_version=2, scheme="exact", network=TVM_TESTNET), + [], + ) + + def test_should_use_extra_decimals_for_custom_assets(self): + server = ExactTvmServerScheme() + + result = server.enhance_payment_requirements( + _make_requirements( + asset="0:" + "8" * 64, + amount="1.25", + extra={"decimals": 9}, + ), + SupportedKind(x402_version=2, scheme="exact", network=TVM_TESTNET), + [], + ) + + assert result.amount == "1250000000" + + class TestInternalHelpers: + def test_get_default_asset_should_raise_for_unknown_network(self): + server = ExactTvmServerScheme() + + with pytest.raises(ValueError, match="No default stablecoin configured"): + server._get_default_asset("tvm:123") + + def test_get_asset_decimals_should_return_default_for_usdt(self): + server = ExactTvmServerScheme() + + assert server._get_asset_decimals(_make_requirements(network=TVM_TESTNET)) == 6 + + +class TestRegisterMoneyParser: + """Test register_money_parser.""" + + def test_should_return_self_for_chaining(self): + server = ExactTvmServerScheme() + + result = server.register_money_parser(lambda amount, network: None) + + assert result is server diff --git a/python/x402/tests/unit/mechanisms/tvm/test_settlement_batcher.py b/python/x402/tests/unit/mechanisms/tvm/test_settlement_batcher.py new file mode 100644 index 0000000000..d3c916defb --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/test_settlement_batcher.py @@ -0,0 +1,205 @@ +"""Tests for TVM settlement batching failure paths.""" + +from __future__ import annotations + +import pytest + +from x402.mechanisms.tvm.constants import ( + ERR_EXACT_TVM_SIMULATION_FAILED, + ERR_EXACT_TVM_TRANSACTION_FAILED, +) +from x402.mechanisms.tvm.exact.settlement_batcher import ( + _PendingConfirmation, + _QueuedSettlement, + _SettlementBatcher, +) + +TVM_TESTNET = "tvm:-3" + + +class _StopWorker(Exception): + pass + + +class _ReleaseSpyCache: + def __init__(self) -> None: + self._queued_by_key: dict[str, _QueuedSettlement] = {} + self.release_checks: list[dict[str, object]] = [] + + def register(self, queued: _QueuedSettlement) -> None: + self._queued_by_key[queued.settlement_hash] = queued + + def release(self, key: str) -> None: + queued = self._queued_by_key[key] + self.release_checks.append( + { + "key": key, + "completed": queued.completed.is_set(), + "error_reason": None if queued.result is None else queued.result.error_reason, + "success": None if queued.result is None else queued.result.success, + } + ) + + +class _SinglePendingQueue: + def __init__(self, pending: _PendingConfirmation) -> None: + self._pending = pending + self._consumed = False + + def get(self) -> _PendingConfirmation: + if self._consumed: + raise _StopWorker() + self._consumed = True + return self._pending + + +class _BuildFailureSigner: + def __init__(self, exc: Exception) -> None: + self._exc = exc + + def build_relay_external_boc_batch(self, network: str, requests: list[object]) -> bytes: + _ = network, requests + raise self._exc + + +class _ConfirmationFailureSigner: + def __init__(self, exc: Exception) -> None: + self._exc = exc + + def wait_for_trace_confirmation( + self, + network: str, + trace_external_hash_norm: str, + *, + timeout_seconds: float, + ) -> dict[str, object]: + _ = network, trace_external_hash_norm, timeout_seconds + raise self._exc + + +class _ConfirmedSigner: + def wait_for_trace_confirmation( + self, + network: str, + trace_external_hash_norm: str, + *, + timeout_seconds: float, + ) -> dict[str, object]: + _ = network, trace_external_hash_norm, timeout_seconds + return {"transactions": {}} + + +def _make_batcher( + *, + signer: object, + settlement_cache: _ReleaseSpyCache, + settlement_verifier, +) -> _SettlementBatcher: + batcher = object.__new__(_SettlementBatcher) + batcher._signer = signer + batcher._settlement_cache = settlement_cache + batcher._confirmation_timeout_seconds = 30.0 + batcher._settlement_verifier = settlement_verifier + return batcher + + +def _make_queued_settlement(settlement_hash: str = "settlement-hash") -> _QueuedSettlement: + return _QueuedSettlement( + network=TVM_TESTNET, + settlement_hash=settlement_hash, + settlement=object(), + relay_request=object(), + ) + + +def test_flush_batch_releases_only_after_result_and_completion_on_send_failure(): + queued = _make_queued_settlement() + cache = _ReleaseSpyCache() + cache.register(queued) + batcher = _make_batcher( + signer=_BuildFailureSigner(RuntimeError("send failed")), + settlement_cache=cache, + settlement_verifier=lambda trace_data, settlement: "unused", + ) + + batcher._flush_batch(TVM_TESTNET, [queued]) + + assert queued.completed.is_set() is True + assert queued.result is not None + assert queued.result.error_reason == ERR_EXACT_TVM_TRANSACTION_FAILED + assert cache.release_checks == [ + { + "key": queued.settlement_hash, + "completed": True, + "error_reason": ERR_EXACT_TVM_TRANSACTION_FAILED, + "success": False, + } + ] + + +def test_confirmation_worker_releases_only_after_result_and_completion_on_wait_failure(): + queued = _make_queued_settlement() + cache = _ReleaseSpyCache() + cache.register(queued) + batcher = _make_batcher( + signer=_ConfirmationFailureSigner(ValueError("trace wait failed")), + settlement_cache=cache, + settlement_verifier=lambda trace_data, settlement: "unused", + ) + batcher._confirmation_queue = _SinglePendingQueue( + _PendingConfirmation( + network=TVM_TESTNET, + batch=[queued], + trace_external_hash_norm="trace-hash", + ) + ) + + with pytest.raises(_StopWorker): + batcher._run_confirmation_worker() + + assert queued.completed.is_set() is True + assert queued.result is not None + assert queued.result.error_reason == ERR_EXACT_TVM_SIMULATION_FAILED + assert cache.release_checks == [ + { + "key": queued.settlement_hash, + "completed": True, + "error_reason": ERR_EXACT_TVM_SIMULATION_FAILED, + "success": False, + } + ] + + +def test_confirmation_worker_releases_only_after_result_and_completion_on_verify_failure(): + queued = _make_queued_settlement() + cache = _ReleaseSpyCache() + cache.register(queued) + batcher = _make_batcher( + signer=_ConfirmedSigner(), + settlement_cache=cache, + settlement_verifier=lambda trace_data, settlement: (_ for _ in ()).throw( + RuntimeError("verification failed") + ), + ) + batcher._confirmation_queue = _SinglePendingQueue( + _PendingConfirmation( + network=TVM_TESTNET, + batch=[queued], + trace_external_hash_norm="trace-hash", + ) + ) + + with pytest.raises(_StopWorker): + batcher._run_confirmation_worker() + + assert queued.completed.is_set() is True + assert queued.result is not None + assert queued.result.error_reason == ERR_EXACT_TVM_TRANSACTION_FAILED + assert cache.release_checks == [ + { + "key": queued.settlement_hash, + "completed": True, + "error_reason": ERR_EXACT_TVM_TRANSACTION_FAILED, + "success": False, + } + ] diff --git a/python/x402/tests/unit/mechanisms/tvm/test_settlement_cache.py b/python/x402/tests/unit/mechanisms/tvm/test_settlement_cache.py new file mode 100644 index 0000000000..f6ba5c307e --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/test_settlement_cache.py @@ -0,0 +1,41 @@ +"""Tests for the TVM settlement cache.""" + +import pytest + +pytest.importorskip("pytoniq_core") + +from x402.mechanisms.tvm.settlement_cache import SettlementCache + + +def test_is_duplicate_rejects_duplicate_until_released() -> None: + cache = SettlementCache() + + assert cache.is_duplicate("settlement-1", 300.0) is False + assert cache.is_duplicate("settlement-1", 300.0) is True + + cache.release("settlement-1") + + assert cache.is_duplicate("settlement-1", 300.0) is False + + +def test_is_duplicate_prunes_expired_entries() -> None: + cache = SettlementCache() + + assert cache.is_duplicate("settlement-1", 300.0) is False + cache._entries["settlement-1"] -= 301.0 + + assert cache.is_duplicate("settlement-1", 300.0) is False + + +def test_release_prunes_expired_entries_when_cache_is_otherwise_idle() -> None: + cache = SettlementCache() + + assert cache.is_duplicate("expired-settlement", 300.0) is False + assert cache.is_duplicate("active-settlement", 300.0) is False + + cache._entries["expired-settlement"] -= 301.0 + + cache.release("missing-settlement") + + assert "expired-settlement" not in cache._entries + assert "active-settlement" in cache._entries diff --git a/python/x402/tests/unit/mechanisms/tvm/test_signers.py b/python/x402/tests/unit/mechanisms/tvm/test_signers.py new file mode 100644 index 0000000000..7b13ec1d0a --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/test_signers.py @@ -0,0 +1,368 @@ +"""Tests for TVM signer implementations.""" + +from __future__ import annotations + +import base64 +import threading + +import pytest + +pytest.importorskip("pytoniq_core") + +from x402.mechanisms.tvm import TVM_MAINNET, TVM_TESTNET +from x402.mechanisms.tvm.constants import ( + DEFAULT_STREAMING_CONFIRMATION_GRACE_SECONDS, + DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS, +) +from x402.mechanisms.tvm.signers import ( + FacilitatorHighloadV3Signer, + HighloadV3Config, + WalletV5R1Config, + WalletV5R1MnemonicSigner, +) +from x402.mechanisms.tvm.types import TvmAccountState +from .builders import TEST_MNEMONIC, derive_test_secret_key +from .helpers import start_captured_thread + + +class TestWalletV5R1Config: + @pytest.mark.parametrize( + ("private_key", "factory"), + [ + pytest.param( + lambda secret_key, seed: secret_key.hex(), + lambda private_key: WalletV5R1Config.from_private_key(TVM_TESTNET, private_key), + id="hex-64", + ), + pytest.param( + lambda secret_key, seed: seed.hex(), + lambda private_key: WalletV5R1Config.from_private_key(TVM_TESTNET, private_key), + id="hex-32", + ), + pytest.param( + lambda secret_key, seed: base64.b64encode(secret_key).decode(), + lambda private_key: WalletV5R1Config.from_private_key(TVM_TESTNET, private_key), + id="base64-64", + ), + pytest.param( + lambda secret_key, seed: base64.b64encode(seed).decode(), + lambda private_key: WalletV5R1Config.from_private_key(TVM_TESTNET, private_key), + id="base64-32", + ), + ], + ) + def test_from_private_key_should_accept_hex_and_base64_seed_or_secret_key( + self, private_key, factory + ): + secret_key = derive_test_secret_key() + seed = secret_key[:32] + + config = factory(private_key(secret_key, seed)) + + assert config.secret_key == secret_key + assert config.network == TVM_TESTNET + + def test_from_private_key_should_reject_invalid_input(self): + with pytest.raises(ValueError, match="valid hex or base64"): + WalletV5R1Config.from_private_key(TVM_TESTNET, "not-a-key") + + +class TestWalletV5R1MnemonicSigner: + def test_should_expose_network_wallet_id_state_init_and_address(self): + config = WalletV5R1Config.from_mnemonic(TVM_TESTNET, TEST_MNEMONIC) + + signer = WalletV5R1MnemonicSigner(config) + + assert signer.network == TVM_TESTNET + assert signer.wallet_id > 0 + assert signer.state_init is not None + assert signer.address.startswith("0:") + assert len(signer.address) == 66 + + def test_sign_message_should_return_ed25519_signature(self): + config = WalletV5R1Config.from_mnemonic(TVM_TESTNET, TEST_MNEMONIC) + signer = WalletV5R1MnemonicSigner(config) + + signature = signer.sign_message(b"message-hash") + + assert isinstance(signature, bytes) + assert len(signature) == 64 + + +class TestHighloadV3Config: + @pytest.mark.parametrize( + ("private_key", "factory"), + [ + pytest.param( + lambda secret_key, seed: secret_key.hex(), + lambda private_key: HighloadV3Config.from_private_key(private_key), + id="hex-64", + ), + pytest.param( + lambda secret_key, seed: seed.hex(), + lambda private_key: HighloadV3Config.from_private_key(private_key), + id="hex-32", + ), + ], + ) + def test_from_private_key_should_accept_hex_seed_or_secret_key(self, private_key, factory): + secret_key = derive_test_secret_key() + seed = secret_key[:32] + + config = factory(private_key(secret_key, seed)) + + assert config.secret_key == secret_key + + +class TestFacilitatorHighloadV3Signer: + def test_get_addresses_for_network_should_return_only_requested_wallet(self): + secret_key = derive_test_secret_key() + signer = FacilitatorHighloadV3Signer( + { + TVM_TESTNET: HighloadV3Config(secret_key=secret_key, subwallet_id=1), + TVM_MAINNET: HighloadV3Config(secret_key=secret_key, subwallet_id=2), + } + ) + + testnet_addresses = signer.get_addresses_for_network(TVM_TESTNET) + mainnet_addresses = signer.get_addresses_for_network(TVM_MAINNET) + + assert len(testnet_addresses) == 1 + assert len(mainnet_addresses) == 1 + assert testnet_addresses != mainnet_addresses + assert signer.get_addresses() == testnet_addresses + mainnet_addresses + + def test_wait_for_trace_confirmation_fetches_full_trace_after_stream_signal(self, monkeypatch): + secret_key = derive_test_secret_key() + signer = FacilitatorHighloadV3Signer( + { + TVM_TESTNET: HighloadV3Config( + secret_key=secret_key, + ) + } + ) + stream_calls: list[tuple[str, float]] = [] + trace_calls: list[str] = [] + expected_trace = { + "trace_id": "trace-id-1", + "transactions": { + "tx-1": { + "account": "0:" + "1" * 64, + } + }, + } + + class _FakeStreamingClient: + def wait_for_trace_confirmation( + self, *, trace_external_hash_norm: str, timeout_seconds: float + ): + stream_calls.append((trace_external_hash_norm, timeout_seconds)) + return { + "type": "transactions", + "finality": "finalized", + "trace_external_hash_norm": trace_external_hash_norm, + } + + class _FakeProviderClient: + def get_trace_by_message_hash(self, trace_external_hash_norm: str): + trace_calls.append(trace_external_hash_norm) + return expected_trace + + monkeypatch.setattr(signer, "_ensure_streaming_watcher", lambda network: True) + monkeypatch.setattr(signer, "_streaming_client", lambda network: _FakeStreamingClient()) + monkeypatch.setattr(signer, "_client", lambda network: _FakeProviderClient()) + + result = signer.wait_for_trace_confirmation( + TVM_TESTNET, + "trace-hash-1", + timeout_seconds=12.5, + ) + + assert len(stream_calls) == 1 + assert stream_calls[0][0] == "trace-hash-1" + assert stream_calls[0][1] == pytest.approx( + DEFAULT_STREAMING_CONFIRMATION_GRACE_SECONDS, abs=0.1 + ) + assert trace_calls == ["trace-hash-1"] + assert result == expected_trace + + def test_wait_for_trace_confirmation_uses_remaining_budget_for_rest_after_stream_timeout( + self, monkeypatch + ): + secret_key = derive_test_secret_key() + signer = FacilitatorHighloadV3Signer( + { + TVM_TESTNET: HighloadV3Config( + secret_key=secret_key, + ) + } + ) + stream_calls: list[tuple[str, float]] = [] + trace_calls: list[str] = [] + fake_now = 100.0 + expected_trace = { + "trace_id": "trace-id-1", + "transactions": { + "tx-1": { + "account": "0:" + "1" * 64, + } + }, + } + + class _FakeStreamingClient: + def wait_for_trace_confirmation( + self, *, trace_external_hash_norm: str, timeout_seconds: float + ): + nonlocal fake_now + stream_calls.append((trace_external_hash_norm, timeout_seconds)) + fake_now += timeout_seconds + raise RuntimeError("stream timeout") + + class _FakeProviderClient: + def get_trace_by_message_hash(self, trace_external_hash_norm: str): + trace_calls.append(trace_external_hash_norm) + return expected_trace + + monkeypatch.setattr(signer, "_ensure_streaming_watcher", lambda network: True) + monkeypatch.setattr(signer, "_streaming_client", lambda network: _FakeStreamingClient()) + monkeypatch.setattr(signer, "_client", lambda network: _FakeProviderClient()) + monkeypatch.setattr("x402.mechanisms.tvm.signers.time.monotonic", lambda: fake_now) + monkeypatch.setattr("x402.mechanisms.tvm.signers.time.sleep", lambda seconds: None) + + result = signer.wait_for_trace_confirmation( + TVM_TESTNET, + "trace-hash-1", + timeout_seconds=12.5, + ) + + assert len(stream_calls) == 1 + assert stream_calls[0][0] == "trace-hash-1" + assert stream_calls[0][1] == pytest.approx( + DEFAULT_STREAMING_CONFIRMATION_GRACE_SECONDS, abs=0.1 + ) + assert fake_now == pytest.approx(105.0, abs=0.1) + assert trace_calls == ["trace-hash-1"] + assert result == expected_trace + + def test_emulate_external_message_uses_emulation_timeout(self, monkeypatch): + secret_key = derive_test_secret_key() + signer = FacilitatorHighloadV3Signer( + { + TVM_TESTNET: HighloadV3Config( + secret_key=secret_key, + toncenter_emulation_timeout_seconds=17.5, + ) + } + ) + emulate_calls: list[tuple[bytes, float]] = [] + + class _FakeProviderClient: + def emulate_trace(self, boc: bytes, *, ignore_chksig: bool = False, timeout: float): + assert ignore_chksig is False + emulate_calls.append((boc, timeout)) + return {"transactions": {}} + + monkeypatch.setattr(signer, "_client", lambda network: _FakeProviderClient()) + + result = signer.emulate_external_message(TVM_TESTNET, b"external-boc") + + assert result == {"transactions": {}} + assert emulate_calls == [(b"external-boc", 17.5)] + + def test_wallet_config_defaults_emulation_timeout(self): + config = WalletV5R1Config.from_mnemonic(TVM_TESTNET, TEST_MNEMONIC) + + assert config.toncenter_emulation_timeout_seconds == ( + DEFAULT_TONCENTER_EMULATION_TIMEOUT_SECONDS + ) + + def test_select_query_id_fetches_account_state_outside_lock(self, monkeypatch): + secret_key = derive_test_secret_key() + signer = FacilitatorHighloadV3Signer( + { + TVM_TESTNET: HighloadV3Config( + secret_key=secret_key, + ) + } + ) + + class _TrackingLock: + def __init__(self) -> None: + self._lock = threading.RLock() + self.depth = 0 + + def __enter__(self): + self._lock.acquire() + self.depth += 1 + return self + + def __exit__(self, exc_type, exc, tb): + self.depth -= 1 + self._lock.release() + + tracking_lock = _TrackingLock() + signer._lock = tracking_lock + account_state_lock_depths: list[int] = [] + + def _get_account_state(address: str, network: str) -> TvmAccountState: + account_state_lock_depths.append(tracking_lock.depth) + return TvmAccountState( + address=address, + balance=0, + is_active=False, + is_uninitialized=True, + is_frozen=False, + state_init=None, + ) + + monkeypatch.setattr(signer, "get_account_state", _get_account_state) + + query_id = signer._select_query_id(TVM_TESTNET, for_emulation=False) + + assert account_state_lock_depths == [0] + assert query_id >= 0 + + def test_ensure_streaming_watcher_starts_only_one_sse_connection(self, monkeypatch): + secret_key = derive_test_secret_key() + signer = FacilitatorHighloadV3Signer( + { + TVM_TESTNET: HighloadV3Config( + secret_key=secret_key, + ) + } + ) + start_entered = threading.Event() + allow_finish = threading.Event() + start_calls = 0 + results: list[bool] = [] + + class _FakeWatcher: + def is_alive(self) -> bool: + return True + + class _FakeStreamingClient: + def start_account_state_watcher(self, *, address: str, on_invalidate): + nonlocal start_calls + _ = on_invalidate + assert address == signer.get_addresses_for_network(TVM_TESTNET)[0] + start_calls += 1 + start_entered.set() + assert allow_finish.wait(timeout=1.0) + return _FakeWatcher() + + monkeypatch.setattr(signer, "_streaming_client", lambda network: _FakeStreamingClient()) + + first = start_captured_thread( + lambda: results.append(signer._ensure_streaming_watcher(TVM_TESTNET)) + ) + assert start_entered.wait(timeout=1.0) + second = start_captured_thread( + lambda: results.append(signer._ensure_streaming_watcher(TVM_TESTNET)) + ) + allow_finish.set() + + first.join() + second.join() + + assert start_calls == 1 + assert results == [True, True] diff --git a/python/x402/tests/unit/mechanisms/tvm/test_streaming.py b/python/x402/tests/unit/mechanisms/tvm/test_streaming.py new file mode 100644 index 0000000000..053f857b8f --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/test_streaming.py @@ -0,0 +1,286 @@ +"""Tests for TVM streaming trace confirmation.""" + +from __future__ import annotations + +import queue +import threading +import time +from dataclasses import dataclass + +import pytest + +pytest.importorskip("pytoniq_core") + +import x402.mechanisms.tvm.streaming as streaming_module +from x402.mechanisms.tvm.streaming import ( + ToncenterStreamingWatcher, + ToncenterStreamingSseClient, + _account_stream_subscription, + _iter_sse_json_events, + _iter_sse_payloads, +) +from .helpers import start_captured_thread + +TRACE_HASH = "trace-hash-1" +FACILITATOR_ADDRESS = "0:" + "1" * 64 + + +@dataclass(frozen=True) +class _ConsumePlan: + events: tuple[dict[str, object], ...] = () + error: Exception | None = None + wait_on: threading.Event | None = None + sleep_seconds: float = 0.0 + set_stop: bool = False + + +def _subscribed_event() -> dict[str, str]: + return {"status": "subscribed"} + + +def _finalized_trace_event(trace_hash: str = TRACE_HASH) -> dict[str, object]: + return { + "type": "transactions", + "finality": "finalized", + "trace_external_hash_norm": trace_hash, + "transactions": [], + } + + +def _start_trace_waiter( + client: ToncenterStreamingSseClient, + *, + trace_hash: str = TRACE_HASH, + timeout_seconds: float = 1.0, +): + return start_captured_thread( + lambda: client.wait_for_trace_confirmation( + trace_external_hash_norm=trace_hash, + timeout_seconds=timeout_seconds, + ) + ) + + +def _planned_consumer(state: dict[str, int], plans: list[_ConsumePlan]): + def fake_consume_stream(*, subscription, stop_event, on_event, resources=None): + _ = subscription, resources + state["calls"] += 1 + plan = plans[state["calls"] - 1] + for event in plan.events: + on_event(event) + if plan.wait_on is not None: + plan.wait_on.wait(timeout=1.0) + if plan.sleep_seconds: + time.sleep(plan.sleep_seconds) + if plan.set_stop: + stop_event.set() + if plan.error is not None: + raise plan.error + + return fake_consume_stream + + +def test_account_stream_subscription_uses_transactions_and_account_state_change(): + assert _account_stream_subscription(FACILITATOR_ADDRESS) == { + "addresses": [FACILITATOR_ADDRESS], + "types": ["account_state_change", "transactions"], + "min_finality": "finalized", + } + + +def test_iter_sse_payloads_ignores_trailing_partial_event_without_blank_line(): + lines = [ + 'data: {"status":"subscribed"}', + "", + 'data: {"type":"transactions"', + ] + + assert list(_iter_sse_payloads(lines)) == ['{"status":"subscribed"}'] + + +def test_iter_sse_json_events_ignores_trailing_partial_event_without_blank_line(): + lines = [ + 'data: {"status":"subscribed"}', + "", + 'data: {"type":"transactions"', + ] + + assert list(_iter_sse_json_events(lines)) == [{"status": "subscribed"}] + + +def test_streaming_watcher_reports_whether_the_caller_is_the_watcher_thread(): + watcher = ToncenterStreamingWatcher( + threading.current_thread(), + threading.Event(), + close_stream=lambda: None, + ) + + assert watcher.is_current_thread() is True + + result_holder: dict[str, bool] = {} + + def check_from_other_thread() -> None: + result_holder["is_current_thread"] = watcher.is_current_thread() + + other_thread = start_captured_thread(check_from_other_thread) + other_thread.join() + assert result_holder["is_current_thread"] is False + + +def test_wait_for_trace_confirmation_returns_finalized_trace_payload_from_transactions_event( + monkeypatch, +): + client = ToncenterStreamingSseClient(base_url="https://toncenter.example") + client._watcher = object() # type: ignore[assignment] + + waiter = start_captured_thread( + lambda: client.wait_for_trace_confirmation( + trace_external_hash_norm=TRACE_HASH, + timeout_seconds=1.0, + ) + ) + client._handle_stream_event( + _finalized_trace_event(), + normalized_address=FACILITATOR_ADDRESS, + on_invalidate=lambda: None, + on_subscribed=lambda: None, + ) + waiter.join() + + assert waiter.result == _finalized_trace_event() + + +def test_start_account_state_watcher_retries_after_failed_start(monkeypatch): + client = ToncenterStreamingSseClient(base_url="https://toncenter.example") + state = {"calls": 0} + + def fake_consume_stream(*, subscription, stop_event, on_event, resources=None): + _ = subscription, resources + state["calls"] += 1 + if state["calls"] == 1: + raise RuntimeError("boom") + on_event(_subscribed_event()) + while not stop_event.wait(0.01): + pass + + monkeypatch.setattr(client, "_consume_stream", fake_consume_stream) + + with pytest.raises(RuntimeError, match="failed to start"): + client.start_account_state_watcher( + address=FACILITATOR_ADDRESS, + on_invalidate=lambda: None, + ) + + watcher = client.start_account_state_watcher( + address=FACILITATOR_ADDRESS, + on_invalidate=lambda: None, + ) + try: + assert watcher.is_alive() is True + assert state["calls"] == 2 + finally: + watcher.close() + + +def test_wait_for_trace_confirmation_survives_stream_reconnect(monkeypatch): + client = ToncenterStreamingSseClient(base_url="https://toncenter.example") + state = {"calls": 0} + monkeypatch.setattr(streaming_module, "DEFAULT_STREAMING_RECONNECT_BACKOFF_SECONDS", 0.01) + monkeypatch.setattr( + client, + "_consume_stream", + _planned_consumer( + state, + [ + _ConsumePlan( + events=(_subscribed_event(),), + sleep_seconds=0.05, + error=RuntimeError("disconnect"), + ), + _ConsumePlan( + events=(_subscribed_event(), _finalized_trace_event()), + set_stop=True, + ), + ], + ), + ) + watcher = client.start_account_state_watcher( + address=FACILITATOR_ADDRESS, + on_invalidate=lambda: None, + ) + try: + waiter = _start_trace_waiter(client) + waiter.join() + + assert waiter.result == _finalized_trace_event() + assert state["calls"] >= 2 + finally: + watcher.close() + + +def test_wait_for_trace_confirmation_fails_after_max_consecutive_stream_failures(monkeypatch): + client = ToncenterStreamingSseClient(base_url="https://toncenter.example") + state = {"calls": 0, "invalidations": 0} + release_first_failure = threading.Event() + + monkeypatch.setattr(streaming_module, "DEFAULT_STREAMING_RECONNECT_BACKOFF_SECONDS", 0.01) + monkeypatch.setattr(streaming_module, "DEFAULT_STREAMING_MAX_CONSECUTIVE_FAILURES", 2) + + monkeypatch.setattr( + client, + "_consume_stream", + _planned_consumer( + state, + [ + _ConsumePlan( + events=(_subscribed_event(),), + wait_on=release_first_failure, + error=RuntimeError("disconnect-1"), + ), + _ConsumePlan(error=RuntimeError("disconnect-2")), + ], + ), + ) + watcher = client.start_account_state_watcher( + address=FACILITATOR_ADDRESS, + on_invalidate=lambda: state.__setitem__("invalidations", state["invalidations"] + 1), + ) + try: + waiter = _start_trace_waiter(client) + release_first_failure.set() + waiter.join(timeout=0.5) + error = waiter.error + assert isinstance(error, RuntimeError) + assert str(error) == ( + "Toncenter facilitator account stream failed before confirmation: disconnect-2" + ) + assert state["calls"] == 2 + assert state["invalidations"] == 2 + finally: + watcher.close() + + +def test_close_stops_watcher_and_fails_pending_waiters(): + client = ToncenterStreamingSseClient(base_url="https://toncenter.example") + waiter: queue.Queue[dict[str, object] | Exception] = queue.Queue(maxsize=1) + close_calls: list[str] = [] + + class _Watcher: + def close(self) -> None: + close_calls.append("closed") + + def is_alive(self) -> bool: + return True + + client._watcher = _Watcher() # type: ignore[assignment] + client._watched_address = FACILITATOR_ADDRESS + client._pending_trace_waiters[TRACE_HASH] = [waiter] + + client.close() + + result = waiter.get_nowait() + assert isinstance(result, RuntimeError) + assert str(result) == "Toncenter facilitator account stream closed" + assert close_calls == ["closed"] + assert client._watcher is None + assert client._watched_address is None diff --git a/python/x402/tests/unit/mechanisms/tvm/test_trace_utils.py b/python/x402/tests/unit/mechanisms/tvm/test_trace_utils.py new file mode 100644 index 0000000000..815fd52d90 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/test_trace_utils.py @@ -0,0 +1,183 @@ +"""Focused tests for TVM trace utility helpers.""" + +from __future__ import annotations + +import base64 + +import pytest + +from x402.mechanisms.tvm.trace_utils import ( + body_hash_to_base64, + message_body_hash_matches, + parse_trace_transactions, + trace_transaction_hash_to_hex, + trace_transaction_balance_before, + trace_transaction_compute_fees, + trace_transaction_fwd_fees, + trace_transaction_storage_fees, + transaction_succeeded, +) + + +class TestParseTraceTransactions: + def test_should_return_transactions_values(self): + transactions = parse_trace_transactions( + {"transactions": {"a": {"hash": "1"}, "b": {"hash": "2"}}} + ) + + assert transactions == [{"hash": "1"}, {"hash": "2"}] + + def test_should_reject_malformed_trace_payload(self): + with pytest.raises(ValueError, match="transactions dict"): + parse_trace_transactions({}) + + +class TestTransactionSucceeded: + def test_should_return_true_for_successful_transaction(self): + assert transaction_succeeded( + { + "description": { + "aborted": False, + "compute_ph": {"success": True, "skipped": False}, + "action": {"success": True}, + } + } + ) + + @pytest.mark.parametrize( + "transaction", + [ + pytest.param( + { + "description": { + "aborted": True, + "compute_ph": {"success": True, "skipped": False}, + } + }, + id="aborted", + ), + pytest.param( + { + "description": { + "aborted": False, + "compute_ph": {"success": False, "skipped": False}, + } + }, + id="compute-failed", + ), + pytest.param( + { + "description": { + "aborted": False, + "compute_ph": {"success": True, "skipped": True}, + } + }, + id="compute-skipped", + ), + pytest.param( + { + "description": { + "aborted": False, + "compute_ph": {"success": True, "skipped": False}, + "action": {"success": False}, + } + }, + id="action-failed", + ), + ], + ) + def test_should_return_false_for_failed_transaction(self, transaction): + assert transaction_succeeded(transaction) is False + + +class TestBodyHashes: + def test_should_encode_raw_hash_to_base64(self): + raw_hash = b"\x01\x02\x03" + + assert body_hash_to_base64(raw_hash) == base64.b64encode(raw_hash).decode("ascii") + + def test_should_convert_toncenter_transaction_hash_to_hex(self): + raw_hash = bytes(range(32)) + + assert ( + trace_transaction_hash_to_hex(base64.b64encode(raw_hash).decode("ascii")) + == raw_hash.hex() + ) + + def test_should_match_message_content_hash(self): + raw_hash = b"\x05" * 32 + message = {"message_content": {"hash": body_hash_to_base64(raw_hash)}} + + assert message_body_hash_matches(message, raw_hash) is True + assert message_body_hash_matches(message, b"\x06" * 32) is False + + +class TestForwardFees: + def test_should_sum_forward_fees_from_out_messages(self): + assert ( + trace_transaction_fwd_fees( + {"out_msgs": [{"fwd_fee": "10"}, {"fwd_fee": "0x20"}, {"fwd_fee": "bad"}]} + ) + == 42 + ) + + def test_should_fallback_to_total_fwd_fees_from_action_phase(self): + assert ( + trace_transaction_fwd_fees({"description": {"action": {"total_fwd_fees": "55"}}}) == 55 + ) + + def test_should_fallback_to_fwd_fee_times_expected_count(self): + assert ( + trace_transaction_fwd_fees( + {"description": {"action": {"fwd_fee": "7"}}}, + expected_count=3, + ) + == 21 + ) + + def test_should_return_zero_when_fees_are_missing(self): + assert trace_transaction_fwd_fees({"description": {}}) == 0 + + +class TestOtherFeeExtraction: + def test_should_extract_compute_fees(self): + assert ( + trace_transaction_compute_fees({"description": {"compute_ph": {"gas_fees": "33"}}}) + == 33 + ) + + def test_should_extract_storage_fees_collected_then_due(self): + assert ( + trace_transaction_storage_fees( + {"description": {"storage_ph": {"storage_fees_collected": "44"}}} + ) + == 44 + ) + assert ( + trace_transaction_storage_fees( + {"description": {"storage_ph": {"storage_fees_due": "55"}}} + ) + == 55 + ) + + +class TestBalanceBefore: + def test_should_prefer_account_state_before_balance(self): + assert ( + trace_transaction_balance_before( + { + "account_state_before": {"balance": "10"}, + "account_state": {"balance": "20"}, + "balance": "30", + } + ) + == 10 + ) + + def test_should_fallback_to_account_state_then_transaction_balance(self): + assert trace_transaction_balance_before({"account_state": {"balance": "20"}}) == 20 + assert trace_transaction_balance_before({"balance": "30"}) == 30 + + def test_should_fail_when_balance_is_missing(self): + with pytest.raises(ValueError, match="missing account_state_before balance"): + trace_transaction_balance_before({}) diff --git a/python/x402/tests/unit/mechanisms/tvm/test_types.py b/python/x402/tests/unit/mechanisms/tvm/test_types.py new file mode 100644 index 0000000000..ee5781da79 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tvm/test_types.py @@ -0,0 +1,137 @@ +"""Tests for TVM payload and parsed data types.""" + +import pytest + +pytest.importorskip("pytoniq_core") + +from pytoniq_core import begin_cell + +from x402.mechanisms.tvm import ( + ExactTvmPayload, + ParsedJettonTransfer, + ParsedTvmSettlement, + TvmAccountState, + TvmJettonWalletData, + TvmRelayRequest, +) + + +class TestExactTvmPayload: + """Test ExactTvmPayload serialization helpers.""" + + def test_to_dict_should_return_expected_shape(self): + """to_dict should use public JSON field names.""" + payload = ExactTvmPayload( + settlement_boc="base64-boc==", + asset="0:" + "1" * 64, + ) + + assert payload.to_dict() == { + "settlementBoc": "base64-boc==", + "asset": "0:" + "1" * 64, + } + + def test_from_dict_should_create_payload_from_dict(self): + """from_dict should hydrate the dataclass from JSON field names.""" + payload = ExactTvmPayload.from_dict( + { + "settlementBoc": "base64-boc==", + "asset": "0:" + "2" * 64, + } + ) + + assert payload.settlement_boc == "base64-boc==" + assert payload.asset == "0:" + "2" * 64 + + def test_round_trip_serialization(self): + """Should preserve data through serialization round-trip.""" + original = ExactTvmPayload( + settlement_boc="payload==", + asset="0:" + "3" * 64, + ) + + restored = ExactTvmPayload.from_dict(original.to_dict()) + + assert restored == original + + def test_from_dict_should_reject_missing_settlement_boc(self): + """from_dict should reject payloads without settlementBoc.""" + with pytest.raises(ValueError, match="settlementBoc.*required"): + ExactTvmPayload.from_dict({"asset": "0:" + "2" * 64}) + + def test_from_dict_should_reject_empty_asset(self): + """from_dict should reject payloads with an empty asset.""" + with pytest.raises(ValueError, match="asset.*required"): + ExactTvmPayload.from_dict( + { + "settlementBoc": "base64-boc==", + "asset": " ", + } + ) + + +class TestParsedTypes: + """Test TVM parsed settlement dataclasses.""" + + def test_should_store_account_state_data(self): + """Account state dataclass should retain provided fields.""" + state = TvmAccountState( + address="0:" + "4" * 64, + balance=123, + is_active=True, + is_frozen=False, + is_uninitialized=False, + state_init=None, + ) + + assert state.address == "0:" + "4" * 64 + assert state.balance == 123 + assert state.is_active is True + + def test_should_store_jetton_wallet_data(self): + """Jetton wallet dataclass should retain provided fields.""" + wallet = TvmJettonWalletData( + address="0:" + "5" * 64, + balance=456, + owner="0:" + "6" * 64, + jetton_minter="0:" + "7" * 64, + ) + + assert wallet.balance == 456 + assert wallet.owner == "0:" + "6" * 64 + + def test_should_store_relay_request_and_parsed_settlement(self): + """Parsed transfer/settlement dataclasses should accept cell payloads.""" + cell = begin_cell().store_uint(1, 1).end_cell() + relay_request = TvmRelayRequest( + destination="0:" + "8" * 64, + body=cell, + state_init=None, + ) + transfer = ParsedJettonTransfer( + source_wallet="0:" + "9" * 64, + destination="0:" + "a" * 64, + response_destination=None, + jetton_amount=1000, + attached_ton_amount=2000, + forward_ton_amount=1, + forward_payload=cell, + body_hash=b"hash", + ) + settlement = ParsedTvmSettlement( + payer="0:" + "c" * 64, + wallet_id=1, + valid_until=2, + seqno=3, + settlement_hash="hash-1", + body=cell, + signed_slice_hash=b"slice", + signature=b"sig", + state_init=None, + transfer=transfer, + ) + + assert relay_request.body == cell + assert settlement.transfer.attached_ton_amount == 2000 + assert settlement.transfer.jetton_amount == 1000 + assert settlement.signature == b"sig" diff --git a/python/x402/uv.lock b/python/x402/uv.lock index d9b2c5ceb9..c0f42ce75a 100644 --- a/python/x402/uv.lock +++ b/python/x402/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -2138,6 +2138,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, ] +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695, upload-time = "2025-05-17T17:23:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772, upload-time = "2025-05-17T17:23:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083, upload-time = "2025-05-17T17:23:21.867Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056, upload-time = "2025-05-17T17:23:24.031Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478, upload-time = "2025-05-17T17:23:26.066Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -2326,6 +2361,41 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -2385,6 +2455,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, ] +[[package]] +name = "pytoniq" +version = "0.1.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytoniq-core" }, + { name = "requests" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/b2/9991a953e4b766918a142fe111f71f12803c6acf65eb30f36eb85ed08f31/pytoniq-0.1.43.tar.gz", hash = "sha256:b4b1c8fed2f9d2f1b6f0ab4b3f1fc5503a0088630d8081f817807ff31e608606", size = 50463, upload-time = "2025-11-30T12:30:41.102Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/c1/b6e5c739839e0e12bde4563438acd55d39552ea63f6de123724b4b91ac64/pytoniq-0.1.43-py3-none-any.whl", hash = "sha256:922c1721124bf7214e0b7044fba2a439e006367778611c0ce813cbe5b00079d3", size = 56118, upload-time = "2025-11-30T12:30:39.65Z" }, +] + +[[package]] +name = "pytoniq-core" +version = "0.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bitarray" }, + { name = "pycryptodomex" }, + { name = "pynacl" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "x25519" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/2c/7afbb9003a3aa72ccfe69711433fe36d2493db2c4acf66dde32f7b55799b/pytoniq_core-0.1.46.tar.gz", hash = "sha256:c8e3cf9ccb1852780a725cd51ba7a66a28122eb39c8b9bb97dcdc5bd02c24734", size = 101236, upload-time = "2025-11-28T10:23:21.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/0e/e27cf7ce1bebb47fb95e1d6deae5c91c6ffcb7851f156990e57079cbe8db/pytoniq_core-0.1.46-py3-none-any.whl", hash = "sha256:0a284c8b68f9fed9d54e4dad871238d844339183bf985a614796360e36e1b95e", size = 91400, upload-time = "2025-11-28T10:23:20.95Z" }, +] + [[package]] name = "pyunormalize" version = "17.0.0" @@ -2937,6 +3038,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/19/8d77f9992e5cbfcaa9133c3bf63b4fbbb051248802e1e803fed5c552fbb2/sentry_sdk-2.48.0-py2.py3-none-any.whl", hash = "sha256:6b12ac256769d41825d9b7518444e57fa35b5642df4c7c5e322af4d2c8721172", size = 414555, upload-time = "2025-12-16T14:55:40.152Z" }, ] +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -3405,6 +3515,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/f9/9e082990c2585c744734f85bec79b5dae5df9c974ffee58fe421652c8e91/werkzeug-3.1.4-py3-none-any.whl", hash = "sha256:2ad50fb9ed09cc3af22c54698351027ace879a0b60a3b5edf5730b2f7d876905", size = 224960, upload-time = "2025-11-29T02:15:21.13Z" }, ] +[[package]] +name = "x25519" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/b6/fca895aff0800cdf941f856df0685a5513094163664b904576e3e3ef1460/x25519-0.0.2.tar.gz", hash = "sha256:ed91d0aba7f4f4959ed8b37118c11d94f56d36c38bb6f2e6c20d0438d75b1556", size = 4833, upload-time = "2021-10-24T15:18:38.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/d1/66c637eb8e7a9601675bf7f04bb9a3015358a0f49e4c31d29a2b9a9d72d9/x25519-0.0.2-py3-none-any.whl", hash = "sha256:5c0833260a548bea9137a5a1b5c30334b751a59d148a62832df0c9e7b919ce99", size = 4907, upload-time = "2021-10-24T15:18:36.727Z" }, +] + [[package]] name = "x402" version = "2.5.0" @@ -3426,6 +3545,9 @@ all = [ { name = "httpx" }, { name = "jsonschema" }, { name = "mcp" }, + { name = "pynacl" }, + { name = "pytoniq" }, + { name = "pytoniq-core" }, { name = "requests" }, { name = "solana" }, { name = "solders" }, @@ -3464,6 +3586,10 @@ mechanisms = [ { name = "eth-account" }, { name = "eth-keys" }, { name = "eth-utils" }, + { name = "httpx" }, + { name = "pynacl" }, + { name = "pytoniq" }, + { name = "pytoniq-core" }, { name = "solana" }, { name = "solders" }, { name = "web3" }, @@ -3480,6 +3606,12 @@ svm = [ { name = "solana" }, { name = "solders" }, ] +tvm = [ + { name = "httpx" }, + { name = "pynacl" }, + { name = "pytoniq" }, + { name = "pytoniq-core" }, +] [package.dev-dependencies] dev = [ @@ -3495,8 +3627,11 @@ dev = [ { name = "mcp" }, { name = "mypy" }, { name = "nest-asyncio" }, + { name = "pynacl" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytoniq" }, + { name = "pytoniq-core" }, { name = "requests" }, { name = "ruff" }, { name = "solana" }, @@ -3515,22 +3650,26 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.115.0" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0.0" }, { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.28.1" }, + { name = "httpx", marker = "extra == 'tvm'", specifier = ">=0.28.1" }, { name = "jsonschema", marker = "extra == 'extensions'", specifier = ">=4.0.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pynacl", marker = "extra == 'tvm'", specifier = ">=1.5.0" }, + { name = "pytoniq", marker = "extra == 'tvm'", specifier = ">=0.1.39" }, + { name = "pytoniq-core", marker = "extra == 'tvm'", specifier = ">=0.1.36" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.31.0" }, { name = "solana", marker = "extra == 'svm'", specifier = ">=0.36.0" }, { name = "solders", marker = "extra == 'svm'", specifier = ">=0.27.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, - { name = "x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "x402", extras = ["evm", "svm", "tvm"], marker = "extra == 'mechanisms'" }, { name = "x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions"], marker = "extra == 'all'" }, ] -provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] +provides-extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "tvm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] [package.metadata.requires-dev] dev = [ @@ -3546,8 +3685,11 @@ dev = [ { name = "mcp", specifier = ">=1.26.0" }, { name = "mypy", specifier = ">=1.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "pynacl", specifier = ">=1.5.0" }, { name = "pytest", specifier = ">=7.0.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytoniq", specifier = ">=0.1.39" }, + { name = "pytoniq-core", specifier = ">=0.1.36" }, { name = "requests", specifier = ">=2.31.0" }, { name = "ruff", specifier = ">=0.1.0" }, { name = "solana", specifier = ">=0.36.0" },