diff --git a/e2e/.env-local b/e2e/.env-local index 77e443bb..797e8cdd 100644 --- a/e2e/.env-local +++ b/e2e/.env-local @@ -1,10 +1,36 @@ -# E2E Test Configuration -SERVER_EVM_ADDRESS= -SERVER_SVM_ADDRESS= -SERVER_STELLAR_ADDRESS= -CLIENT_EVM_PRIVATE_KEY= -CLIENT_SVM_PRIVATE_KEY= -CLIENT_STELLAR_PRIVATE_KEY= -FACILITATOR_EVM_PRIVATE_KEY= -FACILITATOR_SVM_PRIVATE_KEY= -FACILITATOR_STELLAR_PRIVATE_KEY= +# E2E Environment Template +# Copy to .env and fill in real values. + +# Client wallets +CLIENT_EVM_PRIVATE_KEY=0x... +CLIENT_SVM_PRIVATE_KEY=... +CLIENT_APTOS_PRIVATE_KEY=... +CLIENT_STELLAR_PRIVATE_KEY=... +CLIENT_TRON_PRIVATE_KEY=... + +# Server payment addresses +SERVER_EVM_ADDRESS=0x... +SERVER_SVM_ADDRESS=... +SERVER_APTOS_ADDRESS=... +SERVER_STELLAR_ADDRESS=... +SERVER_TRON_ADDRESS=... +EVM_FACILITATOR_ADDRESS=0x... +TRON_FACILITATOR_ADDRESS=... + +# Facilitator wallets +FACILITATOR_EVM_PRIVATE_KEY=0x... +FACILITATOR_SVM_PRIVATE_KEY=... +FACILITATOR_APTOS_PRIVATE_KEY=... +FACILITATOR_STELLAR_PRIVATE_KEY=... +FACILITATOR_TRON_PRIVATE_KEY=... + +# Optional network overrides +# EVM_NETWORK=eip155:97 +# TRON_NETWORK=tron:nile + +# Optional RPC overrides +# BSC_TESTNET_RPC_URL=https://bsc-testnet-rpc.publicnode.com +# TRON_NILE_RPC_URL=https://nile.trongrid.io +# SOLANA_DEVNET_RPC_URL=https://api.devnet.solana.com +# APTOS_TESTNET_RPC_URL=https://fullnode.testnet.aptoslabs.com/v1 +# STELLAR_TESTNET_RPC_URL=https://soroban-testnet.stellar.org diff --git a/e2e/README.md b/e2e/README.md index ea77517d..d3977cfc 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -101,12 +101,15 @@ 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_TRON_ADDRESS=... # Where servers receive TRON payments +TRON_FACILITATOR_ADDRESS=... # TRON facilitator address for Permit2 (base58) # Facilitator wallets (for payment verification/settlement) 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_TRON_PRIVATE_KEY=... # TRON private key for facilitator ``` ### Account Setup Instructions diff --git a/e2e/clients/axios/index.ts b/e2e/clients/axios/index.ts index 3e919bed..ec0f0312 100644 --- a/e2e/clients/axios/index.ts +++ b/e2e/clients/axios/index.ts @@ -1,6 +1,7 @@ import { config } from "dotenv"; import axios from "axios"; import { wrapAxiosWithPayment, decodePaymentResponseHeader } from "@bankofai/x402-axios"; +import { decodePaymentRequiredHeader } from "@bankofai/x402-core/http"; import { createPublicClient, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { bscTestnet } from "viem/chains"; @@ -107,11 +108,31 @@ axiosWithPayment process.exit(0); }) .catch(error => { + const status = error.response?.status || 500; + const headers = error.response?.headers || {}; + const paymentRequiredHeader = headers["payment-required"] || headers["x-payment"]; + const paymentResponseHeader = headers["payment-response"] || headers["x-payment-response"]; + + let paymentRequired = undefined; + let paymentResponse = undefined; + if (paymentRequiredHeader) { + try { + paymentRequired = decodePaymentRequiredHeader(paymentRequiredHeader); + } catch {} + } + if (paymentResponseHeader) { + try { + paymentResponse = decodePaymentResponseHeader(paymentResponseHeader); + } catch {} + } + console.error( JSON.stringify({ success: false, error: error.message || "Request failed", - status_code: error.response?.status || 500, + status_code: status, + payment_required: paymentRequired, + payment_response: paymentResponse, }), ); process.exit(1); diff --git a/e2e/clients/httpx/main.py b/e2e/clients/httpx/main.py index 359d6794..a6eeb12e 100644 --- a/e2e/clients/httpx/main.py +++ b/e2e/clients/httpx/main.py @@ -8,12 +8,14 @@ # Import from new x402 package from bankofai.x402 import x402Client -from bankofai.x402.http import decode_payment_response_header +from bankofai.x402.http import decode_payment_response_header, decode_payment_required_header from bankofai.x402.http.clients import x402_httpx_transport from bankofai.x402.mechanisms.evm import EthAccountSigner from bankofai.x402.mechanisms.evm.exact import register_exact_evm_client from bankofai.x402.mechanisms.svm import KeypairSigner from bankofai.x402.mechanisms.svm.exact import register_exact_svm_client +from bankofai.x402.mechanisms.tron.signers import ClientTronSigner +from bankofai.x402.mechanisms.tron.exact import register_exact_tron_client import httpx # Load environment variables @@ -22,6 +24,8 @@ # Get environment variables evm_private_key = os.getenv("EVM_PRIVATE_KEY") svm_private_key = os.getenv("SVM_PRIVATE_KEY") +tron_private_key = os.getenv("TRON_PRIVATE_KEY") +tron_rpc_url = os.getenv("TRON_RPC_URL") base_url = os.getenv("RESOURCE_SERVER_URL") endpoint_path = os.getenv("ENDPOINT_PATH") @@ -54,9 +58,17 @@ async def main(): svm_signer = KeypairSigner.from_base58(svm_private_key) register_exact_svm_client(client, svm_signer) + # Register TRON exact scheme if private key is available + if tron_private_key: + tron_signer = ClientTronSigner( + private_key=tron_private_key, + full_node=tron_rpc_url or "https://nile.trongrid.io", + ) + register_exact_tron_client(client, tron_signer) + # Create httpx client with x402 payment transport and increased timeout - # Set timeout to 30 seconds to handle busy servers during test runs - timeout = httpx.Timeout(30.0, connect=10.0) + # Set timeout to 90 seconds to handle slow on-chain settlement during test runs + timeout = httpx.Timeout(90.0, connect=10.0) async with httpx.AsyncClient( base_url=base_url, timeout=timeout, @@ -76,6 +88,7 @@ async def main(): "data": response_data, "status_code": response.status_code, "payment_response": None, + "payment_required": None, } # Check for payment response header (V2: PAYMENT-RESPONSE, V1: X-PAYMENT-RESPONSE) @@ -86,6 +99,13 @@ async def main(): payment_response = decode_payment_response_header(payment_header) result["payment_response"] = payment_response.model_dump() + payment_required_header = response.headers.get("PAYMENT-REQUIRED") or response.headers.get( + "X-PAYMENT" + ) + if payment_required_header: + payment_required = decode_payment_required_header(payment_required_header) + result["payment_required"] = payment_required.model_dump() + # Output structured result as JSON for proxy to parse print(json.dumps(result)) exit(0) diff --git a/e2e/clients/httpx/pyproject.toml b/e2e/clients/httpx/pyproject.toml index 7e610938..0049e5b0 100644 --- a/e2e/clients/httpx/pyproject.toml +++ b/e2e/clients/httpx/pyproject.toml @@ -5,7 +5,7 @@ description = "Python httpx client for x402 e2e tests" requires-python = ">=3.10" dependencies = [ "python-dotenv>=1.0.0", - "bankofai.x402[httpx,evm,svm,extensions]" + "bankofai.x402[httpx,evm,svm,tron,extensions]" ] [build-system] diff --git a/e2e/clients/httpx/test.config.json b/e2e/clients/httpx/test.config.json index fcdeb61f..064fbcf5 100644 --- a/e2e/clients/httpx/test.config.json +++ b/e2e/clients/httpx/test.config.json @@ -4,14 +4,18 @@ "language": "python", "protocolFamilies": [ "evm", - "svm" + "svm", + "tron" ], "x402Versions": [ 1, 2 ], "evm": { - "transferMethods": ["eip3009"] + "transferMethods": ["eip3009", "permit2"] + }, + "tron": { + "transferMethods": ["transferWithAuthorization", "permit2"] }, "description": "Python httpx client with x402 v2 payment hooks", "environment": { @@ -21,7 +25,9 @@ ], "optional": [ "EVM_PRIVATE_KEY", - "SVM_PRIVATE_KEY" + "SVM_PRIVATE_KEY", + "TRON_PRIVATE_KEY", + "TRON_RPC_URL" ] } -} \ No newline at end of file +} diff --git a/e2e/clients/httpx/uv.lock b/e2e/clients/httpx/uv.lock index 23ab287f..22cbdd0f 100644 --- a/e2e/clients/httpx/uv.lock +++ b/e2e/clients/httpx/uv.lock @@ -1,5 +1,9 @@ version = 1 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version == '3.14.*'", + "python_full_version != '3.14.*'", +] [[package]] name = "aiohappyeyeballs" @@ -166,6 +170,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362 }, ] +[[package]] +name = "asn1crypto" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045 }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -212,13 +225,18 @@ svm = [ { name = "solana" }, { name = "solders" }, ] +tron = [ + { name = "base58" }, + { name = "tronpy" }, +] [package.metadata] requires-dist = [ - { name = "bankofai-x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, { name = "bankofai-x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "bankofai-x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "bankofai-x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "bankofai-x402", extras = ["httpx", "requests", "flask", "fastapi", "tron", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "bankofai-x402", extras = ["tron", "evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "base58", marker = "extra == 'tron'", specifier = ">=2.1.1" }, { name = "eth-abi", marker = "extra == 'evm'", specifier = ">=5.0.0" }, { name = "eth-account", marker = "extra == 'evm'", specifier = ">=0.12.0" }, { name = "eth-keys", marker = "extra == 'evm'", specifier = ">=0.5.0" }, @@ -234,6 +252,7 @@ requires-dist = [ { 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 = "tronpy", marker = "extra == 'tron'", specifier = ">=0.6.0,<0.7.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, ] @@ -268,16 +287,25 @@ name = "bankofai-x402-httpx-e2e" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "bankofai-x402", extra = ["evm", "extensions", "httpx", "svm"] }, + { name = "bankofai-x402", extra = ["evm", "extensions", "httpx", "svm", "tron"] }, { name = "python-dotenv" }, ] [package.metadata] requires-dist = [ - { name = "bankofai-x402", extras = ["httpx", "evm", "svm", "extensions"], editable = "../../../python/x402" }, + { name = "bankofai-x402", extras = ["httpx", "evm", "svm", "tron", "extensions"], editable = "../../../python/x402" }, { name = "python-dotenv", specifier = ">=1.0.0" }, ] +[[package]] +name = "base58" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/45/8ae61209bb9015f516102fa559a2914178da1d5868428bd86a1b4421141d/base58-2.1.1.tar.gz", hash = "sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c", size = 6528 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/45/ec96b29162a402fc4c1c5512d114d7b3787b9d1c2ec241d9568b4816ee23/base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2", size = 5621 }, +] + [[package]] name = "bitarray" version = "3.8.0" @@ -373,6 +401,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438 }, ] +[[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 } +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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184 }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -548,6 +658,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/40/f259e2bf986d39717427bc12baa8189cd43f9675e81cd3bcab639e593614/ckzg-2.1.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:df66d2be54d91f74aded4ceb71e7b1f789e2636a3015f438904a22ec9de750f1", size = 101018 }, ] +[[package]] +name = "coincurve" +version = "20.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asn1crypto" }, + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/4c/9e5e51e6c12cec6444c86697992f9c6ccffa19f84d042ff939c8b89206ff/coincurve-20.0.0.tar.gz", hash = "sha256:872419e404300302e938849b6b92a196fabdad651060b559dc310e52f8392829", size = 122865 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/0c/f6a8b06f461089aeab441824134ea5d5824dba3acaac0a9dbf8444cbe1d6/coincurve-20.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d559b22828638390118cae9372a1bb6f6594f5584c311deb1de6a83163a0919b", size = 1255634 }, + { url = "https://files.pythonhosted.org/packages/62/c2/0dbabd2c6648f49f730fdcbba84c53b5ffaf452fca85c750633141fe049c/coincurve-20.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33d7f6ebd90fcc550f819f7f2cce2af525c342aac07f0ccda46ad8956ad9d99b", size = 1255532 }, + { url = "https://files.pythonhosted.org/packages/f5/77/c4fa50f8cb5d050a9bcab806503acdd1705b0dfb5c554eed15cc18bc12e8/coincurve-20.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22d70dd55d13fd427418eb41c20fde0a20a5e5f016e2b1bb94710701e759e7e0", size = 1191928 }, + { url = "https://files.pythonhosted.org/packages/a3/11/6254ea354a32a3a1d70722daf58f2ebf0f689f0940eaced5127233416553/coincurve-20.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46f18d481eaae72c169f334cde1fd22011a884e0c9c6adc3fdc1fd13df8236a3", size = 1194364 }, + { url = "https://files.pythonhosted.org/packages/a7/a9/d8717d41eb02688691adc30d7348f7c5fdc78e977f4cea83ee84622050b5/coincurve-20.0.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9de1ec57f43c3526bc462be58fb97910dc1fdd5acab6c71eda9f9719a5bd7489", size = 1204658 }, + { url = "https://files.pythonhosted.org/packages/55/13/40923832d99c18fb01a00f83e5f6f702156e71cc0eb5d6281535eee662af/coincurve-20.0.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a6f007c44c726b5c0b3724093c0d4fb8e294f6b6869beb02d7473b21777473a3", size = 1215298 }, + { url = "https://files.pythonhosted.org/packages/4d/87/646462a7a7810c7a3dcadae8969e1b78d535bcff072c26b17588e93a39b8/coincurve-20.0.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0ff1f3b81330db5092c24da2102e4fcba5094f14945b3eb40746456ceabdd6d9", size = 1204504 }, + { url = "https://files.pythonhosted.org/packages/01/58/fbb9a312d559aee701491435b691e409fb0efa12eabf269ff651d537fed4/coincurve-20.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:82f7de97694d9343f26bd1c8e081b168e5f525894c12445548ce458af227f536", size = 1209298 }, + { url = "https://files.pythonhosted.org/packages/ee/d0/1d5679c000b31f3b32512632d98571f2bb752cd25c127d6f5bf3711b6eae/coincurve-20.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:e905b4b084b4f3b61e5a5d58ac2632fd1d07b7b13b4c6d778335a6ca1dafd7a3", size = 1198934 }, + { url = "https://files.pythonhosted.org/packages/a3/f6/8c1499f730fac49ec13740fb1c015ce8082fa6b917790056988559f22212/coincurve-20.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:3657bb5ed0baf1cf8cf356e7d44aa90a7902cc3dd4a435c6d4d0bed0553ad4f7", size = 1193319 }, + { url = "https://files.pythonhosted.org/packages/24/a7/d60a41b3f0a546854c9b7ca65ab99a5fdf1c9e158ae264a580de8f23fd1c/coincurve-20.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:44087d1126d43925bf9a2391ce5601bf30ce0dba4466c239172dc43226696018", size = 1255635 }, + { url = "https://files.pythonhosted.org/packages/b7/4a/727fab66c0fbecfd7beeb38467910bd3652a77df649565e30160a9d2bae2/coincurve-20.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ccf0ba38b0f307a9b3ce28933f6c71dc12ef3a0985712ca09f48591afd597c8", size = 1255536 }, + { url = "https://files.pythonhosted.org/packages/0f/8b/25d4ae5bb60665023e6d71681fada88ee95b5010dae6fc0b44d8b23b8df1/coincurve-20.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:566bc5986debdf8572b6be824fd4de03d533c49f3de778e29f69017ae3fe82d8", size = 1191928 }, + { url = "https://files.pythonhosted.org/packages/0d/86/8c32c512fa27bfe7cfe70329fd43ebac23c0c8cec202cf6e4f52854e7ce3/coincurve-20.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4d70283168e146f025005c15406086513d5d35e89a60cf4326025930d45013a", size = 1194365 }, + { url = "https://files.pythonhosted.org/packages/fe/74/fefbe512f54df7d02a7ea4821b87cf199a91b3565cdf0c94448b3f6b1af1/coincurve-20.0.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:763c6122dd7d5e7a81c86414ce360dbe9a2d4afa1ca6c853ee03d63820b3d0c5", size = 1204658 }, + { url = "https://files.pythonhosted.org/packages/09/68/05b29f881f628ce8e8468f5f7420f6c4d7c129f43964e81d15bf388ae67a/coincurve-20.0.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f00c361c356bcea386d47a191bb8ac60429f4b51c188966a201bfecaf306ff7f", size = 1215301 }, + { url = "https://files.pythonhosted.org/packages/ee/5d/d91549cf5a163797b0724dc2dcd551b908b6beddb6598b37743df7f6f3ec/coincurve-20.0.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4af57bdadd2e64d117dd0b33cfefe76e90c7a6c496a7b034fc65fd01ec249b15", size = 1204505 }, + { url = "https://files.pythonhosted.org/packages/37/0f/898022e08760fb57d281f3695576e859b0f8a8ac629670223d9066c3f60d/coincurve-20.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a26437b7cbde13fb6e09261610b788ca2a0ca2195c62030afd1e1e0d1a62e035", size = 1209305 }, + { url = "https://files.pythonhosted.org/packages/57/b9/643567d3f680ddf8d1bf10a56112ae7755296500d8eaaef498be637a8533/coincurve-20.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:ed51f8bba35e6c7676ad65539c3dbc35acf014fc402101fa24f6b0a15a74ab9e", size = 1198932 }, + { url = "https://files.pythonhosted.org/packages/b3/3a/898f5c12469b292042608dd0702bcb0420ec32bac6b1ca2a0dd790f922bd/coincurve-20.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:594b840fc25d74118407edbbbc754b815f1bba9759dbf4f67f1c2b78396df2d3", size = 1193318 }, + { url = "https://files.pythonhosted.org/packages/8f/24/e1bf259dd57186fbdc7cec51909db320884162cfad5ec72cbaa63573ff9d/coincurve-20.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4df4416a6c0370d777aa725a25b14b04e45aa228da1251c258ff91444643f688", size = 1255671 }, + { url = "https://files.pythonhosted.org/packages/0a/c5/1817f87d1cd5ff50d8537fe60fb96f66b76dd02da885d970952e6189a801/coincurve-20.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1ccc3e4db55abf3fc0e604a187fdb05f0702bc5952e503d9a75f4ae6eeb4cb3a", size = 1255565 }, + { url = "https://files.pythonhosted.org/packages/90/9f/35e15f993717ed1dcc4c26d9771f073a1054af26808a0f421783bb4cd7e0/coincurve-20.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac8335b1658a2ef5b3eb66d52647742fe8c6f413ad5b9d5310d7ea6d8060d40f", size = 1191953 }, + { url = "https://files.pythonhosted.org/packages/4a/3d/6a9bc32e69b738b5e05f5027bace1da6722352a4a447e495d3c03a601d99/coincurve-20.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7ac025e485a0229fd5394e0bf6b4a75f8a4f6cee0dcf6f0b01a2ef05c5210ff", size = 1194425 }, + { url = "https://files.pythonhosted.org/packages/1a/a6/15424973dc47fc7c87e3c0f8859f6f1b1032582ee9f1b85fdd5d1e33d630/coincurve-20.0.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e46e3f1c21b3330857bcb1a3a5b942f645c8bce912a8a2b252216f34acfe4195", size = 1204678 }, + { url = "https://files.pythonhosted.org/packages/6a/e7/71ddb4d66c11c4ad13e729362f8852e048ae452eba3dfcf57751842bb292/coincurve-20.0.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:df9ff9b17a1d27271bf476cf3fa92df4c151663b11a55d8cea838b8f88d83624", size = 1215395 }, + { url = "https://files.pythonhosted.org/packages/b9/7d/03e0a19cfff1d86f5d019afc69cfbff02caada701ed5a4a50abc63d4261c/coincurve-20.0.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4155759f071375699282e03b3d95fb473ee05c022641c077533e0d906311e57a", size = 1204552 }, + { url = "https://files.pythonhosted.org/packages/07/cd/e9bd4ca7d931653a35c74194da04191a9aecc54b8f48a554cd538dc810e4/coincurve-20.0.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0530b9dd02fc6f6c2916716974b79bdab874227f560c422801ade290e3fc5013", size = 1209392 }, + { url = "https://files.pythonhosted.org/packages/99/54/260053f14f74b99b645084231e1c76994134ded49407a3bba23a8ffc0ff6/coincurve-20.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:eacf9c0ce8739c84549a89c083b1f3526c8780b84517ee75d6b43d276e55f8a0", size = 1198932 }, + { url = "https://files.pythonhosted.org/packages/b4/b5/c465e09345dd38b9415f5d47ae7683b3f461db02fcc03e699b6b5687ab2b/coincurve-20.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:52a67bfddbd6224dfa42085c88ad176559801b57d6a8bd30d92ee040de88b7b3", size = 1193324 }, +] + [[package]] name = "construct" version = "2.10.70" @@ -1349,6 +1501,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305 }, ] +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + [[package]] name = "pycryptodome" version = "3.23.0" @@ -1864,6 +2025,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093 }, ] +[[package]] +name = "tronpy" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "base58" }, + { name = "coincurve" }, + { name = "eth-abi" }, + { name = "httpx" }, + { name = "pycryptodome" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/dd/7ef2690011ae8bf158c6ebf1b9a0c9b182d6c3caf53163733af9355bf000/tronpy-0.6.2.tar.gz", hash = "sha256:758bfc4f88d1e332d35b5e9e1d31173a40d8a91d2c177c218d7768f03973d45b", size = 206494 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/cf/2e4f5bfefcfc9097c9fe4d8301d47ed0aeb35e91ca21ae70350d8ca02c51/tronpy-0.6.2-py3-none-any.whl", hash = "sha256:1d3f8595d372d3f0077a1f5e0a3869cd9edc58fca7dc54b1720d1ff26ade0b7a", size = 51111 }, +] + [[package]] name = "types-requests" version = "2.32.4.20260107" diff --git a/e2e/clients/mcp-python/uv.lock b/e2e/clients/mcp-python/uv.lock index 394669e2..68de74a2 100644 --- a/e2e/clients/mcp-python/uv.lock +++ b/e2e/clients/mcp-python/uv.lock @@ -208,10 +208,11 @@ mcp = [ [package.metadata] requires-dist = [ - { name = "bankofai-x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, { name = "bankofai-x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "bankofai-x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "bankofai-x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "bankofai-x402", extras = ["httpx", "requests", "flask", "fastapi", "tron", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "bankofai-x402", extras = ["tron", "evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "base58", marker = "extra == 'tron'", specifier = ">=2.1.1" }, { name = "eth-abi", marker = "extra == 'evm'", specifier = ">=5.0.0" }, { name = "eth-account", marker = "extra == 'evm'", specifier = ">=0.12.0" }, { name = "eth-keys", marker = "extra == 'evm'", specifier = ">=0.5.0" }, @@ -227,6 +228,7 @@ requires-dist = [ { 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 = "tronpy", marker = "extra == 'tron'", specifier = ">=0.6.0,<0.7.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, ] diff --git a/e2e/clients/requests/main.py b/e2e/clients/requests/main.py index 654611d3..17f43e63 100644 --- a/e2e/clients/requests/main.py +++ b/e2e/clients/requests/main.py @@ -13,6 +13,8 @@ from bankofai.x402.mechanisms.evm.exact import register_exact_evm_client from bankofai.x402.mechanisms.svm import KeypairSigner from bankofai.x402.mechanisms.svm.exact import register_exact_svm_client +from bankofai.x402.mechanisms.tron.signers import ClientTronSigner +from bankofai.x402.mechanisms.tron.exact import register_exact_tron_client # Load environment variables load_dotenv() @@ -20,6 +22,8 @@ # Get environment variables evm_private_key = os.getenv("EVM_PRIVATE_KEY") svm_private_key = os.getenv("SVM_PRIVATE_KEY") +tron_private_key = os.getenv("TRON_PRIVATE_KEY") +tron_rpc_url = os.getenv("TRON_RPC_URL") base_url = os.getenv("RESOURCE_SERVER_URL") endpoint_path = os.getenv("ENDPOINT_PATH") @@ -52,6 +56,14 @@ def main(): svm_signer = KeypairSigner.from_base58(svm_private_key) register_exact_svm_client(client, svm_signer) + # Register TRON exact scheme if private key is available + if tron_private_key: + tron_signer = ClientTronSigner( + private_key=tron_private_key, + full_node=tron_rpc_url or "https://nile.trongrid.io", + ) + register_exact_tron_client(client, tron_signer) + # Create a session with x402 payment handling session = x402_requests(client) diff --git a/e2e/clients/requests/pyproject.toml b/e2e/clients/requests/pyproject.toml index 70d362b8..0f0f30d2 100644 --- a/e2e/clients/requests/pyproject.toml +++ b/e2e/clients/requests/pyproject.toml @@ -5,7 +5,7 @@ description = "Python requests client for x402 e2e tests" requires-python = ">=3.10" dependencies = [ "python-dotenv>=1.0.0", - "bankofai.x402[requests,evm,svm,extensions]" + "bankofai.x402[requests,evm,svm,tron,extensions]" ] [build-system] diff --git a/e2e/clients/requests/test.config.json b/e2e/clients/requests/test.config.json index 009e3eb0..9258aa42 100644 --- a/e2e/clients/requests/test.config.json +++ b/e2e/clients/requests/test.config.json @@ -4,14 +4,18 @@ "language": "python", "protocolFamilies": [ "evm", - "svm" + "svm", + "tron" ], "x402Versions": [ 1, 2 ], "evm": { - "transferMethods": ["eip3009"] + "transferMethods": ["eip3009", "permit2"] + }, + "tron": { + "transferMethods": ["transferWithAuthorization", "permit2"] }, "description": "Python requests client with x402 v2 HTTP adapter", "environment": { @@ -21,7 +25,9 @@ ], "optional": [ "EVM_PRIVATE_KEY", - "SVM_PRIVATE_KEY" + "SVM_PRIVATE_KEY", + "TRON_PRIVATE_KEY", + "TRON_RPC_URL" ] } -} \ No newline at end of file +} diff --git a/e2e/clients/requests/uv.lock b/e2e/clients/requests/uv.lock index ddb812b8..33ca65e0 100644 --- a/e2e/clients/requests/uv.lock +++ b/e2e/clients/requests/uv.lock @@ -1,5 +1,9 @@ version = 1 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version == '3.14.*'", + "python_full_version != '3.14.*'", +] [[package]] name = "aiohappyeyeballs" @@ -166,6 +170,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 }, ] +[[package]] +name = "asn1crypto" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045 }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -212,13 +225,18 @@ svm = [ { name = "solana" }, { name = "solders" }, ] +tron = [ + { name = "base58" }, + { name = "tronpy" }, +] [package.metadata] requires-dist = [ - { name = "bankofai-x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, { name = "bankofai-x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "bankofai-x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "bankofai-x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "bankofai-x402", extras = ["httpx", "requests", "flask", "fastapi", "tron", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "bankofai-x402", extras = ["tron", "evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "base58", marker = "extra == 'tron'", specifier = ">=2.1.1" }, { name = "eth-abi", marker = "extra == 'evm'", specifier = ">=5.0.0" }, { name = "eth-account", marker = "extra == 'evm'", specifier = ">=0.12.0" }, { name = "eth-keys", marker = "extra == 'evm'", specifier = ">=0.5.0" }, @@ -234,6 +252,7 @@ requires-dist = [ { 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 = "tronpy", marker = "extra == 'tron'", specifier = ">=0.6.0,<0.7.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, ] @@ -268,16 +287,25 @@ name = "bankofai-x402-requests-e2e" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "bankofai-x402", extra = ["evm", "extensions", "requests", "svm"] }, + { name = "bankofai-x402", extra = ["evm", "extensions", "requests", "svm", "tron"] }, { name = "python-dotenv" }, ] [package.metadata] requires-dist = [ - { name = "bankofai-x402", extras = ["requests", "evm", "svm", "extensions"], editable = "../../../python/x402" }, + { name = "bankofai-x402", extras = ["requests", "evm", "svm", "tron", "extensions"], editable = "../../../python/x402" }, { name = "python-dotenv", specifier = ">=1.0.0" }, ] +[[package]] +name = "base58" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/45/8ae61209bb9015f516102fa559a2914178da1d5868428bd86a1b4421141d/base58-2.1.1.tar.gz", hash = "sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c", size = 6528 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/45/ec96b29162a402fc4c1c5512d114d7b3787b9d1c2ec241d9568b4816ee23/base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2", size = 5621 }, +] + [[package]] name = "bitarray" version = "3.8.0" @@ -373,6 +401,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438 }, ] +[[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 } +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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184 }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -548,6 +658,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/40/f259e2bf986d39717427bc12baa8189cd43f9675e81cd3bcab639e593614/ckzg-2.1.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:df66d2be54d91f74aded4ceb71e7b1f789e2636a3015f438904a22ec9de750f1", size = 101018 }, ] +[[package]] +name = "coincurve" +version = "20.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asn1crypto" }, + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/4c/9e5e51e6c12cec6444c86697992f9c6ccffa19f84d042ff939c8b89206ff/coincurve-20.0.0.tar.gz", hash = "sha256:872419e404300302e938849b6b92a196fabdad651060b559dc310e52f8392829", size = 122865 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/0c/f6a8b06f461089aeab441824134ea5d5824dba3acaac0a9dbf8444cbe1d6/coincurve-20.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d559b22828638390118cae9372a1bb6f6594f5584c311deb1de6a83163a0919b", size = 1255634 }, + { url = "https://files.pythonhosted.org/packages/62/c2/0dbabd2c6648f49f730fdcbba84c53b5ffaf452fca85c750633141fe049c/coincurve-20.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33d7f6ebd90fcc550f819f7f2cce2af525c342aac07f0ccda46ad8956ad9d99b", size = 1255532 }, + { url = "https://files.pythonhosted.org/packages/f5/77/c4fa50f8cb5d050a9bcab806503acdd1705b0dfb5c554eed15cc18bc12e8/coincurve-20.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22d70dd55d13fd427418eb41c20fde0a20a5e5f016e2b1bb94710701e759e7e0", size = 1191928 }, + { url = "https://files.pythonhosted.org/packages/a3/11/6254ea354a32a3a1d70722daf58f2ebf0f689f0940eaced5127233416553/coincurve-20.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46f18d481eaae72c169f334cde1fd22011a884e0c9c6adc3fdc1fd13df8236a3", size = 1194364 }, + { url = "https://files.pythonhosted.org/packages/a7/a9/d8717d41eb02688691adc30d7348f7c5fdc78e977f4cea83ee84622050b5/coincurve-20.0.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9de1ec57f43c3526bc462be58fb97910dc1fdd5acab6c71eda9f9719a5bd7489", size = 1204658 }, + { url = "https://files.pythonhosted.org/packages/55/13/40923832d99c18fb01a00f83e5f6f702156e71cc0eb5d6281535eee662af/coincurve-20.0.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a6f007c44c726b5c0b3724093c0d4fb8e294f6b6869beb02d7473b21777473a3", size = 1215298 }, + { url = "https://files.pythonhosted.org/packages/4d/87/646462a7a7810c7a3dcadae8969e1b78d535bcff072c26b17588e93a39b8/coincurve-20.0.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0ff1f3b81330db5092c24da2102e4fcba5094f14945b3eb40746456ceabdd6d9", size = 1204504 }, + { url = "https://files.pythonhosted.org/packages/01/58/fbb9a312d559aee701491435b691e409fb0efa12eabf269ff651d537fed4/coincurve-20.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:82f7de97694d9343f26bd1c8e081b168e5f525894c12445548ce458af227f536", size = 1209298 }, + { url = "https://files.pythonhosted.org/packages/ee/d0/1d5679c000b31f3b32512632d98571f2bb752cd25c127d6f5bf3711b6eae/coincurve-20.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:e905b4b084b4f3b61e5a5d58ac2632fd1d07b7b13b4c6d778335a6ca1dafd7a3", size = 1198934 }, + { url = "https://files.pythonhosted.org/packages/a3/f6/8c1499f730fac49ec13740fb1c015ce8082fa6b917790056988559f22212/coincurve-20.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:3657bb5ed0baf1cf8cf356e7d44aa90a7902cc3dd4a435c6d4d0bed0553ad4f7", size = 1193319 }, + { url = "https://files.pythonhosted.org/packages/24/a7/d60a41b3f0a546854c9b7ca65ab99a5fdf1c9e158ae264a580de8f23fd1c/coincurve-20.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:44087d1126d43925bf9a2391ce5601bf30ce0dba4466c239172dc43226696018", size = 1255635 }, + { url = "https://files.pythonhosted.org/packages/b7/4a/727fab66c0fbecfd7beeb38467910bd3652a77df649565e30160a9d2bae2/coincurve-20.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ccf0ba38b0f307a9b3ce28933f6c71dc12ef3a0985712ca09f48591afd597c8", size = 1255536 }, + { url = "https://files.pythonhosted.org/packages/0f/8b/25d4ae5bb60665023e6d71681fada88ee95b5010dae6fc0b44d8b23b8df1/coincurve-20.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:566bc5986debdf8572b6be824fd4de03d533c49f3de778e29f69017ae3fe82d8", size = 1191928 }, + { url = "https://files.pythonhosted.org/packages/0d/86/8c32c512fa27bfe7cfe70329fd43ebac23c0c8cec202cf6e4f52854e7ce3/coincurve-20.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4d70283168e146f025005c15406086513d5d35e89a60cf4326025930d45013a", size = 1194365 }, + { url = "https://files.pythonhosted.org/packages/fe/74/fefbe512f54df7d02a7ea4821b87cf199a91b3565cdf0c94448b3f6b1af1/coincurve-20.0.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:763c6122dd7d5e7a81c86414ce360dbe9a2d4afa1ca6c853ee03d63820b3d0c5", size = 1204658 }, + { url = "https://files.pythonhosted.org/packages/09/68/05b29f881f628ce8e8468f5f7420f6c4d7c129f43964e81d15bf388ae67a/coincurve-20.0.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f00c361c356bcea386d47a191bb8ac60429f4b51c188966a201bfecaf306ff7f", size = 1215301 }, + { url = "https://files.pythonhosted.org/packages/ee/5d/d91549cf5a163797b0724dc2dcd551b908b6beddb6598b37743df7f6f3ec/coincurve-20.0.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4af57bdadd2e64d117dd0b33cfefe76e90c7a6c496a7b034fc65fd01ec249b15", size = 1204505 }, + { url = "https://files.pythonhosted.org/packages/37/0f/898022e08760fb57d281f3695576e859b0f8a8ac629670223d9066c3f60d/coincurve-20.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a26437b7cbde13fb6e09261610b788ca2a0ca2195c62030afd1e1e0d1a62e035", size = 1209305 }, + { url = "https://files.pythonhosted.org/packages/57/b9/643567d3f680ddf8d1bf10a56112ae7755296500d8eaaef498be637a8533/coincurve-20.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:ed51f8bba35e6c7676ad65539c3dbc35acf014fc402101fa24f6b0a15a74ab9e", size = 1198932 }, + { url = "https://files.pythonhosted.org/packages/b3/3a/898f5c12469b292042608dd0702bcb0420ec32bac6b1ca2a0dd790f922bd/coincurve-20.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:594b840fc25d74118407edbbbc754b815f1bba9759dbf4f67f1c2b78396df2d3", size = 1193318 }, + { url = "https://files.pythonhosted.org/packages/8f/24/e1bf259dd57186fbdc7cec51909db320884162cfad5ec72cbaa63573ff9d/coincurve-20.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4df4416a6c0370d777aa725a25b14b04e45aa228da1251c258ff91444643f688", size = 1255671 }, + { url = "https://files.pythonhosted.org/packages/0a/c5/1817f87d1cd5ff50d8537fe60fb96f66b76dd02da885d970952e6189a801/coincurve-20.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1ccc3e4db55abf3fc0e604a187fdb05f0702bc5952e503d9a75f4ae6eeb4cb3a", size = 1255565 }, + { url = "https://files.pythonhosted.org/packages/90/9f/35e15f993717ed1dcc4c26d9771f073a1054af26808a0f421783bb4cd7e0/coincurve-20.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac8335b1658a2ef5b3eb66d52647742fe8c6f413ad5b9d5310d7ea6d8060d40f", size = 1191953 }, + { url = "https://files.pythonhosted.org/packages/4a/3d/6a9bc32e69b738b5e05f5027bace1da6722352a4a447e495d3c03a601d99/coincurve-20.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7ac025e485a0229fd5394e0bf6b4a75f8a4f6cee0dcf6f0b01a2ef05c5210ff", size = 1194425 }, + { url = "https://files.pythonhosted.org/packages/1a/a6/15424973dc47fc7c87e3c0f8859f6f1b1032582ee9f1b85fdd5d1e33d630/coincurve-20.0.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e46e3f1c21b3330857bcb1a3a5b942f645c8bce912a8a2b252216f34acfe4195", size = 1204678 }, + { url = "https://files.pythonhosted.org/packages/6a/e7/71ddb4d66c11c4ad13e729362f8852e048ae452eba3dfcf57751842bb292/coincurve-20.0.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:df9ff9b17a1d27271bf476cf3fa92df4c151663b11a55d8cea838b8f88d83624", size = 1215395 }, + { url = "https://files.pythonhosted.org/packages/b9/7d/03e0a19cfff1d86f5d019afc69cfbff02caada701ed5a4a50abc63d4261c/coincurve-20.0.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4155759f071375699282e03b3d95fb473ee05c022641c077533e0d906311e57a", size = 1204552 }, + { url = "https://files.pythonhosted.org/packages/07/cd/e9bd4ca7d931653a35c74194da04191a9aecc54b8f48a554cd538dc810e4/coincurve-20.0.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0530b9dd02fc6f6c2916716974b79bdab874227f560c422801ade290e3fc5013", size = 1209392 }, + { url = "https://files.pythonhosted.org/packages/99/54/260053f14f74b99b645084231e1c76994134ded49407a3bba23a8ffc0ff6/coincurve-20.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:eacf9c0ce8739c84549a89c083b1f3526c8780b84517ee75d6b43d276e55f8a0", size = 1198932 }, + { url = "https://files.pythonhosted.org/packages/b4/b5/c465e09345dd38b9415f5d47ae7683b3f461db02fcc03e699b6b5687ab2b/coincurve-20.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:52a67bfddbd6224dfa42085c88ad176559801b57d6a8bd30d92ee040de88b7b3", size = 1193324 }, +] + [[package]] name = "construct" version = "2.10.70" @@ -1349,6 +1501,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305 }, ] +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + [[package]] name = "pycryptodome" version = "3.23.0" @@ -1864,6 +2025,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093 }, ] +[[package]] +name = "tronpy" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "base58" }, + { name = "coincurve" }, + { name = "eth-abi" }, + { name = "httpx" }, + { name = "pycryptodome" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/dd/7ef2690011ae8bf158c6ebf1b9a0c9b182d6c3caf53163733af9355bf000/tronpy-0.6.2.tar.gz", hash = "sha256:758bfc4f88d1e332d35b5e9e1d31173a40d8a91d2c177c218d7768f03973d45b", size = 206494 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/cf/2e4f5bfefcfc9097c9fe4d8301d47ed0aeb35e91ca21ae70350d8ca02c51/tronpy-0.6.2-py3-none-any.whl", hash = "sha256:1d3f8595d372d3f0077a1f5e0a3869cd9edc58fca7dc54b1720d1ff26ade0b7a", size = 51111 }, +] + [[package]] name = "types-requests" version = "2.32.4.20260107" diff --git a/e2e/facilitators/python/main.py b/e2e/facilitators/python/main.py index 1e82d86f..59a6d45c 100644 --- a/e2e/facilitators/python/main.py +++ b/e2e/facilitators/python/main.py @@ -28,6 +28,11 @@ from bankofai.x402.mechanisms.evm.exact import register_exact_evm_facilitator from bankofai.x402.mechanisms.svm import FacilitatorKeypairSigner from bankofai.x402.mechanisms.svm.exact import register_exact_svm_facilitator +from bankofai.x402.mechanisms.tron.signers import FacilitatorTronSigner +from bankofai.x402.mechanisms.tron.exact import register_exact_tron_facilitator +from bankofai.x402.extensions.trc20_approval_gas_sponsoring import ( + create_trc20_approval_gas_sponsoring_extension, +) from bazaar import BazaarCatalog @@ -47,6 +52,8 @@ if not os.environ.get("SVM_PRIVATE_KEY"): print("⚠️ SVM_PRIVATE_KEY not set — SVM payment support disabled") +if not os.environ.get("TRON_PRIVATE_KEY"): + print("⚠️ TRON_PRIVATE_KEY not set — TRON payment support disabled") # Initialize the EVM signer from private key evm_rpc_url = os.environ.get("EVM_RPC_URL") or "https://bsc-testnet-rpc.publicnode.com" @@ -63,6 +70,16 @@ svm_signer = FacilitatorKeypairSigner(svm_keypair) print(f"SVM Facilitator account: {svm_signer.get_addresses()[0]}") +# Initialize the TRON signer from private key (optional) +tron_signer = None +if os.environ.get("TRON_PRIVATE_KEY"): + tron_full_node = os.environ.get("TRON_RPC_URL") or "https://nile.trongrid.io" + tron_signer = FacilitatorTronSigner( + private_key=os.environ["TRON_PRIVATE_KEY"], + full_node=tron_full_node, + ) + print(f"TRON Facilitator account: {tron_signer.get_addresses()[0]}") + def _handle_after_verify(ctx: Any) -> None: """Handle after verify hook - extract discovery info and catalog.""" @@ -120,7 +137,7 @@ def _handle_after_verify(ctx: Any) -> None: register_exact_evm_facilitator( facilitator, evm_signer, - networks="eip155:97", # BSC Testnet + networks=os.environ.get("EVM_NETWORK", "eip155:97"), # BSC Testnet default deploy_erc4337_with_eip6492=True, ) @@ -132,6 +149,17 @@ def _handle_after_verify(ctx: Any) -> None: networks="solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", # Devnet ) +# Register TRON schemes (V1 and V2) if configured +if tron_signer: + facilitator.register_extension( + create_trc20_approval_gas_sponsoring_extension(tron_signer) + ) + register_exact_tron_facilitator( + facilitator, + tron_signer, + networks=os.environ.get("TRON_NETWORK", "tron:nile"), + ) + # Pydantic models for request/response class VerifyRequest(BaseModel): diff --git a/e2e/facilitators/python/pyproject.toml b/e2e/facilitators/python/pyproject.toml index a9785bb4..6af1caa1 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 = [ - "bankofai.x402[fastapi,evm,svm,extensions]", + "bankofai.x402[fastapi,evm,svm,tron,extensions]", "python-dotenv>=1.2.1", "uvicorn[standard]>=0.40.0", ] @@ -14,4 +14,3 @@ package = false [tool.uv.sources] "bankofai.x402" = { path = "../../../python/x402", editable = true } - diff --git a/e2e/facilitators/python/test.config.json b/e2e/facilitators/python/test.config.json index 028f8da2..4bd4af45 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", + "tron" ], "x402Versions": [ 1, @@ -14,7 +15,10 @@ "bazaar" ], "evm": { - "transferMethods": ["eip3009"] + "transferMethods": ["eip3009", "permit2"] + }, + "tron": { + "transferMethods": ["transferWithAuthorization", "permit2"] }, "environment": { "required": [ @@ -23,10 +27,12 @@ ], "optional": [ "SVM_PRIVATE_KEY", + "TRON_PRIVATE_KEY", "EVM_NETWORK", "SVM_NETWORK", - "EVM_RPC_URL" + "EVM_RPC_URL", + "TRON_NETWORK", + "TRON_RPC_URL" ] } } - diff --git a/e2e/facilitators/python/uv.lock b/e2e/facilitators/python/uv.lock index 245695a7..14690020 100644 --- a/e2e/facilitators/python/uv.lock +++ b/e2e/facilitators/python/uv.lock @@ -1,5 +1,9 @@ version = 1 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version == '3.14.*'", + "python_full_version != '3.14.*'", +] [[package]] name = "aiohappyeyeballs" @@ -175,6 +179,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 }, ] +[[package]] +name = "asn1crypto" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045 }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -222,13 +235,18 @@ svm = [ { name = "solana" }, { name = "solders" }, ] +tron = [ + { name = "base58" }, + { name = "tronpy" }, +] [package.metadata] requires-dist = [ - { name = "bankofai-x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, { name = "bankofai-x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "bankofai-x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "bankofai-x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "bankofai-x402", extras = ["httpx", "requests", "flask", "fastapi", "tron", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "bankofai-x402", extras = ["tron", "evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "base58", marker = "extra == 'tron'", specifier = ">=2.1.1" }, { name = "eth-abi", marker = "extra == 'evm'", specifier = ">=5.0.0" }, { name = "eth-account", marker = "extra == 'evm'", specifier = ">=0.12.0" }, { name = "eth-keys", marker = "extra == 'evm'", specifier = ">=0.5.0" }, @@ -244,6 +262,7 @@ requires-dist = [ { 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 = "tronpy", marker = "extra == 'tron'", specifier = ">=0.6.0,<0.7.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, ] @@ -278,18 +297,27 @@ name = "bankofai-x402-e2e-facilitator-python" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "bankofai-x402", extra = ["evm", "extensions", "fastapi", "svm"] }, + { name = "bankofai-x402", extra = ["evm", "extensions", "fastapi", "svm", "tron"] }, { name = "python-dotenv" }, { name = "uvicorn", extra = ["standard"] }, ] [package.metadata] requires-dist = [ - { name = "bankofai-x402", extras = ["fastapi", "evm", "svm", "extensions"], editable = "../../../python/x402" }, + { name = "bankofai-x402", extras = ["fastapi", "evm", "svm", "tron", "extensions"], editable = "../../../python/x402" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" }, ] +[[package]] +name = "base58" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/45/8ae61209bb9015f516102fa559a2914178da1d5868428bd86a1b4421141d/base58-2.1.1.tar.gz", hash = "sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c", size = 6528 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/45/ec96b29162a402fc4c1c5512d114d7b3787b9d1c2ec241d9568b4816ee23/base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2", size = 5621 }, +] + [[package]] name = "bitarray" version = "3.8.0" @@ -385,6 +413,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900 }, ] +[[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 } +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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184 }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -572,6 +682,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 }, ] +[[package]] +name = "coincurve" +version = "20.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asn1crypto" }, + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/4c/9e5e51e6c12cec6444c86697992f9c6ccffa19f84d042ff939c8b89206ff/coincurve-20.0.0.tar.gz", hash = "sha256:872419e404300302e938849b6b92a196fabdad651060b559dc310e52f8392829", size = 122865 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/0c/f6a8b06f461089aeab441824134ea5d5824dba3acaac0a9dbf8444cbe1d6/coincurve-20.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d559b22828638390118cae9372a1bb6f6594f5584c311deb1de6a83163a0919b", size = 1255634 }, + { url = "https://files.pythonhosted.org/packages/62/c2/0dbabd2c6648f49f730fdcbba84c53b5ffaf452fca85c750633141fe049c/coincurve-20.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33d7f6ebd90fcc550f819f7f2cce2af525c342aac07f0ccda46ad8956ad9d99b", size = 1255532 }, + { url = "https://files.pythonhosted.org/packages/f5/77/c4fa50f8cb5d050a9bcab806503acdd1705b0dfb5c554eed15cc18bc12e8/coincurve-20.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22d70dd55d13fd427418eb41c20fde0a20a5e5f016e2b1bb94710701e759e7e0", size = 1191928 }, + { url = "https://files.pythonhosted.org/packages/a3/11/6254ea354a32a3a1d70722daf58f2ebf0f689f0940eaced5127233416553/coincurve-20.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46f18d481eaae72c169f334cde1fd22011a884e0c9c6adc3fdc1fd13df8236a3", size = 1194364 }, + { url = "https://files.pythonhosted.org/packages/a7/a9/d8717d41eb02688691adc30d7348f7c5fdc78e977f4cea83ee84622050b5/coincurve-20.0.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9de1ec57f43c3526bc462be58fb97910dc1fdd5acab6c71eda9f9719a5bd7489", size = 1204658 }, + { url = "https://files.pythonhosted.org/packages/55/13/40923832d99c18fb01a00f83e5f6f702156e71cc0eb5d6281535eee662af/coincurve-20.0.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a6f007c44c726b5c0b3724093c0d4fb8e294f6b6869beb02d7473b21777473a3", size = 1215298 }, + { url = "https://files.pythonhosted.org/packages/4d/87/646462a7a7810c7a3dcadae8969e1b78d535bcff072c26b17588e93a39b8/coincurve-20.0.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0ff1f3b81330db5092c24da2102e4fcba5094f14945b3eb40746456ceabdd6d9", size = 1204504 }, + { url = "https://files.pythonhosted.org/packages/01/58/fbb9a312d559aee701491435b691e409fb0efa12eabf269ff651d537fed4/coincurve-20.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:82f7de97694d9343f26bd1c8e081b168e5f525894c12445548ce458af227f536", size = 1209298 }, + { url = "https://files.pythonhosted.org/packages/ee/d0/1d5679c000b31f3b32512632d98571f2bb752cd25c127d6f5bf3711b6eae/coincurve-20.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:e905b4b084b4f3b61e5a5d58ac2632fd1d07b7b13b4c6d778335a6ca1dafd7a3", size = 1198934 }, + { url = "https://files.pythonhosted.org/packages/a3/f6/8c1499f730fac49ec13740fb1c015ce8082fa6b917790056988559f22212/coincurve-20.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:3657bb5ed0baf1cf8cf356e7d44aa90a7902cc3dd4a435c6d4d0bed0553ad4f7", size = 1193319 }, + { url = "https://files.pythonhosted.org/packages/24/a7/d60a41b3f0a546854c9b7ca65ab99a5fdf1c9e158ae264a580de8f23fd1c/coincurve-20.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:44087d1126d43925bf9a2391ce5601bf30ce0dba4466c239172dc43226696018", size = 1255635 }, + { url = "https://files.pythonhosted.org/packages/b7/4a/727fab66c0fbecfd7beeb38467910bd3652a77df649565e30160a9d2bae2/coincurve-20.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ccf0ba38b0f307a9b3ce28933f6c71dc12ef3a0985712ca09f48591afd597c8", size = 1255536 }, + { url = "https://files.pythonhosted.org/packages/0f/8b/25d4ae5bb60665023e6d71681fada88ee95b5010dae6fc0b44d8b23b8df1/coincurve-20.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:566bc5986debdf8572b6be824fd4de03d533c49f3de778e29f69017ae3fe82d8", size = 1191928 }, + { url = "https://files.pythonhosted.org/packages/0d/86/8c32c512fa27bfe7cfe70329fd43ebac23c0c8cec202cf6e4f52854e7ce3/coincurve-20.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4d70283168e146f025005c15406086513d5d35e89a60cf4326025930d45013a", size = 1194365 }, + { url = "https://files.pythonhosted.org/packages/fe/74/fefbe512f54df7d02a7ea4821b87cf199a91b3565cdf0c94448b3f6b1af1/coincurve-20.0.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:763c6122dd7d5e7a81c86414ce360dbe9a2d4afa1ca6c853ee03d63820b3d0c5", size = 1204658 }, + { url = "https://files.pythonhosted.org/packages/09/68/05b29f881f628ce8e8468f5f7420f6c4d7c129f43964e81d15bf388ae67a/coincurve-20.0.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f00c361c356bcea386d47a191bb8ac60429f4b51c188966a201bfecaf306ff7f", size = 1215301 }, + { url = "https://files.pythonhosted.org/packages/ee/5d/d91549cf5a163797b0724dc2dcd551b908b6beddb6598b37743df7f6f3ec/coincurve-20.0.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4af57bdadd2e64d117dd0b33cfefe76e90c7a6c496a7b034fc65fd01ec249b15", size = 1204505 }, + { url = "https://files.pythonhosted.org/packages/37/0f/898022e08760fb57d281f3695576e859b0f8a8ac629670223d9066c3f60d/coincurve-20.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a26437b7cbde13fb6e09261610b788ca2a0ca2195c62030afd1e1e0d1a62e035", size = 1209305 }, + { url = "https://files.pythonhosted.org/packages/57/b9/643567d3f680ddf8d1bf10a56112ae7755296500d8eaaef498be637a8533/coincurve-20.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:ed51f8bba35e6c7676ad65539c3dbc35acf014fc402101fa24f6b0a15a74ab9e", size = 1198932 }, + { url = "https://files.pythonhosted.org/packages/b3/3a/898f5c12469b292042608dd0702bcb0420ec32bac6b1ca2a0dd790f922bd/coincurve-20.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:594b840fc25d74118407edbbbc754b815f1bba9759dbf4f67f1c2b78396df2d3", size = 1193318 }, + { url = "https://files.pythonhosted.org/packages/8f/24/e1bf259dd57186fbdc7cec51909db320884162cfad5ec72cbaa63573ff9d/coincurve-20.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4df4416a6c0370d777aa725a25b14b04e45aa228da1251c258ff91444643f688", size = 1255671 }, + { url = "https://files.pythonhosted.org/packages/0a/c5/1817f87d1cd5ff50d8537fe60fb96f66b76dd02da885d970952e6189a801/coincurve-20.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1ccc3e4db55abf3fc0e604a187fdb05f0702bc5952e503d9a75f4ae6eeb4cb3a", size = 1255565 }, + { url = "https://files.pythonhosted.org/packages/90/9f/35e15f993717ed1dcc4c26d9771f073a1054af26808a0f421783bb4cd7e0/coincurve-20.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac8335b1658a2ef5b3eb66d52647742fe8c6f413ad5b9d5310d7ea6d8060d40f", size = 1191953 }, + { url = "https://files.pythonhosted.org/packages/4a/3d/6a9bc32e69b738b5e05f5027bace1da6722352a4a447e495d3c03a601d99/coincurve-20.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7ac025e485a0229fd5394e0bf6b4a75f8a4f6cee0dcf6f0b01a2ef05c5210ff", size = 1194425 }, + { url = "https://files.pythonhosted.org/packages/1a/a6/15424973dc47fc7c87e3c0f8859f6f1b1032582ee9f1b85fdd5d1e33d630/coincurve-20.0.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e46e3f1c21b3330857bcb1a3a5b942f645c8bce912a8a2b252216f34acfe4195", size = 1204678 }, + { url = "https://files.pythonhosted.org/packages/6a/e7/71ddb4d66c11c4ad13e729362f8852e048ae452eba3dfcf57751842bb292/coincurve-20.0.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:df9ff9b17a1d27271bf476cf3fa92df4c151663b11a55d8cea838b8f88d83624", size = 1215395 }, + { url = "https://files.pythonhosted.org/packages/b9/7d/03e0a19cfff1d86f5d019afc69cfbff02caada701ed5a4a50abc63d4261c/coincurve-20.0.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4155759f071375699282e03b3d95fb473ee05c022641c077533e0d906311e57a", size = 1204552 }, + { url = "https://files.pythonhosted.org/packages/07/cd/e9bd4ca7d931653a35c74194da04191a9aecc54b8f48a554cd538dc810e4/coincurve-20.0.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0530b9dd02fc6f6c2916716974b79bdab874227f560c422801ade290e3fc5013", size = 1209392 }, + { url = "https://files.pythonhosted.org/packages/99/54/260053f14f74b99b645084231e1c76994134ded49407a3bba23a8ffc0ff6/coincurve-20.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:eacf9c0ce8739c84549a89c083b1f3526c8780b84517ee75d6b43d276e55f8a0", size = 1198932 }, + { url = "https://files.pythonhosted.org/packages/b4/b5/c465e09345dd38b9415f5d47ae7683b3f461db02fcc03e699b6b5687ab2b/coincurve-20.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:52a67bfddbd6224dfa42085c88ad176559801b57d6a8bd30d92ee040de88b7b3", size = 1193324 }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -1753,6 +1905,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305 }, ] +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + [[package]] name = "pycryptodome" version = "3.23.0" @@ -2619,6 +2780,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093 }, ] +[[package]] +name = "tronpy" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "base58" }, + { name = "coincurve" }, + { name = "eth-abi" }, + { name = "httpx" }, + { name = "pycryptodome" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/dd/7ef2690011ae8bf158c6ebf1b9a0c9b182d6c3caf53163733af9355bf000/tronpy-0.6.2.tar.gz", hash = "sha256:758bfc4f88d1e332d35b5e9e1d31173a40d8a91d2c177c218d7768f03973d45b", size = 206494 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/cf/2e4f5bfefcfc9097c9fe4d8301d47ed0aeb35e91ca21ae70350d8ca02c51/tronpy-0.6.2-py3-none-any.whl", hash = "sha256:1d3f8595d372d3f0077a1f5e0a3869cd9edc58fca7dc54b1720d1ff26ade0b7a", size = 51111 }, +] + [[package]] name = "typer" version = "0.21.1" diff --git a/e2e/facilitators/typescript/index.ts b/e2e/facilitators/typescript/index.ts index 86201922..3ec21609 100644 --- a/e2e/facilitators/typescript/index.ts +++ b/e2e/facilitators/typescript/index.ts @@ -44,9 +44,12 @@ import { ExactSvmSchemeV1 } from "@bankofai/x402-svm/exact/v1/facilitator"; import { NETWORKS as SVM_V1_NETWORKS } from "@bankofai/x402-svm/v1"; import { createEd25519Signer, type FacilitatorStellarSigner } from "@bankofai/x402-stellar"; import { ExactStellarScheme } from "@bankofai/x402-stellar/exact/facilitator"; +import { createFacilitatorTronSigner } from "@bankofai/x402-tron"; +import { registerExactTronScheme } from "@bankofai/x402-tron/exact/facilitator"; import crypto from "crypto"; import dotenv from "dotenv"; import express from "express"; +import { TronWeb } from "tronweb"; import { createWalletClient, http, publicActions, Chain } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { bscTestnet, base } from "viem/chains"; @@ -61,10 +64,13 @@ const SVM_NETWORK = process.env.SVM_NETWORK || "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; const APTOS_NETWORK = process.env.APTOS_NETWORK || "aptos:2"; const STELLAR_NETWORK = process.env.STELLAR_NETWORK || "stellar:testnet"; +const TRON_NETWORK = process.env.TRON_NETWORK || "tron:nile"; const EVM_RPC_URL = process.env.EVM_RPC_URL; const SVM_RPC_URL = process.env.SVM_RPC_URL; const APTOS_RPC_URL = process.env.APTOS_RPC_URL; const STELLAR_RPC_URL = process.env.STELLAR_RPC_URL; +const TRON_RPC_URL = process.env.TRON_RPC_URL; +const TRON_GRID_API_KEY = process.env.TRON_GRID_API_KEY; // Map CAIP-2 network IDs to viem chains function getEvmChain(network: string): Chain { @@ -81,10 +87,12 @@ console.log(`🌐 EVM Network: ${EVM_NETWORK}`); console.log(`🌐 SVM Network: ${SVM_NETWORK}`); console.log(`🌐 Aptos Network: ${APTOS_NETWORK}`); console.log(`🌐 Stellar Network: ${STELLAR_NETWORK}`); +console.log(`🌐 TRON Network: ${TRON_NETWORK}`); if (EVM_RPC_URL) console.log(`🌐 EVM RPC URL: ${EVM_RPC_URL}`); if (SVM_RPC_URL) console.log(`🌐 SVM RPC URL: ${SVM_RPC_URL}`); if (APTOS_RPC_URL) console.log(`🌐 Aptos RPC URL: ${APTOS_RPC_URL}`); if (STELLAR_RPC_URL) console.log(`🌐 Stellar RPC URL: ${STELLAR_RPC_URL}`); +if (TRON_RPC_URL) console.log(`🌐 TRON RPC URL: ${TRON_RPC_URL}`); // Validate required environment variables if (!process.env.EVM_PRIVATE_KEY) { @@ -95,6 +103,9 @@ if (!process.env.EVM_PRIVATE_KEY) { if (!process.env.SVM_PRIVATE_KEY) { console.warn("⚠️ SVM_PRIVATE_KEY not set — SVM payment support disabled"); } +if (!process.env.TRON_PRIVATE_KEY) { + console.warn("⚠️ TRON_PRIVATE_KEY not set — TRON payment support disabled"); +} // Initialize the EVM account from private key const evmAccount = privateKeyToAccount( @@ -132,6 +143,30 @@ if (process.env.STELLAR_PRIVATE_KEY) { console.info(`Stellar Facilitator account: ${stellarSigner.address}`); } +function resolveTronRpcUrl(network: string): string { + if (TRON_RPC_URL) { + return TRON_RPC_URL; + } + if (network === "tron:mainnet") { + return "https://api.trongrid.io"; + } + if (network === "tron:shasta") { + return "https://api.shasta.trongrid.io"; + } + return "https://nile.trongrid.io"; +} + +let tronSigner: ReturnType | undefined; +if (process.env.TRON_PRIVATE_KEY) { + const tronWeb = new TronWeb({ + fullHost: resolveTronRpcUrl(TRON_NETWORK), + privateKey: process.env.TRON_PRIVATE_KEY, + headers: TRON_GRID_API_KEY ? { "TRON-PRO-API-KEY": TRON_GRID_API_KEY } : undefined, + }); + tronSigner = createFacilitatorTronSigner(tronWeb, process.env.TRON_PRIVATE_KEY); + console.info(`TRON Facilitator account: ${tronSigner.address}`); +} + // Create a Viem client with both wallet and public capabilities const evmChain = getEvmChain(EVM_NETWORK); const viemClient = createWalletClient({ @@ -227,6 +262,12 @@ if (aptosSigner) { if (stellarSigner) { facilitator.register(STELLAR_NETWORK as Network, new ExactStellarScheme([stellarSigner])); } +if (tronSigner) { + registerExactTronScheme(facilitator, { + signer: tronSigner, + networks: TRON_NETWORK as Network, + }); +} facilitator .registerExtension(BAZAAR) @@ -427,6 +468,7 @@ app.get("/health", (req, res) => { svmNetwork: SVM_NETWORK, aptosNetwork: aptosAccount ? APTOS_NETWORK : "(not configured)", stellarNetwork: stellarSigner ? STELLAR_NETWORK : "(not configured)", + tronNetwork: tronSigner ? TRON_NETWORK : "(not configured)", facilitator: "typescript", version: "2.0.0", extensions: [BAZAAR.key], @@ -458,9 +500,11 @@ app.listen(parseInt(PORT), () => { ║ EVM Network: ${EVM_NETWORK} ║ ║ SVM Network: ${SVM_NETWORK} ║ ║ Aptos Network: ${APTOS_NETWORK} ║ +║ TRON Network: ${TRON_NETWORK} ║ ║ EVM Address: ${evmAccount.address} ║ ║ Aptos Address: ${aptosAccount ? aptosAccount.accountAddress.toStringLong().slice(0, 20) + "..." : "(not configured)"} ║ Stellar Address: ${stellarSigner ? stellarSigner.address : "(not configured)"} ║ +║ TRON Address: ${tronSigner ? tronSigner.address : "(not configured)"} ║ ║ Extensions: bazaar ║ ║ ║ ║ Endpoints: ║ diff --git a/e2e/facilitators/typescript/package.json b/e2e/facilitators/typescript/package.json index 9ae8c214..419aa768 100644 --- a/e2e/facilitators/typescript/package.json +++ b/e2e/facilitators/typescript/package.json @@ -20,9 +20,11 @@ "@bankofai/x402-evm": "workspace:*", "@bankofai/x402-extensions": "workspace:*", "@bankofai/x402-stellar": "workspace:*", + "@bankofai/x402-tron": "workspace:*", "@bankofai/x402-svm": "workspace:*", "dotenv": "^16.4.5", "express": "^4.19.2", + "tronweb": "^6.1.0", "viem": "^2.21.54" }, "devDependencies": { @@ -33,4 +35,4 @@ "tsx": "^4.19.2", "typescript": "^5.7.2" } -} \ No newline at end of file +} diff --git a/e2e/facilitators/typescript/test.config.json b/e2e/facilitators/typescript/test.config.json index c934d421..66382b37 100644 --- a/e2e/facilitators/typescript/test.config.json +++ b/e2e/facilitators/typescript/test.config.json @@ -6,7 +6,8 @@ "evm", "svm", "aptos", - "stellar" + "stellar", + "tron" ], "x402Versions": [ 1, @@ -23,12 +24,16 @@ "environment": { "required": [ "PORT", - "EVM_PRIVATE_KEY" + "EVM_PRIVATE_KEY", + "TRON_PRIVATE_KEY" ], "optional": [ "SVM_PRIVATE_KEY", "APTOS_PRIVATE_KEY", "STELLAR_PRIVATE_KEY", + "TRON_NETWORK", + "TRON_RPC_URL", + "TRON_GRID_API_KEY", "EVM_NETWORK", "SVM_NETWORK", "APTOS_NETWORK", diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml index 182899e0..7c10b6b9 100644 --- a/e2e/pnpm-lock.yaml +++ b/e2e/pnpm-lock.yaml @@ -1363,6 +1363,9 @@ importers: '@bankofai/x402-core': specifier: workspace:* version: link:../../core + '@bankofai/x402-extensions': + specifier: workspace:* + version: link:../../extensions tronweb: specifier: ^6.1.0 version: 6.2.2(bufferutil@4.0.9)(utf-8-validate@5.0.10) @@ -1723,6 +1726,9 @@ importers: '@bankofai/x402-svm': specifier: workspace:* version: link:../../../typescript/packages/mechanisms/svm + '@bankofai/x402-tron': + specifier: workspace:* + version: link:../../../typescript/packages/mechanisms/tron '@scure/base': specifier: ^1.2.6 version: 1.2.6 @@ -1735,6 +1741,9 @@ importers: express: specifier: ^4.19.2 version: 4.21.2 + tronweb: + specifier: ^6.1.0 + version: 6.2.2(bufferutil@4.0.9)(utf-8-validate@5.0.10) viem: specifier: ^2.21.54 version: 2.38.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) diff --git a/e2e/scripts/permit2-approval.ts b/e2e/scripts/permit2-approval.ts index 0cf622a0..29064ae3 100644 --- a/e2e/scripts/permit2-approval.ts +++ b/e2e/scripts/permit2-approval.ts @@ -25,8 +25,8 @@ import { bscTestnet } from 'viem/chains'; config(); -// Permit2 canonical address (same on all EVM chains) -const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3'; +// BSC uses PancakeSwap's Permit2 deployment (not the canonical address). +const PERMIT2_ADDRESS = '0x31c2F6fcFf4F8759b3Bd5Bf0e1084A055615c768'; // Known tokens on BSC Testnet const TOKENS: Record = { diff --git a/e2e/servers/express/index.ts b/e2e/servers/express/index.ts index 9cdc66c3..8056180b 100644 --- a/e2e/servers/express/index.ts +++ b/e2e/servers/express/index.ts @@ -31,6 +31,7 @@ const SVM_NETWORK = (process.env.SVM_NETWORK || const APTOS_NETWORK = (process.env.APTOS_NETWORK || "aptos:2") as `${string}:${string}`; const STELLAR_NETWORK = (process.env.STELLAR_NETWORK || "stellar:testnet") as `${string}:${string}`; const EVM_PAYEE_ADDRESS = process.env.EVM_PAYEE_ADDRESS as `0x${string}`; +const EVM_FACILITATOR_ADDRESS = process.env.EVM_FACILITATOR_ADDRESS as `0x${string}` | undefined; const SVM_PAYEE_ADDRESS = process.env.SVM_PAYEE_ADDRESS as string; const APTOS_PAYEE_ADDRESS = process.env.APTOS_PAYEE_ADDRESS as string; const STELLAR_PAYEE_ADDRESS = process.env.STELLAR_PAYEE_ADDRESS as string | undefined; @@ -49,6 +50,11 @@ if (!facilitatorUrl) { console.error("❌ FACILITATOR_URL environment variable is required"); process.exit(1); } +if (!EVM_FACILITATOR_ADDRESS) { + console.warn( + "⚠️ EVM_FACILITATOR_ADDRESS not set — Permit2 endpoints disabled", + ); +} // Initialize Express app const app = express(); @@ -215,63 +221,69 @@ app.use( }, } : {}), - // Permit2 endpoint for generic ERC-20 tokens (no EIP-2612, uses raw approve tx) - "GET /protected-permit2-erc20": { - accepts: { - payTo: EVM_PAYEE_ADDRESS, - scheme: "exact", - network: EVM_NETWORK, - assets: ["DHLU"], - price: { - amount: "1000", - asset: "0x375cADdd2cB68cE82e3D9B075D551067a7b4B816", // DHLU (ERC-20 approval path, no name/version) - extra: { - assetTransferMethod: "permit2", - }, - }, - }, - extensions: { - ...declareErc20ApprovalGasSponsoringExtension(), - }, - }, - // Permit2 endpoint - explicitly requires Permit2 flow instead of EIP-3009 - "GET /protected-permit2": { - accepts: { - payTo: EVM_PAYEE_ADDRESS, - scheme: "exact", - network: EVM_NETWORK, - assets: ["DHLU"], - price: { - amount: "1000", - asset: "0x375cADdd2cB68cE82e3D9B075D551067a7b4B816", // DHLU (permit2 + EIP-2612 path) - extra: { - name: "DA HULU", - version: "1", - assetTransferMethod: "permit2", - }, - }, - }, - extensions: { - ...declareDiscoveryExtension({ - output: { - example: { - message: "Permit2 endpoint accessed successfully", - timestamp: "2024-01-01T00:00:00Z", - method: "permit2", + ...(EVM_FACILITATOR_ADDRESS + ? { + // Permit2 endpoint for generic ERC-20 tokens (no EIP-2612, uses raw approve tx) + "GET /protected-permit2-erc20": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "exact", + network: EVM_NETWORK, + assets: ["DHLU"], + price: { + amount: "1000", + asset: "0x375cADdd2cB68cE82e3D9B075D551067a7b4B816", // DHLU (ERC-20 approval path, no name/version) + extra: { + assetTransferMethod: "permit2", + permit2FacilitatorAddress: EVM_FACILITATOR_ADDRESS, + }, + }, }, - schema: { - properties: { - message: { type: "string" }, - timestamp: { type: "string" }, - method: { type: "string" }, + extensions: { + ...declareErc20ApprovalGasSponsoringExtension(), + }, + }, + // Permit2 endpoint - explicitly requires Permit2 flow instead of EIP-3009 + "GET /protected-permit2": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "exact", + network: EVM_NETWORK, + assets: ["DHLU"], + price: { + amount: "1000", + asset: "0x375cADdd2cB68cE82e3D9B075D551067a7b4B816", // DHLU (permit2 + EIP-2612 path) + extra: { + name: "DA HULU", + version: "1", + assetTransferMethod: "permit2", + permit2FacilitatorAddress: EVM_FACILITATOR_ADDRESS, + }, }, - required: ["message", "timestamp", "method"], + }, + extensions: { + ...declareDiscoveryExtension({ + output: { + example: { + message: "Permit2 endpoint accessed successfully", + timestamp: "2024-01-01T00:00:00Z", + method: "permit2", + }, + schema: { + properties: { + message: { type: "string" }, + timestamp: { type: "string" }, + method: { type: "string" }, + }, + required: ["message", "timestamp", "method"], + }, + }, + }), + ...declareEip2612GasSponsoringExtension(), }, }, - }), - ...declareEip2612GasSponsoringExtension(), - }, - }, + } + : {}), ...(STELLAR_PAYEE_ADDRESS ? { "GET /protected-stellar": { @@ -353,13 +365,15 @@ app.get("/protected-aptos", (req, res) => { * that do NOT implement EIP-2612. The facilitator broadcasts the pre-signed * approve() transaction on the client's behalf before settling. */ -app.get("/protected-permit2-erc20", (req, res) => { - res.json({ - message: "Permit2 ERC-20 approval endpoint accessed successfully", - timestamp: new Date().toISOString(), - method: "permit2-erc20-approval", +if (EVM_FACILITATOR_ADDRESS) { + app.get("/protected-permit2-erc20", (req, res) => { + res.json({ + message: "Permit2 ERC-20 approval endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "permit2-erc20-approval", + }); }); -}); +} /** * Protected Permit2 endpoint - requires payment via Permit2 flow @@ -367,13 +381,15 @@ app.get("/protected-permit2-erc20", (req, res) => { * This endpoint demonstrates the Permit2 payment flow. * Clients must have approved Permit2 to spend their USDC before accessing. */ -app.get("/protected-permit2", (req, res) => { - res.json({ - message: "Permit2 endpoint accessed successfully", - timestamp: new Date().toISOString(), - method: "permit2", +if (EVM_FACILITATOR_ADDRESS) { + app.get("/protected-permit2", (req, res) => { + res.json({ + message: "Permit2 endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "permit2", + }); }); -}); +} /** * Protected Stellar endpoint - requires payment to access @@ -420,6 +436,12 @@ app.post("/close", (req, res) => { }, 100); }); +const permit2Endpoints = EVM_FACILITATOR_ADDRESS + ? "\n" + + "║ • GET /protected-permit2 (Permit2 payment - EVM) ║\n" + + "║ • GET /protected-permit2-erc20 (Permit2 + ERC-20 approval) ║" + : ""; + // Start the server app.listen(parseInt(PORT), () => { console.log(` @@ -439,9 +461,7 @@ app.listen(parseInt(PORT), () => { ║ Endpoints: ║ ║ • GET /protected (EIP-3009 payment - EVM) ║ ║ • GET /protected-svm (SVM payment) ║ -║ • GET /protected-aptos (Aptos payment) ║ -║ • GET /protected-permit2 (Permit2 payment - EVM) ║ -║ • GET /protected-permit2-erc20 (Permit2 + ERC-20 approval) ║ +║ • GET /protected-aptos (Aptos payment) ║${permit2Endpoints} ║ • GET /protected-stellar (Stellar payment) ║ ║ • GET /health (no payment required) ║ ║ • POST /close (shutdown server) ║ diff --git a/e2e/servers/fastapi/main.py b/e2e/servers/fastapi/main.py index 71ab554b..fadbf177 100644 --- a/e2e/servers/fastapi/main.py +++ b/e2e/servers/fastapi/main.py @@ -17,18 +17,25 @@ register_exact_evm_server, ) from bankofai.x402.mechanisms.svm.exact import register_exact_svm_server +from bankofai.x402.mechanisms.tron.exact import register_exact_tron_server from bankofai.x402.extensions.bazaar import ( bazaar_resource_server_extension, declare_discovery_extension, OutputConfig, ) +from bankofai.x402.extensions.trc20_approval_gas_sponsoring import ( + declare_trc20_approval_gas_sponsoring_extension, +) # Load environment variables load_dotenv() # Get configuration from environment EVM_ADDRESS = os.getenv("EVM_PAYEE_ADDRESS") +EVM_FACILITATOR_ADDRESS = os.getenv("EVM_FACILITATOR_ADDRESS") SVM_ADDRESS = os.getenv("SVM_PAYEE_ADDRESS") +TRON_ADDRESS = os.getenv("TRON_PAYEE_ADDRESS") +TRON_FACILITATOR_ADDRESS = os.getenv("TRON_FACILITATOR_ADDRESS") PORT = int(os.getenv("PORT", "4021")) FACILITATOR_URL = os.getenv("FACILITATOR_URL") @@ -38,17 +45,24 @@ if not SVM_ADDRESS: print("Warning: SVM_PAYEE_ADDRESS not set - SVM payment endpoints disabled") +if not TRON_ADDRESS: + print("Warning: TRON_PAYEE_ADDRESS not set - TRON payment endpoints disabled") +if not TRON_FACILITATOR_ADDRESS: + print("Warning: TRON_FACILITATOR_ADDRESS not set - TRON Permit2 endpoints disabled") +if not EVM_FACILITATOR_ADDRESS: + print("Warning: EVM_FACILITATOR_ADDRESS not set - EVM Permit2 endpoints disabled") # Network configurations (CAIP-2 format) EVM_NETWORK = "eip155:97" # BSC Testnet SVM_NETWORK = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" # Solana Devnet +TRON_NETWORK = os.getenv("TRON_NETWORK") or "tron:nile" app = FastAPI() # Create HTTP facilitator client if FACILITATOR_URL: print(f"Using remote facilitator at: {FACILITATOR_URL}") - config = FacilitatorConfig(url=FACILITATOR_URL) + config = FacilitatorConfig(url=FACILITATOR_URL, timeout=90.0) facilitator = HTTPFacilitatorClient(config) else: print("Using default facilitator") @@ -74,6 +88,8 @@ register_exact_evm_server(server, EVM_NETWORK) if SVM_ADDRESS: register_exact_svm_server(server, SVM_NETWORK) +if TRON_ADDRESS: + register_exact_tron_server(server, TRON_NETWORK) # Register Bazaar discovery extension server.register_extension(bazaar_resource_server_extension) @@ -140,6 +156,47 @@ ), }, }, + **( + { + "GET /protected-permit2": { + "accepts": { + "scheme": "exact", + "payTo": EVM_ADDRESS, + "assets": ["DHLU"], + "price": { + "amount": "1000", + "asset": "0x375cADdd2cB68cE82e3D9B075D551067a7b4B816", + "extra": { + "name": "DA HULU", + "version": "1", + "assetTransferMethod": "permit2", + "permit2FacilitatorAddress": EVM_FACILITATOR_ADDRESS, + }, + }, + "network": EVM_NETWORK, + }, + "extensions": { + **declare_discovery_extension( + output=OutputConfig( + example={ + "message": "Access granted to Permit2 protected resource", + "timestamp": "2024-01-01T00:00:00Z", + }, + schema={ + "properties": { + "message": {"type": "string"}, + "timestamp": {"type": "string"}, + }, + "required": ["message", "timestamp"], + }, + ) + ), + }, + }, + } + if EVM_FACILITATOR_ADDRESS + else {} + ), **( { "GET /protected-svm": { @@ -171,6 +228,82 @@ if SVM_ADDRESS else {} ), + **( + { + "GET /protected-tron": { + "accepts": { + "scheme": "exact", + "payTo": TRON_ADDRESS, + "price": "$0.01", + "network": TRON_NETWORK, + "extra": { + "assetTransferMethod": "permit2", + "permit2FacilitatorAddress": TRON_FACILITATOR_ADDRESS, + }, + }, + "extensions": { + **declare_discovery_extension( + output=OutputConfig( + example={ + "message": "Access granted to TRON protected resource", + "timestamp": "2024-01-01T00:00:00Z", + }, + schema={ + "properties": { + "message": {"type": "string"}, + "timestamp": {"type": "string"}, + }, + "required": ["message", "timestamp"], + }, + ) + ), + **declare_trc20_approval_gas_sponsoring_extension( + description="TRC-20 approval gas sponsoring (Permit2)", + ), + }, + }, + } + if TRON_ADDRESS and TRON_FACILITATOR_ADDRESS + else {} + ), + **( + { + "GET /protected-tron-permit2": { + "accepts": { + "scheme": "exact", + "payTo": TRON_ADDRESS, + "price": "$0.01", + "network": TRON_NETWORK, + "extra": { + "assetTransferMethod": "permit2", + "permit2FacilitatorAddress": TRON_FACILITATOR_ADDRESS, + }, + }, + "extensions": { + **declare_discovery_extension( + output=OutputConfig( + example={ + "message": "Access granted to TRON Permit2 resource", + "timestamp": "2024-01-01T00:00:00Z", + }, + schema={ + "properties": { + "message": {"type": "string"}, + "timestamp": {"type": "string"}, + }, + "required": ["message", "timestamp"], + }, + ) + ), + **declare_trc20_approval_gas_sponsoring_extension( + description="TRC-20 approval gas sponsoring (Permit2)", + ), + }, + }, + } + if TRON_ADDRESS and TRON_FACILITATOR_ADDRESS + else {} + ), } @@ -208,6 +341,18 @@ async def protected_endpoint_2() -> Dict[str, Any]: } +@app.get("/protected-permit2") +async def protected_permit2_endpoint() -> Dict[str, Any]: + """Protected endpoint that requires Permit2 payment.""" + if shutdown_requested: + raise HTTPException(status_code=503, detail="Server shutting down") + + return { + "message": "Access granted to Permit2 protected resource", + "timestamp": "2024-01-01T00:00:00Z", + } + + @app.get("/protected-svm") async def protected_svm_endpoint() -> Dict[str, Any]: """Protected endpoint that requires SVM (Solana) payment.""" @@ -220,6 +365,30 @@ async def protected_svm_endpoint() -> Dict[str, Any]: } +@app.get("/protected-tron") +async def protected_tron_endpoint() -> Dict[str, Any]: + """Protected endpoint that requires TRON payment.""" + if shutdown_requested: + raise HTTPException(status_code=503, detail="Server shutting down") + + return { + "message": "Access granted to TRON protected resource", + "timestamp": "2024-01-01T00:00:00Z", + } + + +@app.get("/protected-tron-permit2") +async def protected_tron_permit2_endpoint() -> Dict[str, Any]: + """Protected endpoint that requires TRON Permit2 payment.""" + if shutdown_requested: + raise HTTPException(status_code=503, detail="Server shutting down") + + return { + "message": "Access granted to TRON Permit2 resource", + "timestamp": "2024-01-01T00:00:00Z", + } + + @app.get("/health") async def health_check() -> Dict[str, Any]: """Health check endpoint.""" diff --git a/e2e/servers/fastapi/test.config.json b/e2e/servers/fastapi/test.config.json index 91d3041f..4c2503dd 100644 --- a/e2e/servers/fastapi/test.config.json +++ b/e2e/servers/fastapi/test.config.json @@ -24,6 +24,14 @@ "protocolFamily": "evm", "transferMethod": "eip3009" }, + { + "path": "/protected-permit2", + "method": "GET", + "description": "Protected endpoint requiring Permit2 payment", + "requiresPayment": true, + "protocolFamily": "evm", + "transferMethod": "permit2" + }, { "path": "/protected-svm", "method": "GET", @@ -31,6 +39,22 @@ "requiresPayment": true, "protocolFamily": "svm" }, + { + "path": "/protected-tron", + "method": "GET", + "description": "Protected endpoint requiring TRON payment", + "requiresPayment": true, + "protocolFamily": "tron", + "transferMethod": "transferWithAuthorization" + }, + { + "path": "/protected-tron-permit2", + "method": "GET", + "description": "Protected endpoint requiring TRON Permit2 payment", + "requiresPayment": true, + "protocolFamily": "tron", + "transferMethod": "permit2" + }, { "path": "/health", "method": "GET", @@ -50,8 +74,9 @@ ], "optional": [ "SVM_PAYEE_ADDRESS", + "TRON_PAYEE_ADDRESS", "PORT", "FACILITATOR_URL" ] } -} \ No newline at end of file +} diff --git a/e2e/servers/fastapi/uv.lock b/e2e/servers/fastapi/uv.lock index df17c353..0d350cd2 100644 --- a/e2e/servers/fastapi/uv.lock +++ b/e2e/servers/fastapi/uv.lock @@ -225,10 +225,11 @@ svm = [ [package.metadata] requires-dist = [ - { name = "bankofai-x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, { name = "bankofai-x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "bankofai-x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "bankofai-x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "bankofai-x402", extras = ["httpx", "requests", "flask", "fastapi", "tron", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "bankofai-x402", extras = ["tron", "evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "base58", marker = "extra == 'tron'", specifier = ">=2.1.1" }, { name = "eth-abi", marker = "extra == 'evm'", specifier = ">=5.0.0" }, { name = "eth-account", marker = "extra == 'evm'", specifier = ">=0.12.0" }, { name = "eth-keys", marker = "extra == 'evm'", specifier = ">=0.5.0" }, @@ -244,6 +245,7 @@ requires-dist = [ { 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 = "tronpy", marker = "extra == 'tron'", specifier = ">=0.6.0,<0.7.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, ] diff --git a/e2e/servers/flask/uv.lock b/e2e/servers/flask/uv.lock index e100787c..6657cb69 100644 --- a/e2e/servers/flask/uv.lock +++ b/e2e/servers/flask/uv.lock @@ -215,10 +215,11 @@ svm = [ [package.metadata] requires-dist = [ - { name = "bankofai-x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, { name = "bankofai-x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "bankofai-x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "bankofai-x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "bankofai-x402", extras = ["httpx", "requests", "flask", "fastapi", "tron", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "bankofai-x402", extras = ["tron", "evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "base58", marker = "extra == 'tron'", specifier = ">=2.1.1" }, { name = "eth-abi", marker = "extra == 'evm'", specifier = ">=5.0.0" }, { name = "eth-account", marker = "extra == 'evm'", specifier = ">=0.12.0" }, { name = "eth-keys", marker = "extra == 'evm'", specifier = ">=0.5.0" }, @@ -234,6 +235,7 @@ requires-dist = [ { 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 = "tronpy", marker = "extra == 'tron'", specifier = ">=0.6.0,<0.7.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, ] diff --git a/e2e/servers/mcp-python/uv.lock b/e2e/servers/mcp-python/uv.lock index 6d6916ec..4582ea6c 100644 --- a/e2e/servers/mcp-python/uv.lock +++ b/e2e/servers/mcp-python/uv.lock @@ -208,10 +208,11 @@ mcp = [ [package.metadata] requires-dist = [ - { name = "bankofai-x402", extras = ["evm", "svm"], marker = "extra == 'mechanisms'" }, { name = "bankofai-x402", extras = ["flask", "fastapi"], marker = "extra == 'servers'" }, { name = "bankofai-x402", extras = ["httpx", "requests"], marker = "extra == 'clients'" }, - { name = "bankofai-x402", extras = ["httpx", "requests", "flask", "fastapi", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "bankofai-x402", extras = ["httpx", "requests", "flask", "fastapi", "tron", "evm", "svm", "mcp", "extensions"], marker = "extra == 'all'" }, + { name = "bankofai-x402", extras = ["tron", "evm", "svm"], marker = "extra == 'mechanisms'" }, + { name = "base58", marker = "extra == 'tron'", specifier = ">=2.1.1" }, { name = "eth-abi", marker = "extra == 'evm'", specifier = ">=5.0.0" }, { name = "eth-account", marker = "extra == 'evm'", specifier = ">=0.12.0" }, { name = "eth-keys", marker = "extra == 'evm'", specifier = ">=0.5.0" }, @@ -227,6 +228,7 @@ requires-dist = [ { 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 = "tronpy", marker = "extra == 'tron'", specifier = ">=0.6.0,<0.7.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, ] diff --git a/e2e/src/clients/generic-client.ts b/e2e/src/clients/generic-client.ts index 3485f0fc..4f69fdd7 100644 --- a/e2e/src/clients/generic-client.ts +++ b/e2e/src/clients/generic-client.ts @@ -24,7 +24,9 @@ export class GenericClientProxy extends BaseProxy implements ClientProxy { SVM_PRIVATE_KEY: config.svmPrivateKey, APTOS_PRIVATE_KEY: config.aptosPrivateKey, STELLAR_PRIVATE_KEY: config.stellarPrivateKey, + TRON_PRIVATE_KEY: config.tronPrivateKey, EVM_RPC_URL: config.evmRpcUrl || '', + TRON_RPC_URL: config.tronRpcUrl || '', RESOURCE_SERVER_URL: config.serverUrl, ENDPOINT_PATH: config.endpointPath, } @@ -70,4 +72,4 @@ export class GenericClientProxy extends BaseProxy implements ClientProxy { async forceStop(): Promise { await this.stopProcess(); } -} \ No newline at end of file +} diff --git a/e2e/src/discovery.ts b/e2e/src/discovery.ts index 2950367b..ce4532dd 100644 --- a/e2e/src/discovery.ts +++ b/e2e/src/discovery.ts @@ -293,8 +293,8 @@ export class TestDiscovery { // For protocols with transfer methods (EVM, TRON), check compatibility with client if (endpointProtocolFamily === 'evm' || endpointProtocolFamily === 'tron') { - const defaultMethod = endpointProtocolFamily === 'evm' ? 'eip3009' : 'tip712'; - const endpointTransferMethod = endpoint.transferMethod || defaultMethod; + const defaultMethod = endpointProtocolFamily === 'evm' ? 'eip3009' : 'transferWithAuthorization'; + const endpointTransferMethod = (endpoint as any).permit2 ? 'permit2' : (endpoint.transferMethod || defaultMethod); const clientTransferMethods = (endpointProtocolFamily === 'evm' ? client.config.evm?.transferMethods : client.config.tron?.transferMethods) || [defaultMethod]; @@ -310,8 +310,8 @@ export class TestDiscovery { const supportsVersion = f.config.x402Versions?.includes(serverVersion); // For protocols with transfer methods, also check compatibility if (endpointProtocolFamily === 'evm' || endpointProtocolFamily === 'tron') { - const defaultMethod = endpointProtocolFamily === 'evm' ? 'eip3009' : 'tip712'; - const endpointTransferMethod = endpoint.transferMethod || defaultMethod; + const defaultMethod = endpointProtocolFamily === 'evm' ? 'eip3009' : 'transferWithAuthorization'; + const endpointTransferMethod = (endpoint as any).permit2 ? 'permit2' : (endpoint.transferMethod || defaultMethod); const facilTransferMethods = (endpointProtocolFamily === 'evm' ? f.config.evm?.transferMethods : f.config.tron?.transferMethods) || [defaultMethod]; @@ -321,12 +321,6 @@ export class TestDiscovery { }); for (const facilitator of matchingFacilitators) { - // TODO: Python SDK currently lacks Permit2 support. - // We skip these scenarios when using the python facilitator to avoid expected failures. - if (facilitator.name === 'python' && (endpoint.transferMethod === 'permit2' || (endpoint as any).permit2)) { - continue; - } - scenarios.push({ client, server, diff --git a/e2e/src/facilitators/facilitator-manager.ts b/e2e/src/facilitators/facilitator-manager.ts index e54be2da..12e1fc29 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, + tronPrivateKey: process.env.FACILITATOR_TRON_PRIVATE_KEY, networks, }); diff --git a/e2e/src/facilitators/generic-facilitator.ts b/e2e/src/facilitators/generic-facilitator.ts index 672f758a..5191da1a 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; + tronPrivateKey?: 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 || '', + TRON_PRIVATE_KEY: config.tronPrivateKey || '', // 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, + TRON_NETWORK: config.networks.tron?.caip2 || '', + TRON_RPC_URL: config.networks.tron?.rpcUrl || '', }; // Pass through any additional environment variables required by the facilitator @@ -339,6 +343,6 @@ export class GenericFacilitatorProxy extends BaseProxy implements FacilitatorPro } getUrl(): string { - return `http://localhost:${this.port}`; + return `http://127.0.0.1:${this.port}`; } } diff --git a/e2e/src/sampling.ts b/e2e/src/sampling.ts index b493975a..e30ae811 100644 --- a/e2e/src/sampling.ts +++ b/e2e/src/sampling.ts @@ -37,7 +37,7 @@ export class CoverageTracker { * including different EVM transfer methods (eip3009 vs permit2). */ private getEndpointCoverageKey(serverName: string, endpointPath: string, protocolFamily: string, version: number, transferMethod?: string): string { - const defaultMethods: Record = { evm: 'eip3009', tron: 'tip712' }; + const defaultMethods: Record = { evm: 'eip3009', tron: 'transferWithAuthorization' }; const method = defaultMethods[protocolFamily] ? (transferMethod || defaultMethods[protocolFamily]) : ''; return `${serverName}-${endpointPath}-${protocolFamily}${method ? `-${method}` : ''}-v${version}`; } @@ -195,4 +195,3 @@ export function minimizeScenarios(scenarios: TestScenario[]): TestScenario[] { return minimized; } - diff --git a/e2e/src/servers/generic-server.ts b/e2e/src/servers/generic-server.ts index 9d06672f..c69690cb 100644 --- a/e2e/src/servers/generic-server.ts +++ b/e2e/src/servers/generic-server.ts @@ -108,6 +108,11 @@ export class GenericServerProxy extends BaseProxy implements ServerProxy { STELLAR_RPC_URL: config.networks.stellar.rpcUrl, STELLAR_PAYEE_ADDRESS: config.stellarPayTo, + // TRON network config + TRON_NETWORK: config.networks.tron?.caip2 || '', + TRON_RPC_URL: config.networks.tron?.rpcUrl || '', + TRON_PAYEE_ADDRESS: config.tronPayTo, + // Facilitator FACILITATOR_URL: config.facilitatorUrl || '', } diff --git a/e2e/src/types.ts b/e2e/src/types.ts index a12e2802..49da4513 100644 --- a/e2e/src/types.ts +++ b/e2e/src/types.ts @@ -2,7 +2,7 @@ import type { NetworkSet } from './networks/networks'; export type ProtocolFamily = 'evm' | 'svm' | 'aptos' | 'stellar' | 'tron'; export type Transport = 'http' | 'mcp'; -export type TransferMethod = 'eip3009' | 'permit2' | 'tip712'; +export type TransferMethod = 'eip3009' | 'permit2' | 'transferWithAuthorization'; export interface ClientResult { success: boolean; @@ -18,6 +18,7 @@ export interface ClientConfig { aptosPrivateKey: string; stellarPrivateKey: string; evmRpcUrl?: string; + tronRpcUrl?: string; tronPrivateKey: string; serverUrl: string; endpointPath: string; diff --git a/e2e/test.ts b/e2e/test.ts index 53c96a25..de99b3b3 100644 --- a/e2e/test.ts +++ b/e2e/test.ts @@ -129,12 +129,28 @@ async function runClientTest( ): Promise { const verboseLogs: string[] = []; + const sanitizeForLogs = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(sanitizeForLogs); + } + if (value && typeof value === "object") { + const entries = Object.entries(value as Record).map(([k, v]) => { + if (k.toLowerCase().includes("privatekey")) { + return [k, ""] as const; + } + return [k, sanitizeForLogs(v)] as const; + }); + return Object.fromEntries(entries); + } + return value; + }; + const bufferLog = (msg: string) => { verboseLogs.push(msg); }; try { - bufferLog(` 📞 Running client: ${JSON.stringify(callConfig, null, 2)}`); + bufferLog(` 📞 Running client: ${JSON.stringify(sanitizeForLogs(callConfig), null, 2)}`); const result = await client.call(callConfig); bufferLog(` 📊 Client result: ${JSON.stringify(result, null, 2)}`); @@ -238,14 +254,18 @@ 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 serverTronAddress = process.env.SERVER_TRON_ADDRESS; + const tronFacilitatorAddress = process.env.TRON_FACILITATOR_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 clientTronPrivateKey = process.env.CLIENT_TRON_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; + const facilitatorTronPrivateKey = process.env.FACILITATOR_TRON_PRIVATE_KEY; // Env validation is deferred until after scenario filtering so we only // require variables for the protocol families that will actually be tested. @@ -320,6 +340,9 @@ 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})`); + if (networks.tron) { + log(` TRON: ${networks.tron.name} (${networks.tron.caip2})`); + } if (networkMode === 'mainnet') { log('\n⚠️ WARNING: Running on MAINNET - real funds will be used!'); @@ -377,6 +400,12 @@ async function runTest() { if (!clientStellarPrivateKey) missingEnv.push('CLIENT_STELLAR_PRIVATE_KEY'); if (!facilitatorStellarPrivateKey) missingEnv.push('FACILITATOR_STELLAR_PRIVATE_KEY'); } + if (requiredFamilies.has('tron')) { + if (!serverTronAddress) missingEnv.push('SERVER_TRON_ADDRESS'); + if (!tronFacilitatorAddress) missingEnv.push('TRON_FACILITATOR_ADDRESS'); + if (!clientTronPrivateKey) missingEnv.push('CLIENT_TRON_PRIVATE_KEY'); + if (!facilitatorTronPrivateKey) missingEnv.push('FACILITATOR_TRON_PRIVATE_KEY'); + } if (missingEnv.length > 0) { errorLog('❌ Missing required environment variables for selected protocol families:'); @@ -387,7 +416,7 @@ async function runTest() { // Auto-detect Permit2 scenarios const hasPermit2Scenarios = filteredScenarios.some( - (s) => s.endpoint.transferMethod === 'permit2' + (s) => s.endpoint.transferMethod === 'permit2' || (s.endpoint as any).permit2 ); // Check if eip2612GasSponsoring extension should be tested @@ -599,6 +628,8 @@ async function runTest() { aptosPrivateKey: clientAptosPrivateKey || '', stellarPrivateKey: clientStellarPrivateKey || '', evmRpcUrl: networks.evm.rpcUrl, + tronPrivateKey: clientTronPrivateKey || '', + tronRpcUrl: networks.tron?.rpcUrl || '', serverUrl: `http://localhost:${port}`, endpointPath: scenario.endpoint.path, }; @@ -679,6 +710,7 @@ async function runTest() { const facilitatorConfig = facilitatorName ? uniqueFacilitators.get(facilitatorName)?.config : undefined; const facilitatorSupportsAptos = facilitatorConfig?.protocolFamilies?.includes('aptos') ?? false; const facilitatorSupportsStellar = facilitatorConfig?.protocolFamilies?.includes('stellar') ?? false; + const facilitatorSupportsTron = facilitatorConfig?.protocolFamilies?.includes('tron') ?? false; const serverConfig: ServerConfig = { port, @@ -686,6 +718,7 @@ async function runTest() { svmPayTo: serverSvmAddress!, aptosPayTo: facilitatorSupportsAptos ? (serverAptosAddress || '') : '', stellarPayTo: facilitatorSupportsStellar ? (serverStellarAddress || '') : '', + tronPayTo: facilitatorSupportsTron ? (serverTronAddress || '') : '', networks, facilitatorUrl, }; diff --git a/python/x402/pyproject.toml b/python/x402/pyproject.toml index 4168f0eb..c75917e7 100644 --- a/python/x402/pyproject.toml +++ b/python/x402/pyproject.toml @@ -37,7 +37,7 @@ fastapi = ["fastapi[standard]>=0.115.0", "starlette>=0.27.0"] # Blockchain mechanisms - install based on which chains you need tron = [ "base58>=2.1.1", - "tronpy>=0.4.0", + "tronpy>=0.6.0,<0.7.0", ] evm = [ "eth-abi>=5.0.0", diff --git a/python/x402/src/bankofai/x402/client_base.py b/python/x402/src/bankofai/x402/client_base.py index 4bc6ed7c..6da8245b 100644 --- a/python/x402/src/bankofai/x402/client_base.py +++ b/python/x402/src/bankofai/x402/client_base.py @@ -5,13 +5,14 @@ from __future__ import annotations +import inspect from collections.abc import Awaitable, Callable, Generator from dataclasses import dataclass, field from typing import Any, Literal from typing_extensions import Self -from .interfaces import SchemeNetworkClient, SchemeNetworkClientV1 +from .interfaces import PaymentPayloadContext, SchemeNetworkClient, SchemeNetworkClientV1 from .schemas import ( AbortResult, Network, @@ -268,6 +269,37 @@ def get_registered_schemes( # Core Logic Generators (shared between async/sync) # ======================================================================== + @staticmethod + def _supports_payload_context(create_payload: Callable[..., Any]) -> bool: + """Return whether create_payment_payload supports a context argument.""" + try: + signature = inspect.signature(create_payload) + except (TypeError, ValueError): + return True + + parameters = list(signature.parameters.values()) + positional = [ + p + for p in parameters + if p.kind + in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ] + if len(positional) >= 2: + return True + return any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in parameters) + + @classmethod + def _create_inner_payload( + cls, + create_payload: Callable[..., Any], + requirements: PaymentRequirements | PaymentRequirementsV1, + payload_context: PaymentPayloadContext, + ) -> Any: + """Call scheme payload factory with backward-compatible signature handling.""" + if cls._supports_payload_context(create_payload): + return create_payload(requirements, payload_context) + return create_payload(requirements) + def _create_payment_payload_v2_core( self, payment_required: PaymentRequired, @@ -304,15 +336,32 @@ def _create_payment_payload_v2_core( client = schemes[selected.scheme] # 5. Create inner payload - inner_payload = client.create_payment_payload(selected) + payload_context = PaymentPayloadContext(extensions=payment_required.extensions) + inner_payload = self._create_inner_payload( + client.create_payment_payload, + selected, + payload_context, + ) + client_extensions = None + if isinstance(inner_payload, tuple): + inner_payload, client_extensions = inner_payload # 6. Wrap into full PaymentPayload + merged_extensions: dict[str, Any] | None + base_extensions = extensions if extensions is not None else payment_required.extensions + if base_extensions is None: + base_extensions = {} + if client_extensions: + merged_extensions = {**base_extensions, **client_extensions} + else: + merged_extensions = base_extensions if base_extensions else None + payload = PaymentPayload( x402_version=2, payload=inner_payload, accepted=selected, resource=resource or payment_required.resource, - extensions=extensions or payment_required.extensions, + extensions=merged_extensions, ) # 7. Execute after hooks @@ -374,7 +423,14 @@ def _create_payment_payload_v1_core( client = schemes[selected.scheme] # 5. Create inner payload - inner_payload = client.create_payment_payload(selected) + payload_context = PaymentPayloadContext(extensions=None) + inner_payload = self._create_inner_payload( + client.create_payment_payload, + selected, + payload_context, + ) + if isinstance(inner_payload, tuple): + inner_payload = inner_payload[0] # 6. Wrap into full PaymentPayloadV1 payload = PaymentPayloadV1( diff --git a/python/x402/src/bankofai/x402/extensions/__init__.py b/python/x402/src/bankofai/x402/extensions/__init__.py index ff74ea70..c7234bd9 100644 --- a/python/x402/src/bankofai/x402/extensions/__init__.py +++ b/python/x402/src/bankofai/x402/extensions/__init__.py @@ -36,6 +36,27 @@ # Create alias for backward compatibility ValidationResult = BazaarValidationResult +from .eip2612_gas_sponsoring import ( # noqa: E402 + EIP2612_GAS_SPONSORING, + Eip2612GasSponsoringExtension, + Eip2612GasSponsoringInfo, + Eip2612GasSponsoringServerInfo, + declare_eip2612_gas_sponsoring_extension, + extract_eip2612_gas_sponsoring_info, + validate_eip2612_gas_sponsoring_info, +) +from .erc20_approval_gas_sponsoring import ( # noqa: E402 + ERC20_APPROVAL_GAS_SPONSORING, + ERC20_APPROVAL_GAS_SPONSORING_VERSION, + Erc20ApprovalGasSponsoringExtension, + Erc20ApprovalGasSponsoringInfo, + Erc20ApprovalGasSponsoringServerInfo, + Erc20ApprovalGasSponsoringSigner, + create_erc20_approval_gas_sponsoring_extension, + declare_erc20_approval_gas_sponsoring_extension, + extract_erc20_approval_gas_sponsoring_info, + validate_erc20_approval_gas_sponsoring_info, +) from .payment_identifier import ( # noqa: E402 PAYMENT_ID_MAX_LENGTH, PAYMENT_ID_MIN_LENGTH, @@ -59,6 +80,18 @@ validate_payment_identifier, validate_payment_identifier_requirement, ) +from .trc20_approval_gas_sponsoring import ( # noqa: E402 + TRC20_APPROVAL_GAS_SPONSORING, + TRC20_APPROVAL_GAS_SPONSORING_VERSION, + Trc20ApprovalGasSponsoringExtension, + Trc20ApprovalGasSponsoringInfo, + Trc20ApprovalGasSponsoringServerInfo, + Trc20ApprovalGasSponsoringSigner, + create_trc20_approval_gas_sponsoring_extension, + declare_trc20_approval_gas_sponsoring_extension, + extract_trc20_approval_gas_sponsoring_info, + validate_trc20_approval_gas_sponsoring_info, +) __all__ = [ # Constants @@ -125,4 +158,34 @@ "validate_payment_identifier_requirement", "PaymentIdentifierValidationResult", "BazaarValidationResult", + # EIP-2612 gas sponsoring + "EIP2612_GAS_SPONSORING", + "Eip2612GasSponsoringInfo", + "Eip2612GasSponsoringServerInfo", + "Eip2612GasSponsoringExtension", + "declare_eip2612_gas_sponsoring_extension", + "extract_eip2612_gas_sponsoring_info", + "validate_eip2612_gas_sponsoring_info", + # ERC-20 approval gas sponsoring + "ERC20_APPROVAL_GAS_SPONSORING", + "ERC20_APPROVAL_GAS_SPONSORING_VERSION", + "Erc20ApprovalGasSponsoringSigner", + "Erc20ApprovalGasSponsoringInfo", + "Erc20ApprovalGasSponsoringServerInfo", + "Erc20ApprovalGasSponsoringExtension", + "create_erc20_approval_gas_sponsoring_extension", + "declare_erc20_approval_gas_sponsoring_extension", + "extract_erc20_approval_gas_sponsoring_info", + "validate_erc20_approval_gas_sponsoring_info", + # TRC-20 approval gas sponsoring + "TRC20_APPROVAL_GAS_SPONSORING", + "TRC20_APPROVAL_GAS_SPONSORING_VERSION", + "Trc20ApprovalGasSponsoringSigner", + "Trc20ApprovalGasSponsoringInfo", + "Trc20ApprovalGasSponsoringServerInfo", + "Trc20ApprovalGasSponsoringExtension", + "create_trc20_approval_gas_sponsoring_extension", + "declare_trc20_approval_gas_sponsoring_extension", + "extract_trc20_approval_gas_sponsoring_info", + "validate_trc20_approval_gas_sponsoring_info", ] diff --git a/python/x402/src/bankofai/x402/extensions/eip2612_gas_sponsoring/__init__.py b/python/x402/src/bankofai/x402/extensions/eip2612_gas_sponsoring/__init__.py new file mode 100644 index 00000000..ed70f167 --- /dev/null +++ b/python/x402/src/bankofai/x402/extensions/eip2612_gas_sponsoring/__init__.py @@ -0,0 +1,23 @@ +"""EIP-2612 Gas Sponsoring Extension for x402 v2.""" + +from .types import ( + EIP2612_GAS_SPONSORING, + Eip2612GasSponsoringExtension, + Eip2612GasSponsoringInfo, + Eip2612GasSponsoringServerInfo, +) +from .utils import ( + declare_eip2612_gas_sponsoring_extension, + extract_eip2612_gas_sponsoring_info, + validate_eip2612_gas_sponsoring_info, +) + +__all__ = [ + "EIP2612_GAS_SPONSORING", + "Eip2612GasSponsoringInfo", + "Eip2612GasSponsoringServerInfo", + "Eip2612GasSponsoringExtension", + "declare_eip2612_gas_sponsoring_extension", + "extract_eip2612_gas_sponsoring_info", + "validate_eip2612_gas_sponsoring_info", +] diff --git a/python/x402/src/bankofai/x402/extensions/eip2612_gas_sponsoring/types.py b/python/x402/src/bankofai/x402/extensions/eip2612_gas_sponsoring/types.py new file mode 100644 index 00000000..969758a3 --- /dev/null +++ b/python/x402/src/bankofai/x402/extensions/eip2612_gas_sponsoring/types.py @@ -0,0 +1,45 @@ +"""Type definitions for the EIP-2612 Gas Sponsoring Extension.""" + +from dataclasses import dataclass +from typing import Any + +from ...interfaces import FacilitatorExtension + +EIP2612_GAS_SPONSORING = FacilitatorExtension(key="eip2612GasSponsoring") + + +@dataclass +class Eip2612GasSponsoringInfo: + from_address: str + asset: str + spender: str + amount: str + nonce: str + deadline: str + signature: str + version: str + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Eip2612GasSponsoringInfo": + return cls( + from_address=str(data.get("from", "")), + asset=str(data.get("asset", "")), + spender=str(data.get("spender", "")), + amount=str(data.get("amount", "")), + nonce=str(data.get("nonce", "")), + deadline=str(data.get("deadline", "")), + signature=str(data.get("signature", "")), + version=str(data.get("version", "")), + ) + + +@dataclass +class Eip2612GasSponsoringServerInfo: + description: str + version: str + + +@dataclass +class Eip2612GasSponsoringExtension: + info: Eip2612GasSponsoringInfo | Eip2612GasSponsoringServerInfo + schema: dict[str, Any] diff --git a/python/x402/src/bankofai/x402/extensions/eip2612_gas_sponsoring/utils.py b/python/x402/src/bankofai/x402/extensions/eip2612_gas_sponsoring/utils.py new file mode 100644 index 00000000..5bfa932b --- /dev/null +++ b/python/x402/src/bankofai/x402/extensions/eip2612_gas_sponsoring/utils.py @@ -0,0 +1,43 @@ +"""Utility helpers for the EIP-2612 Gas Sponsoring Extension.""" + +from typing import Any + +from .types import EIP2612_GAS_SPONSORING, Eip2612GasSponsoringInfo + + +def declare_eip2612_gas_sponsoring_extension(description: str = "EIP-2612 gas sponsoring") -> dict: + """Declare the EIP-2612 gas sponsoring extension in PaymentRequired.""" + return { + EIP2612_GAS_SPONSORING.key: { + "info": {"description": description, "version": "1"}, + "schema": {}, + } + } + + +def extract_eip2612_gas_sponsoring_info( + payment_payload_extensions: dict[str, Any] | None, +) -> Eip2612GasSponsoringInfo | None: + if not payment_payload_extensions: + return None + raw = payment_payload_extensions.get(EIP2612_GAS_SPONSORING.key) + if not isinstance(raw, dict): + return None + info = raw.get("info") + if not isinstance(info, dict): + return None + return Eip2612GasSponsoringInfo.from_dict(info) + + +def validate_eip2612_gas_sponsoring_info(info: Eip2612GasSponsoringInfo) -> bool: + required = [ + info.from_address, + info.asset, + info.spender, + info.amount, + info.nonce, + info.deadline, + info.signature, + info.version, + ] + return all(bool(v) for v in required) diff --git a/python/x402/src/bankofai/x402/extensions/erc20_approval_gas_sponsoring/__init__.py b/python/x402/src/bankofai/x402/extensions/erc20_approval_gas_sponsoring/__init__.py new file mode 100644 index 00000000..96d68c1b --- /dev/null +++ b/python/x402/src/bankofai/x402/extensions/erc20_approval_gas_sponsoring/__init__.py @@ -0,0 +1,29 @@ +"""ERC-20 Approval Gas Sponsoring Extension for x402 v2.""" + +from .types import ( + ERC20_APPROVAL_GAS_SPONSORING, + ERC20_APPROVAL_GAS_SPONSORING_VERSION, + Erc20ApprovalGasSponsoringExtension, + Erc20ApprovalGasSponsoringInfo, + Erc20ApprovalGasSponsoringServerInfo, + Erc20ApprovalGasSponsoringSigner, + create_erc20_approval_gas_sponsoring_extension, +) +from .utils import ( + declare_erc20_approval_gas_sponsoring_extension, + extract_erc20_approval_gas_sponsoring_info, + validate_erc20_approval_gas_sponsoring_info, +) + +__all__ = [ + "ERC20_APPROVAL_GAS_SPONSORING", + "ERC20_APPROVAL_GAS_SPONSORING_VERSION", + "Erc20ApprovalGasSponsoringExtension", + "Erc20ApprovalGasSponsoringSigner", + "Erc20ApprovalGasSponsoringInfo", + "Erc20ApprovalGasSponsoringServerInfo", + "create_erc20_approval_gas_sponsoring_extension", + "declare_erc20_approval_gas_sponsoring_extension", + "extract_erc20_approval_gas_sponsoring_info", + "validate_erc20_approval_gas_sponsoring_info", +] diff --git a/python/x402/src/bankofai/x402/extensions/erc20_approval_gas_sponsoring/types.py b/python/x402/src/bankofai/x402/extensions/erc20_approval_gas_sponsoring/types.py new file mode 100644 index 00000000..d38c4b52 --- /dev/null +++ b/python/x402/src/bankofai/x402/extensions/erc20_approval_gas_sponsoring/types.py @@ -0,0 +1,87 @@ +"""Type definitions for the ERC-20 Approval Gas Sponsoring Extension.""" + +from dataclasses import dataclass +from typing import Any, Protocol + +from ...interfaces import FacilitatorExtension + + +class Erc20ApprovalGasSponsoringSigner(Protocol): + def get_addresses(self) -> list[str]: ... + + def read_contract( + self, address: str, abi: list[dict[str, Any]], function_name: str, *args: Any + ) -> Any: ... + + def verify_typed_data( + self, + address: str, + domain: dict[str, Any], + types: dict[str, Any], + primary_type: str, + message: dict[str, Any], + signature: bytes | str, + ) -> bool: ... + + def write_contract( + self, address: str, abi: list[dict[str, Any]], function_name: str, *args: Any + ) -> str: ... + + def send_transaction(self, to: str, data: bytes) -> str: ... + + def wait_for_transaction_receipt(self, tx_hash: str): ... + + def get_code(self, address: str) -> bytes: ... + + def send_raw_transaction(self, serialized_tx: str) -> str: ... + + +ERC20_APPROVAL_GAS_SPONSORING = FacilitatorExtension(key="erc20ApprovalGasSponsoring") +ERC20_APPROVAL_GAS_SPONSORING_VERSION = "1" + + +@dataclass(frozen=True) +class Erc20ApprovalGasSponsoringExtension(FacilitatorExtension): + signer: Erc20ApprovalGasSponsoringSigner | None = None + + +def create_erc20_approval_gas_sponsoring_extension( + signer: Erc20ApprovalGasSponsoringSigner, +) -> Erc20ApprovalGasSponsoringExtension: + return Erc20ApprovalGasSponsoringExtension( + key=ERC20_APPROVAL_GAS_SPONSORING.key, + signer=signer, + ) + + +@dataclass +class Erc20ApprovalGasSponsoringInfo: + from_address: str + asset: str + spender: str + amount: str + signed_transaction: str + version: str + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Erc20ApprovalGasSponsoringInfo": + return cls( + from_address=str(data.get("from", "")), + asset=str(data.get("asset", "")), + spender=str(data.get("spender", "")), + amount=str(data.get("amount", "")), + signed_transaction=str(data.get("signedTransaction", "")), + version=str(data.get("version", "")), + ) + + +@dataclass +class Erc20ApprovalGasSponsoringServerInfo: + description: str + version: str + + +@dataclass +class Erc20ApprovalGasSponsoringExtensionInfo: + info: Erc20ApprovalGasSponsoringInfo | Erc20ApprovalGasSponsoringServerInfo + schema: dict[str, Any] diff --git a/python/x402/src/bankofai/x402/extensions/erc20_approval_gas_sponsoring/utils.py b/python/x402/src/bankofai/x402/extensions/erc20_approval_gas_sponsoring/utils.py new file mode 100644 index 00000000..cc28d35b --- /dev/null +++ b/python/x402/src/bankofai/x402/extensions/erc20_approval_gas_sponsoring/utils.py @@ -0,0 +1,46 @@ +"""Utility helpers for the ERC-20 Approval Gas Sponsoring Extension.""" + +from typing import Any + +from .types import ( + ERC20_APPROVAL_GAS_SPONSORING, + ERC20_APPROVAL_GAS_SPONSORING_VERSION, + Erc20ApprovalGasSponsoringInfo, +) + + +def declare_erc20_approval_gas_sponsoring_extension( + description: str = "ERC-20 approval gas sponsoring", +) -> dict: + return { + ERC20_APPROVAL_GAS_SPONSORING.key: { + "info": {"description": description, "version": ERC20_APPROVAL_GAS_SPONSORING_VERSION}, + "schema": {}, + } + } + + +def extract_erc20_approval_gas_sponsoring_info( + payment_payload_extensions: dict[str, Any] | None, +) -> Erc20ApprovalGasSponsoringInfo | None: + if not payment_payload_extensions: + return None + raw = payment_payload_extensions.get(ERC20_APPROVAL_GAS_SPONSORING.key) + if not isinstance(raw, dict): + return None + info = raw.get("info") + if not isinstance(info, dict): + return None + return Erc20ApprovalGasSponsoringInfo.from_dict(info) + + +def validate_erc20_approval_gas_sponsoring_info(info: Erc20ApprovalGasSponsoringInfo) -> bool: + required = [ + info.from_address, + info.asset, + info.spender, + info.amount, + info.signed_transaction, + info.version, + ] + return all(bool(v) for v in required) diff --git a/python/x402/src/bankofai/x402/extensions/trc20_approval_gas_sponsoring/__init__.py b/python/x402/src/bankofai/x402/extensions/trc20_approval_gas_sponsoring/__init__.py new file mode 100644 index 00000000..7576ca2d --- /dev/null +++ b/python/x402/src/bankofai/x402/extensions/trc20_approval_gas_sponsoring/__init__.py @@ -0,0 +1,29 @@ +"""TRC-20 Approval Gas Sponsoring Extension for x402 v2.""" + +from .types import ( + TRC20_APPROVAL_GAS_SPONSORING, + TRC20_APPROVAL_GAS_SPONSORING_VERSION, + Trc20ApprovalGasSponsoringExtension, + Trc20ApprovalGasSponsoringInfo, + Trc20ApprovalGasSponsoringServerInfo, + Trc20ApprovalGasSponsoringSigner, + create_trc20_approval_gas_sponsoring_extension, +) +from .utils import ( + declare_trc20_approval_gas_sponsoring_extension, + extract_trc20_approval_gas_sponsoring_info, + validate_trc20_approval_gas_sponsoring_info, +) + +__all__ = [ + "TRC20_APPROVAL_GAS_SPONSORING", + "TRC20_APPROVAL_GAS_SPONSORING_VERSION", + "Trc20ApprovalGasSponsoringExtension", + "Trc20ApprovalGasSponsoringSigner", + "Trc20ApprovalGasSponsoringInfo", + "Trc20ApprovalGasSponsoringServerInfo", + "create_trc20_approval_gas_sponsoring_extension", + "declare_trc20_approval_gas_sponsoring_extension", + "extract_trc20_approval_gas_sponsoring_info", + "validate_trc20_approval_gas_sponsoring_info", +] diff --git a/python/x402/src/bankofai/x402/extensions/trc20_approval_gas_sponsoring/types.py b/python/x402/src/bankofai/x402/extensions/trc20_approval_gas_sponsoring/types.py new file mode 100644 index 00000000..7f6c3a0b --- /dev/null +++ b/python/x402/src/bankofai/x402/extensions/trc20_approval_gas_sponsoring/types.py @@ -0,0 +1,83 @@ +"""Type definitions for the TRC-20 Approval Gas Sponsoring Extension.""" + +import json +from dataclasses import dataclass +from typing import Any, Protocol + +from ...interfaces import FacilitatorExtension + + +class Trc20ApprovalGasSponsoringSigner(Protocol): + def get_addresses(self) -> list[str]: ... + + def read_contract( + self, + address: str, + function_name: str, + args: list[Any] | None = None, + ) -> Any: ... + + def wait_for_transaction_receipt(self, tx_hash: str): ... + + def send_raw_transaction(self, signed_transaction: dict[str, Any]) -> str: ... + + def get_sign_weight(self, transaction: Any) -> Any: ... + + +TRC20_APPROVAL_GAS_SPONSORING = FacilitatorExtension(key="trc20ApprovalGasSponsoring") +TRC20_APPROVAL_GAS_SPONSORING_VERSION = "1" + + +@dataclass(frozen=True) +class Trc20ApprovalGasSponsoringExtension(FacilitatorExtension): + signer: Trc20ApprovalGasSponsoringSigner | None = None + + +def create_trc20_approval_gas_sponsoring_extension( + signer: Trc20ApprovalGasSponsoringSigner, +) -> Trc20ApprovalGasSponsoringExtension: + return Trc20ApprovalGasSponsoringExtension( + key=TRC20_APPROVAL_GAS_SPONSORING.key, + signer=signer, + ) + + +@dataclass +class Trc20ApprovalGasSponsoringInfo: + from_address: str + asset: str + spender: str + amount: str + signed_transaction: dict[str, Any] + version: str + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Trc20ApprovalGasSponsoringInfo": + signed_tx = data.get("signedTransaction") + if isinstance(signed_tx, str): + try: + signed_tx = json.loads(signed_tx) + except Exception: + signed_tx = {} + if not isinstance(signed_tx, dict): + signed_tx = {} + return cls( + from_address=str(data.get("from", "")), + asset=str(data.get("asset", "")), + spender=str(data.get("spender", "")), + amount=str(data.get("amount", "")), + signed_transaction=signed_tx, + version=str(data.get("version", "")), + ) + + +@dataclass +class Trc20ApprovalGasSponsoringServerInfo: + description: str + version: str + + +@dataclass +class Trc20ApprovalGasSponsoringExtensionInfo: + info: Trc20ApprovalGasSponsoringInfo | Trc20ApprovalGasSponsoringServerInfo + schema: dict[str, Any] diff --git a/python/x402/src/bankofai/x402/extensions/trc20_approval_gas_sponsoring/utils.py b/python/x402/src/bankofai/x402/extensions/trc20_approval_gas_sponsoring/utils.py new file mode 100644 index 00000000..68056d1a --- /dev/null +++ b/python/x402/src/bankofai/x402/extensions/trc20_approval_gas_sponsoring/utils.py @@ -0,0 +1,46 @@ +"""Utility helpers for the TRC-20 Approval Gas Sponsoring Extension.""" + +from typing import Any + +from .types import ( + TRC20_APPROVAL_GAS_SPONSORING, + TRC20_APPROVAL_GAS_SPONSORING_VERSION, + Trc20ApprovalGasSponsoringInfo, +) + + +def declare_trc20_approval_gas_sponsoring_extension( + description: str = "TRC-20 approval gas sponsoring", +) -> dict: + return { + TRC20_APPROVAL_GAS_SPONSORING.key: { + "info": {"description": description, "version": TRC20_APPROVAL_GAS_SPONSORING_VERSION}, + "schema": {}, + } + } + + +def extract_trc20_approval_gas_sponsoring_info( + payment_payload_extensions: dict[str, Any] | None, +) -> Trc20ApprovalGasSponsoringInfo | None: + if not payment_payload_extensions: + return None + raw = payment_payload_extensions.get(TRC20_APPROVAL_GAS_SPONSORING.key) + if not isinstance(raw, dict): + return None + info = raw.get("info") + if not isinstance(info, dict): + return None + return Trc20ApprovalGasSponsoringInfo.from_dict(info) + + +def validate_trc20_approval_gas_sponsoring_info(info: Trc20ApprovalGasSponsoringInfo) -> bool: + required = [ + info.from_address, + info.asset, + info.spender, + info.amount, + info.signed_transaction, + info.version, + ] + return all(bool(v) for v in required) diff --git a/python/x402/src/bankofai/x402/http/clients/httpx.py b/python/x402/src/bankofai/x402/http/clients/httpx.py index 80811bb3..3fcb2589 100644 --- a/python/x402/src/bankofai/x402/http/clients/httpx.py +++ b/python/x402/src/bankofai/x402/http/clients/httpx.py @@ -143,7 +143,8 @@ def get_header(name: str) -> str | None: except PaymentError: raise except Exception as e: - raise PaymentError(f"Failed to handle payment: {e}") from e + error_detail = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__ + raise PaymentError(f"Failed to handle payment: {error_detail}") from e async def aclose(self) -> None: """Close the underlying transport.""" diff --git a/python/x402/src/bankofai/x402/interfaces.py b/python/x402/src/bankofai/x402/interfaces.py index 456a5974..d632f9c7 100644 --- a/python/x402/src/bankofai/x402/interfaces.py +++ b/python/x402/src/bankofai/x402/interfaces.py @@ -63,6 +63,13 @@ def get_extension(self, key: str) -> FacilitatorExtension | None: return self._extensions.get(key) +@dataclass(frozen=True) +class PaymentPayloadContext: + """Context passed to client schemes during payload creation.""" + + extensions: dict[str, Any] | None = None + + # ============================================================================ # Client-Side Protocols # ============================================================================ @@ -98,11 +105,13 @@ def scheme(self) -> str: def create_payment_payload( self, requirements: PaymentRequirements, + context: PaymentPayloadContext | None = None, ) -> dict[str, Any]: """Create the scheme-specific inner payload dict. Args: requirements: The payment requirements to fulfill. + context: Optional context with server-declared extensions. Returns: Scheme-specific payload dict. x402Client wraps this into @@ -125,11 +134,13 @@ def scheme(self) -> str: def create_payment_payload( self, requirements: PaymentRequirementsV1, + context: PaymentPayloadContext | None = None, ) -> dict[str, Any]: """Create the scheme-specific inner payload dict for V1. Args: requirements: The V1 payment requirements to fulfill. + context: Optional context with server-declared extensions. Returns: Scheme-specific payload dict. x402Client wraps this into diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/README.md b/python/x402/src/bankofai/x402/mechanisms/evm/README.md index 4a6b513b..9532487e 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/README.md +++ b/python/x402/src/bankofai/x402/mechanisms/evm/README.md @@ -27,7 +27,9 @@ from bankofai.x402.mechanisms.evm import EthAccountSigner from eth_account import Account account = Account.from_key("0x...") -signer = EthAccountSigner(account) +signer = EthAccountSigner(account, rpc_url="https://your-rpc") +# Or set EVM_RPC_URL_ / EVM_RPC_URL / WEB3_PROVIDER_URL and omit rpc_url. +# SDK resolves RPC per network and falls back to testnet defaults for known chains. client = x402Client() client.register("eip155:*", ExactEvmScheme(signer=signer)) @@ -127,4 +129,3 @@ Automatic handling of: - Deployed smart wallets (ERC-1271 signature verification) - Undeployed smart wallets (ERC-6492 counterfactual verification) - EOA wallets (standard ECDSA) - diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/__init__.py b/python/x402/src/bankofai/x402/mechanisms/evm/__init__.py index 375436d2..3f254066 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/__init__.py +++ b/python/x402/src/bankofai/x402/mechanisms/evm/__init__.py @@ -5,9 +5,26 @@ AUTHORIZATION_STATE_ABI, BALANCE_OF_ABI, DEFAULT_DECIMALS, + DEFAULT_MAX_FEE_PER_GAS, + DEFAULT_MAX_PRIORITY_FEE_PER_GAS, DEFAULT_VALIDITY_PERIOD, EIP1271_MAGIC_VALUE, + EIP2612_NONCES_ABI, + EIP2612_PERMIT_TYPES, + ERC20_ALLOWANCE_ABI, + ERC20_APPROVE_ABI, + ERC20_APPROVE_GAS_LIMIT, ERC6492_MAGIC_VALUE, + ERR_EIP2612_ASSET_MISMATCH, + ERR_EIP2612_DEADLINE_EXPIRED, + ERR_EIP2612_EXTENSION_FORMAT, + ERR_EIP2612_FROM_MISMATCH, + ERR_EIP2612_SPENDER_NOT_PERMIT2, + ERR_ERC20_APPROVAL_ASSET_MISMATCH, + ERR_ERC20_APPROVAL_EXTENSION_FORMAT, + ERR_ERC20_APPROVAL_FROM_MISMATCH, + ERR_ERC20_APPROVAL_SPENDER_NOT_PERMIT2, + ERR_ERC20_APPROVAL_TX_FAILED, ERR_FAILED_TO_GET_ASSET_INFO, ERR_FAILED_TO_GET_NETWORK_CONFIG, ERR_FAILED_TO_VERIFY_SIGNATURE, @@ -17,6 +34,21 @@ ERR_MISSING_EIP712_DOMAIN, ERR_NETWORK_MISMATCH, ERR_NONCE_ALREADY_USED, + ERR_PERMIT2_2612_AMOUNT_MISMATCH, + ERR_PERMIT2_ALLOWANCE_REQUIRED, + ERR_PERMIT2_AMOUNT_MISMATCH, + ERR_PERMIT2_DEADLINE_EXPIRED, + ERR_PERMIT2_INVALID_AMOUNT, + ERR_PERMIT2_INVALID_DESTINATION, + ERR_PERMIT2_INVALID_FACILITATOR, + ERR_PERMIT2_INVALID_NONCE, + ERR_PERMIT2_INVALID_OWNER, + ERR_PERMIT2_INVALID_SIGNATURE, + ERR_PERMIT2_INVALID_SPENDER, + ERR_PERMIT2_NOT_YET_VALID, + ERR_PERMIT2_PAYMENT_TOO_EARLY, + ERR_PERMIT2_RECIPIENT_MISMATCH, + ERR_PERMIT2_TOKEN_MISMATCH, ERR_RECIPIENT_MISMATCH, ERR_SMART_WALLET_DEPLOYMENT_FAILED, ERR_TRANSACTION_FAILED, @@ -26,13 +58,21 @@ ERR_VALID_BEFORE_EXPIRED, IS_VALID_SIGNATURE_ABI, NETWORK_CONFIGS, + PERMIT2_ADDRESS, + PERMIT2_ADDRESSES, + PERMIT2_WITNESS_TYPES, SCHEME_EXACT, TRANSFER_WITH_AUTHORIZATION_BYTES_ABI, TRANSFER_WITH_AUTHORIZATION_VRS_ABI, TX_STATUS_FAILED, TX_STATUS_SUCCESS, + X402_EXACT_PERMIT2_PROXY_ADDRESS, + X402_EXACT_PERMIT2_PROXY_ADDRESSES, AssetInfo, NetworkConfig, + get_permit2_address, + get_x402_exact_permit2_proxy_address, + x402ExactPermit2ProxyABI, ) # EIP-712 @@ -67,9 +107,13 @@ ExactEIP3009Payload, ExactEvmPayloadV1, ExactEvmPayloadV2, + ExactPermit2Payload, + Permit2Authorization, + Permit2Witness, TransactionReceipt, TypedDataDomain, TypedDataField, + is_permit2_payload, ) # Utilities @@ -127,6 +171,31 @@ "ERR_NETWORK_MISMATCH", "ERR_UNSUPPORTED_SCHEME", "ERR_TRANSACTION_FAILED", + "ERR_PERMIT2_INVALID_SPENDER", + "ERR_PERMIT2_RECIPIENT_MISMATCH", + "ERR_PERMIT2_INVALID_FACILITATOR", + "ERR_PERMIT2_DEADLINE_EXPIRED", + "ERR_PERMIT2_NOT_YET_VALID", + "ERR_PERMIT2_AMOUNT_MISMATCH", + "ERR_PERMIT2_TOKEN_MISMATCH", + "ERR_PERMIT2_INVALID_SIGNATURE", + "ERR_PERMIT2_ALLOWANCE_REQUIRED", + "ERR_PERMIT2_INVALID_AMOUNT", + "ERR_PERMIT2_INVALID_DESTINATION", + "ERR_PERMIT2_INVALID_OWNER", + "ERR_PERMIT2_PAYMENT_TOO_EARLY", + "ERR_PERMIT2_INVALID_NONCE", + "ERR_PERMIT2_2612_AMOUNT_MISMATCH", + "ERR_EIP2612_EXTENSION_FORMAT", + "ERR_EIP2612_FROM_MISMATCH", + "ERR_EIP2612_ASSET_MISMATCH", + "ERR_EIP2612_SPENDER_NOT_PERMIT2", + "ERR_EIP2612_DEADLINE_EXPIRED", + "ERR_ERC20_APPROVAL_EXTENSION_FORMAT", + "ERR_ERC20_APPROVAL_FROM_MISMATCH", + "ERR_ERC20_APPROVAL_ASSET_MISMATCH", + "ERR_ERC20_APPROVAL_SPENDER_NOT_PERMIT2", + "ERR_ERC20_APPROVAL_TX_FAILED", "ERR_FAILED_TO_GET_NETWORK_CONFIG", "ERR_FAILED_TO_GET_ASSET_INFO", "ERR_FAILED_TO_VERIFY_SIGNATURE", @@ -134,20 +203,39 @@ "TRANSFER_WITH_AUTHORIZATION_BYTES_ABI", "AUTHORIZATION_STATE_ABI", "BALANCE_OF_ABI", + "ERC20_ALLOWANCE_ABI", + "ERC20_APPROVE_ABI", + "EIP2612_PERMIT_TYPES", + "EIP2612_NONCES_ABI", + "ERC20_APPROVE_GAS_LIMIT", + "DEFAULT_MAX_FEE_PER_GAS", + "DEFAULT_MAX_PRIORITY_FEE_PER_GAS", "IS_VALID_SIGNATURE_ABI", + "PERMIT2_WITNESS_TYPES", + "PERMIT2_ADDRESS", + "PERMIT2_ADDRESSES", + "X402_EXACT_PERMIT2_PROXY_ADDRESS", + "X402_EXACT_PERMIT2_PROXY_ADDRESSES", + "get_permit2_address", + "get_x402_exact_permit2_proxy_address", + "x402ExactPermit2ProxyABI", "AssetInfo", "NetworkConfig", # Types "ExactEIP3009Authorization", "ExactEIP3009Payload", + "ExactPermit2Payload", "ExactEvmPayloadV1", "ExactEvmPayloadV2", + "Permit2Authorization", + "Permit2Witness", "TypedDataDomain", "TypedDataField", "TransactionReceipt", "ERC6492SignatureData", "AUTHORIZATION_TYPES", "DOMAIN_TYPES", + "is_permit2_payload", # Signer protocols "ClientEvmSigner", "FacilitatorEvmSigner", diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/constants.py b/python/x402/src/bankofai/x402/mechanisms/evm/constants.py index 2531b26b..c6fddbdf 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/constants.py +++ b/python/x402/src/bankofai/x402/mechanisms/evm/constants.py @@ -48,6 +48,42 @@ ERR_FAILED_TO_GET_ASSET_INFO = "invalid_exact_evm_failed_to_get_asset_info" ERR_FAILED_TO_VERIFY_SIGNATURE = "invalid_exact_evm_failed_to_verify_signature" ERR_TRANSACTION_FAILED = "transaction_failed" +# Permit2 verify errors +ERR_PERMIT2_INVALID_SPENDER = "invalid_permit2_spender" +ERR_PERMIT2_RECIPIENT_MISMATCH = "invalid_permit2_recipient_mismatch" +ERR_PERMIT2_INVALID_FACILITATOR = "invalid_permit2_facilitator_mismatch" +ERR_PERMIT2_DEADLINE_EXPIRED = "permit2_deadline_expired" +ERR_PERMIT2_NOT_YET_VALID = "permit2_not_yet_valid" +ERR_PERMIT2_AMOUNT_MISMATCH = "permit2_amount_mismatch" +ERR_PERMIT2_TOKEN_MISMATCH = "permit2_token_mismatch" +ERR_PERMIT2_INVALID_SIGNATURE = "invalid_permit2_signature" +ERR_PERMIT2_ALLOWANCE_REQUIRED = "permit2_allowance_required" +# Permit2 settle errors +ERR_PERMIT2_INVALID_AMOUNT = "permit2_invalid_amount" +ERR_PERMIT2_INVALID_DESTINATION = "permit2_invalid_destination" +ERR_PERMIT2_INVALID_OWNER = "permit2_invalid_owner" +ERR_PERMIT2_PAYMENT_TOO_EARLY = "permit2_payment_too_early" +ERR_PERMIT2_INVALID_NONCE = "permit2_invalid_nonce" +ERR_PERMIT2_2612_AMOUNT_MISMATCH = "permit2_2612_amount_mismatch" +# EIP-2612 extension verify errors +ERR_EIP2612_EXTENSION_FORMAT = "invalid_eip2612_extension_format" +ERR_EIP2612_FROM_MISMATCH = "eip2612_from_mismatch" +ERR_EIP2612_ASSET_MISMATCH = "eip2612_asset_mismatch" +ERR_EIP2612_SPENDER_NOT_PERMIT2 = "eip2612_spender_not_permit2" +ERR_EIP2612_DEADLINE_EXPIRED = "eip2612_deadline_expired" +# ERC-20 approval extension verify errors +ERR_ERC20_APPROVAL_EXTENSION_FORMAT = "invalid_erc20_approval_extension_format" +ERR_ERC20_APPROVAL_FROM_MISMATCH = "erc20_approval_from_mismatch" +ERR_ERC20_APPROVAL_ASSET_MISMATCH = "erc20_approval_asset_mismatch" +ERR_ERC20_APPROVAL_SPENDER_NOT_PERMIT2 = "erc20_approval_spender_not_permit2" +ERR_ERC20_APPROVAL_TX_WRONG_TARGET = "erc20_approval_tx_wrong_target" +ERR_ERC20_APPROVAL_TX_WRONG_SELECTOR = "erc20_approval_tx_wrong_selector" +ERR_ERC20_APPROVAL_TX_WRONG_SPENDER = "erc20_approval_tx_wrong_spender" +ERR_ERC20_APPROVAL_TX_INVALID_CALLDATA = "erc20_approval_tx_invalid_calldata" +ERR_ERC20_APPROVAL_TX_SIGNER_MISMATCH = "erc20_approval_tx_signer_mismatch" +ERR_ERC20_APPROVAL_TX_INVALID_SIGNATURE = "erc20_approval_tx_invalid_signature" +ERR_ERC20_APPROVAL_TX_PARSE_FAILED = "erc20_approval_tx_parse_failed" +ERR_ERC20_APPROVAL_TX_FAILED = "erc20_approval_tx_failed" class _AssetInfoRequired(TypedDict): @@ -76,6 +112,7 @@ class NetworkConfig(_NetworkConfigRequired, total=False): """Configuration for an EVM network.""" default_asset: AssetInfo + default_rpc_url: str # Network configurations @@ -93,6 +130,7 @@ class NetworkConfig(_NetworkConfigRequired, total=False): # Base Sepolia (Testnet) "eip155:84532": { "chain_id": 84532, + "default_rpc_url": "https://sepolia.base.org", "default_asset": { "address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "name": "USDC", @@ -100,6 +138,11 @@ class NetworkConfig(_NetworkConfigRequired, total=False): "decimals": 6, }, }, + # BSC Testnet + "eip155:97": { + "chain_id": 97, + "default_rpc_url": "https://bsc-testnet-rpc.publicnode.com", + }, # MegaETH Mainnet (uses Permit2 instead of EIP-3009, supports EIP-2612) "eip155:4326": { "chain_id": 4326, @@ -189,6 +232,56 @@ class NetworkConfig(_NetworkConfigRequired, total=False): } ] +ERC20_APPROVE_ABI = [ + { + "inputs": [ + {"name": "spender", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "name": "approve", + "outputs": [{"name": "", "type": "bool"}], + "stateMutability": "nonpayable", + "type": "function", + } +] + +ERC20_ALLOWANCE_ABI = [ + { + "inputs": [ + {"name": "owner", "type": "address"}, + {"name": "spender", "type": "address"}, + ], + "name": "allowance", + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + } +] + +EIP2612_PERMIT_TYPES: dict[str, list[dict[str, str]]] = { + "Permit": [ + {"name": "owner", "type": "address"}, + {"name": "spender", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "nonce", "type": "uint256"}, + {"name": "deadline", "type": "uint256"}, + ] +} + +EIP2612_NONCES_ABI = [ + { + "type": "function", + "name": "nonces", + "inputs": [{"name": "owner", "type": "address"}], + "outputs": [{"type": "uint256"}], + "stateMutability": "view", + } +] + +ERC20_APPROVE_GAS_LIMIT = 70_000 +DEFAULT_MAX_FEE_PER_GAS = 1_000_000_000 +DEFAULT_MAX_PRIORITY_FEE_PER_GAS = 100_000_000 + IS_VALID_SIGNATURE_ABI = [ { "inputs": [ @@ -201,3 +294,139 @@ class NetworkConfig(_NetworkConfigRequired, total=False): "type": "function", } ] + +# Permit2 EIP-712 types for signing PermitWitnessTransferFrom. +# Types must be in alphabetical order after the primary type. +PERMIT2_WITNESS_TYPES: dict[str, list[dict[str, str]]] = { + "PermitWitnessTransferFrom": [ + {"name": "permitted", "type": "TokenPermissions"}, + {"name": "spender", "type": "address"}, + {"name": "nonce", "type": "uint256"}, + {"name": "deadline", "type": "uint256"}, + {"name": "witness", "type": "Witness"}, + ], + "TokenPermissions": [ + {"name": "token", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "Witness": [ + {"name": "to", "type": "address"}, + {"name": "facilitator", "type": "address"}, + {"name": "validAfter", "type": "uint256"}, + ], +} + +# Canonical Permit2 address and overrides. +PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3" +PERMIT2_ADDRESSES: dict[str, str] = { + "eip155:56": "0x31c2F6fcFf4F8759b3Bd5Bf0e1084A055615c768", + "eip155:97": "0x31c2F6fcFf4F8759b3Bd5Bf0e1084A055615c768", +} + +# x402 exact Permit2 proxy addresses. +X402_EXACT_PERMIT2_PROXY_ADDRESS = "0xEe38Ec718255fe78e9D16aCC0e1183C731679b23" +X402_EXACT_PERMIT2_PROXY_ADDRESSES: dict[str, str] = { + "eip155:56": X402_EXACT_PERMIT2_PROXY_ADDRESS, + "eip155:97": X402_EXACT_PERMIT2_PROXY_ADDRESS, +} + + +def get_permit2_address(network: str) -> str: + """Resolve Permit2 contract address for a network.""" + return PERMIT2_ADDRESSES.get(network, PERMIT2_ADDRESS) + + +def get_x402_exact_permit2_proxy_address(network: str) -> str: + """Resolve x402 exact Permit2 proxy address for a network.""" + return X402_EXACT_PERMIT2_PROXY_ADDRESSES.get(network, X402_EXACT_PERMIT2_PROXY_ADDRESS) + + +_PERMIT2_WITNESS_ABI_COMPONENTS = [ + {"name": "to", "type": "address", "internalType": "address"}, + {"name": "facilitator", "type": "address", "internalType": "address"}, + {"name": "validAfter", "type": "uint256", "internalType": "uint256"}, +] + +# x402ExactPermit2Proxy ABI (settle + settleWithPermit). +x402ExactPermit2ProxyABI = [ + { + "type": "function", + "name": "settle", + "inputs": [ + { + "name": "permit", + "type": "tuple", + "internalType": "struct ISignatureTransfer.PermitTransferFrom", + "components": [ + { + "name": "permitted", + "type": "tuple", + "internalType": "struct ISignatureTransfer.TokenPermissions", + "components": [ + {"name": "token", "type": "address", "internalType": "address"}, + {"name": "amount", "type": "uint256", "internalType": "uint256"}, + ], + }, + {"name": "nonce", "type": "uint256", "internalType": "uint256"}, + {"name": "deadline", "type": "uint256", "internalType": "uint256"}, + ], + }, + {"name": "owner", "type": "address", "internalType": "address"}, + { + "name": "witness", + "type": "tuple", + "internalType": "struct x402ExactPermit2Proxy.Witness", + "components": _PERMIT2_WITNESS_ABI_COMPONENTS, + }, + {"name": "signature", "type": "bytes", "internalType": "bytes"}, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "settleWithPermit", + "inputs": [ + { + "name": "permit2612", + "type": "tuple", + "internalType": "struct x402ExactPermit2Proxy.EIP2612Permit", + "components": [ + {"name": "value", "type": "uint256", "internalType": "uint256"}, + {"name": "deadline", "type": "uint256", "internalType": "uint256"}, + {"name": "r", "type": "bytes32", "internalType": "bytes32"}, + {"name": "s", "type": "bytes32", "internalType": "bytes32"}, + {"name": "v", "type": "uint8", "internalType": "uint8"}, + ], + }, + { + "name": "permit", + "type": "tuple", + "internalType": "struct ISignatureTransfer.PermitTransferFrom", + "components": [ + { + "name": "permitted", + "type": "tuple", + "internalType": "struct ISignatureTransfer.TokenPermissions", + "components": [ + {"name": "token", "type": "address", "internalType": "address"}, + {"name": "amount", "type": "uint256", "internalType": "uint256"}, + ], + }, + {"name": "nonce", "type": "uint256", "internalType": "uint256"}, + {"name": "deadline", "type": "uint256", "internalType": "uint256"}, + ], + }, + {"name": "owner", "type": "address", "internalType": "address"}, + { + "name": "witness", + "type": "tuple", + "internalType": "struct x402ExactPermit2Proxy.Witness", + "components": _PERMIT2_WITNESS_ABI_COMPONENTS, + }, + {"name": "signature", "type": "bytes", "internalType": "bytes"}, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, +] diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/exact/client.py b/python/x402/src/bankofai/x402/mechanisms/evm/exact/client.py index 66157a82..a24a85e5 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/exact/client.py +++ b/python/x402/src/bankofai/x402/mechanisms/evm/exact/client.py @@ -5,8 +5,20 @@ from datetime import timedelta from typing import Any +from ....extensions.eip2612_gas_sponsoring import EIP2612_GAS_SPONSORING +from ....extensions.erc20_approval_gas_sponsoring import ERC20_APPROVAL_GAS_SPONSORING +from ....interfaces import PaymentPayloadContext from ....schemas import PaymentRequirements -from ..constants import SCHEME_EXACT +from ..constants import ( + DEFAULT_MAX_FEE_PER_GAS, + DEFAULT_MAX_PRIORITY_FEE_PER_GAS, + EIP2612_NONCES_ABI, + EIP2612_PERMIT_TYPES, + ERC20_ALLOWANCE_ABI, + ERC20_APPROVE_GAS_LIMIT, + SCHEME_EXACT, + get_permit2_address, +) from ..eip712 import build_typed_data_for_signing from ..signer import ClientEvmSigner from ..types import ExactEIP3009Authorization, ExactEIP3009Payload, TypedDataField @@ -15,10 +27,12 @@ create_validity_window, get_asset_info, get_evm_chain_id, + resolve_evm_rpc_url, ) +from .permit2 import create_permit2_payload -def _wrap_if_local_account(signer: Any) -> ClientEvmSigner: +def _wrap_if_local_account(signer: Any, network: str | None = None) -> ClientEvmSigner: """Auto-wrap eth_account LocalAccount in EthAccountSigner if needed.""" try: from eth_account.signers.local import LocalAccount @@ -26,7 +40,8 @@ def _wrap_if_local_account(signer: Any) -> ClientEvmSigner: if isinstance(signer, LocalAccount): from ..signers import EthAccountSigner - return EthAccountSigner(signer) + rpc_url = resolve_evm_rpc_url(network) + return EthAccountSigner(signer, rpc_url=rpc_url, network=network) except ImportError: pass return signer @@ -52,12 +67,17 @@ def __init__(self, signer: ClientEvmSigner): eth_account LocalAccount, which will be auto-wrapped in EthAccountSigner. """ + self._raw_signer = signer self._signer = _wrap_if_local_account(signer) + self._network_signers: dict[str, ClientEvmSigner] = {} + + # If signer is EthAccountSigner without RPC, approval extension will be unavailable. def create_payment_payload( self, requirements: PaymentRequirements, - ) -> dict[str, Any]: + context: PaymentPayloadContext | None = None, + ) -> dict[str, Any] | tuple[dict[str, Any], dict[str, Any]]: """Create signed EIP-3009 inner payload. Args: @@ -67,13 +87,25 @@ def create_payment_payload( Inner payload dict (authorization + signature). x402Client wraps this with x402_version, accepted, resource, extensions. """ + signer = self._resolve_signer_for_network(str(requirements.network)) + extra = requirements.extra or {} + asset_transfer_method = extra.get("assetTransferMethod", "eip3009") + if asset_transfer_method == "permit2": + payload = create_permit2_payload(signer, requirements) + extensions = _try_build_gas_sponsoring_extensions( + signer, requirements, payload, context + ) + if extensions: + return payload, extensions + return payload + nonce = create_nonce() valid_after, valid_before = create_validity_window( timedelta(seconds=requirements.max_timeout_seconds or 3600) ) authorization = ExactEIP3009Authorization( - from_address=self._signer.address, + from_address=signer.address, to=requirements.pay_to, value=requirements.amount, valid_after=str(valid_after), @@ -81,7 +113,7 @@ def create_payment_payload( nonce=nonce, ) - signature = self._sign_authorization(authorization, requirements) + signature = self._sign_authorization(signer, authorization, requirements) payload = ExactEIP3009Payload(authorization=authorization, signature=signature) @@ -90,6 +122,7 @@ def create_payment_payload( def _sign_authorization( self, + signer: ClientEvmSigner, authorization: ExactEIP3009Authorization, requirements: PaymentRequirements, ) -> str: @@ -140,6 +173,205 @@ def _sign_authorization( TypedDataField(name=f["name"], type=f["type"]) for f in fields ] - sig_bytes = self._signer.sign_typed_data(domain, typed_fields, primary_type, message) + sig_bytes = signer.sign_typed_data(domain, typed_fields, primary_type, message) return "0x" + sig_bytes.hex() + + def _resolve_signer_for_network(self, network: str) -> ClientEvmSigner: + """Resolve signer for a specific network. + + For raw LocalAccount inputs, this creates a network-specific wrapped signer + (cached by network) so RPC selection can follow per-network defaults. + """ + try: + from eth_account.signers.local import LocalAccount + + if isinstance(self._raw_signer, LocalAccount): + if network not in self._network_signers: + self._network_signers[network] = _wrap_if_local_account( + self._raw_signer, network + ) + return self._network_signers[network] + except ImportError: + pass + + return self._signer + + +def _try_build_gas_sponsoring_extensions( + signer: ClientEvmSigner, + requirements: PaymentRequirements, + payload: dict[str, Any], + context: PaymentPayloadContext | None, +) -> dict[str, Any] | None: + if context is None or not context.extensions: + return None + + if EIP2612_GAS_SPONSORING.key in context.extensions: + eip2612 = _try_build_eip2612_extension(signer, requirements, payload) + if eip2612: + return {EIP2612_GAS_SPONSORING.key: {"info": eip2612, "schema": {}}} + + if ERC20_APPROVAL_GAS_SPONSORING.key in context.extensions: + erc20 = _try_build_erc20_approval_extension(signer, requirements) + if erc20: + return {ERC20_APPROVAL_GAS_SPONSORING.key: {"info": erc20, "schema": {}}} + + return None + + +def _try_build_eip2612_extension( + signer: ClientEvmSigner, + requirements: PaymentRequirements, + payload: dict[str, Any], +) -> dict[str, Any] | None: + if not hasattr(signer, "read_contract"): + return None + extra = requirements.extra or {} + token_name = extra.get("name") + token_version = extra.get("version") + if not token_name or not token_version: + return None + + permit2_auth = payload.get("permit2Authorization") or {} + deadline = permit2_auth.get("deadline") + if not deadline: + return None + + token_address = requirements.asset + permit2_address = get_permit2_address(str(requirements.network)) + try: + allowance = signer.read_contract( + token_address, ERC20_ALLOWANCE_ABI, "allowance", signer.address, permit2_address + ) + if int(allowance) >= int(requirements.amount): + return None + except Exception: + pass + try: + nonce = signer.read_contract(token_address, EIP2612_NONCES_ABI, "nonces", signer.address) + except Exception: + return None + + chain_id = get_evm_chain_id(str(requirements.network)) + domain = { + "name": token_name, + "version": token_version, + "chainId": chain_id, + "verifyingContract": token_address, + } + message = { + "owner": signer.address, + "spender": permit2_address, + "value": int(requirements.amount), + "nonce": int(nonce), + "deadline": int(deadline), + } + sig = signer.sign_typed_data( + domain=domain, + types={ + k: [TypedDataField(name=f["name"], type=f["type"]) for f in v] + for k, v in EIP2612_PERMIT_TYPES.items() + }, + primary_type="Permit", + message=message, + ) + + return { + "from": signer.address, + "asset": token_address, + "spender": permit2_address, + "amount": str(requirements.amount), + "nonce": str(nonce), + "deadline": str(deadline), + "signature": "0x" + sig.hex(), + "version": "1", + } + + +def _try_build_erc20_approval_extension( + signer: ClientEvmSigner, + requirements: PaymentRequirements, +) -> dict[str, Any] | None: + if not hasattr(signer, "sign_transaction") or not hasattr(signer, "get_transaction_count"): + return None + + token_address = requirements.asset + permit2_address = get_permit2_address(str(requirements.network)) + # Best-effort allowance check if available + if hasattr(signer, "read_contract"): + try: + allowance = signer.read_contract( + token_address, ERC20_ALLOWANCE_ABI, "allowance", signer.address, permit2_address + ) + if int(allowance) >= int(requirements.amount): + return None + except Exception: + pass + + data = _encode_erc20_approve(permit2_address) + nonce = signer.get_transaction_count(signer.address) + chain_id = get_evm_chain_id(str(requirements.network)) + tx: dict[str, Any] = { + "to": token_address, + "data": data, + "value": 0, + "nonce": nonce, + "gas": ERC20_APPROVE_GAS_LIMIT, + "chainId": chain_id, + } + fees = None + if hasattr(signer, "estimate_fees_per_gas"): + try: + fees = signer.estimate_fees_per_gas() + except Exception: + fees = None + if fees: + max_fee, max_priority_fee = fees + tx["maxFeePerGas"] = max_fee + tx["maxPriorityFeePerGas"] = max_priority_fee + elif hasattr(signer, "get_gas_price"): + try: + tx["gasPrice"] = signer.get_gas_price() + except Exception: + tx["maxFeePerGas"] = DEFAULT_MAX_FEE_PER_GAS + tx["maxPriorityFeePerGas"] = DEFAULT_MAX_PRIORITY_FEE_PER_GAS + else: + tx["maxFeePerGas"] = DEFAULT_MAX_FEE_PER_GAS + tx["maxPriorityFeePerGas"] = DEFAULT_MAX_PRIORITY_FEE_PER_GAS + signed = signer.sign_transaction(tx) + signed_hex = _normalize_signed_tx(signed) + return { + "from": signer.address, + "asset": token_address, + "spender": permit2_address, + "amount": str(2**256 - 1), + "signedTransaction": signed_hex, + "version": "1", + } + + +def _encode_erc20_approve(spender: str) -> str: + try: + from eth_abi import encode + from eth_utils import keccak + + selector = keccak(text="approve(address,uint256)")[:4] + data = encode(["address", "uint256"], [spender, 2**256 - 1]) + return "0x" + (selector + data).hex() + except Exception as e: + raise ValueError(f"failed to encode approve: {e}") from e + + +def _normalize_signed_tx(signed: Any) -> str: + if isinstance(signed, bytes): + return "0x" + signed.hex() + if isinstance(signed, str): + return signed if signed.startswith("0x") else "0x" + signed + raw = getattr(signed, "rawTransaction", None) + if raw is not None: + if isinstance(raw, bytes): + return "0x" + raw.hex() + if isinstance(raw, str): + return raw if raw.startswith("0x") else "0x" + raw + raise ValueError("Unsupported signed transaction format") diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/exact/facilitator.py b/python/x402/src/bankofai/x402/mechanisms/evm/exact/facilitator.py index ecf3b070..4a2fa70e 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/exact/facilitator.py +++ b/python/x402/src/bankofai/x402/mechanisms/evm/exact/facilitator.py @@ -36,9 +36,15 @@ from ..eip712 import hash_eip3009_authorization from ..erc6492 import has_deployment_info, parse_erc6492_signature from ..signer import FacilitatorEvmSigner -from ..types import ERC6492SignatureData, ExactEIP3009Payload +from ..types import ( + ERC6492SignatureData, + ExactEIP3009Payload, + ExactPermit2Payload, + is_permit2_payload, +) from ..utils import bytes_to_hex, get_evm_chain_id, hex_to_bytes, normalize_address from ..verify import verify_universal_signature +from .permit2 import settle_permit2, verify_permit2 @dataclass @@ -77,14 +83,17 @@ def __init__( self._config = config or ExactEvmSchemeConfig() def get_extra(self, network: Network) -> dict[str, Any] | None: - """Get mechanism-specific extra data. EVM: None. + """Get mechanism-specific extra data. Args: network: Network identifier. Returns: - None for EVM scheme. + Extra metadata for Permit2 facilitator address. """ + signers = self._signer.get_addresses() + if signers: + return {"permit2FacilitatorAddress": signers[0]} return None def get_signers(self, network: Network) -> list[str]: @@ -122,7 +131,12 @@ def verify( Returns: VerifyResponse with is_valid and payer. """ - evm_payload = ExactEIP3009Payload.from_dict(payload.payload) + raw_payload = payload.payload or {} + if is_permit2_payload(raw_payload): + permit2_payload = ExactPermit2Payload.from_dict(raw_payload) + return verify_permit2(self._signer, payload, requirements, permit2_payload, context) + + evm_payload = ExactEIP3009Payload.from_dict(raw_payload) payer = evm_payload.authorization.from_address network = str(requirements.network) @@ -257,6 +271,11 @@ def settle( SettleResponse with success, transaction, and payer. """ # First verify + raw_payload = payload.payload or {} + if is_permit2_payload(raw_payload): + permit2_payload = ExactPermit2Payload.from_dict(raw_payload) + return settle_permit2(self._signer, payload, requirements, permit2_payload, context) + verify_result = self.verify(payload, requirements, context) if not verify_result.is_valid: return SettleResponse( diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/exact/permit2.py b/python/x402/src/bankofai/x402/mechanisms/evm/exact/permit2.py new file mode 100644 index 00000000..dd1719c6 --- /dev/null +++ b/python/x402/src/bankofai/x402/mechanisms/evm/exact/permit2.py @@ -0,0 +1,698 @@ +"""EVM Permit2 client + facilitator logic for the Exact payment scheme.""" + +from __future__ import annotations + +import time +from typing import Any + +from eth_account import Account +from eth_account.typed_transactions import TypedTransaction +from hexbytes import HexBytes + +from ....extensions.eip2612_gas_sponsoring import ( + extract_eip2612_gas_sponsoring_info, + validate_eip2612_gas_sponsoring_info, +) +from ....extensions.erc20_approval_gas_sponsoring import ( + ERC20_APPROVAL_GAS_SPONSORING, + Erc20ApprovalGasSponsoringExtension, + extract_erc20_approval_gas_sponsoring_info, + validate_erc20_approval_gas_sponsoring_info, +) +from ....interfaces import FacilitatorContext +from ....schemas import PaymentPayload, PaymentRequirements, SettleResponse, VerifyResponse +from ..constants import ( + BALANCE_OF_ABI, + ERC20_ALLOWANCE_ABI, + ERR_EIP2612_ASSET_MISMATCH, + ERR_EIP2612_DEADLINE_EXPIRED, + ERR_EIP2612_EXTENSION_FORMAT, + ERR_EIP2612_FROM_MISMATCH, + ERR_EIP2612_SPENDER_NOT_PERMIT2, + ERR_ERC20_APPROVAL_ASSET_MISMATCH, + ERR_ERC20_APPROVAL_EXTENSION_FORMAT, + ERR_ERC20_APPROVAL_FROM_MISMATCH, + ERR_ERC20_APPROVAL_SPENDER_NOT_PERMIT2, + ERR_ERC20_APPROVAL_TX_FAILED, + ERR_ERC20_APPROVAL_TX_INVALID_CALLDATA, + ERR_ERC20_APPROVAL_TX_INVALID_SIGNATURE, + ERR_ERC20_APPROVAL_TX_PARSE_FAILED, + ERR_ERC20_APPROVAL_TX_SIGNER_MISMATCH, + ERR_ERC20_APPROVAL_TX_WRONG_SELECTOR, + ERR_ERC20_APPROVAL_TX_WRONG_SPENDER, + ERR_ERC20_APPROVAL_TX_WRONG_TARGET, + ERR_NETWORK_MISMATCH, + ERR_PERMIT2_2612_AMOUNT_MISMATCH, + ERR_PERMIT2_ALLOWANCE_REQUIRED, + ERR_PERMIT2_AMOUNT_MISMATCH, + ERR_PERMIT2_DEADLINE_EXPIRED, + ERR_PERMIT2_INVALID_AMOUNT, + ERR_PERMIT2_INVALID_DESTINATION, + ERR_PERMIT2_INVALID_FACILITATOR, + ERR_PERMIT2_INVALID_NONCE, + ERR_PERMIT2_INVALID_OWNER, + ERR_PERMIT2_INVALID_SIGNATURE, + ERR_PERMIT2_INVALID_SPENDER, + ERR_PERMIT2_NOT_YET_VALID, + ERR_PERMIT2_PAYMENT_TOO_EARLY, + ERR_PERMIT2_RECIPIENT_MISMATCH, + ERR_PERMIT2_TOKEN_MISMATCH, + ERR_TRANSACTION_FAILED, + ERR_UNSUPPORTED_SCHEME, + PERMIT2_WITNESS_TYPES, + get_permit2_address, + get_x402_exact_permit2_proxy_address, + x402ExactPermit2ProxyABI, +) +from ..signer import ClientEvmSigner, FacilitatorEvmSigner +from ..types import ( + ExactPermit2Payload, + Permit2Authorization, + Permit2Witness, + TypedDataDomain, + TypedDataField, +) +from ..utils import create_permit2_nonce, get_evm_chain_id, hex_to_bytes, normalize_address + + +def create_permit2_payload( + signer: ClientEvmSigner, + requirements: PaymentRequirements, +) -> dict[str, Any]: + """Create signed Permit2 payload (inner payload only).""" + now = int(time.time()) + nonce = create_permit2_nonce() + + valid_after = str(now - 600) + deadline = str(now + (requirements.max_timeout_seconds or 3600)) + + facilitator = (requirements.extra or {}).get("permit2FacilitatorAddress") + if not facilitator: + raise ValueError("Permit2 facilitator address required in payment requirements extra") + + authorization = Permit2Authorization( + from_address=normalize_address(signer.address), + permitted_token=normalize_address(requirements.asset), + permitted_amount=requirements.amount, + spender=normalize_address(get_x402_exact_permit2_proxy_address(str(requirements.network))), + nonce=nonce, + deadline=deadline, + witness=Permit2Witness( + to=normalize_address(requirements.pay_to), + facilitator=normalize_address(str(facilitator)), + valid_after=valid_after, + ), + ) + + signature = _sign_permit2_authorization(signer, authorization, requirements) + payload = ExactPermit2Payload(permit2_authorization=authorization, signature=signature) + return payload.to_dict() + + +def verify_permit2( + signer: FacilitatorEvmSigner, + payload: PaymentPayload, + requirements: PaymentRequirements, + permit2_payload: ExactPermit2Payload, + context: FacilitatorContext | None = None, +) -> VerifyResponse: + """Verify a Permit2 payment payload on EVM.""" + payer = permit2_payload.permit2_authorization.from_address + + if payload.accepted.scheme != "exact" or requirements.scheme != "exact": + return VerifyResponse(is_valid=False, invalid_reason=ERR_UNSUPPORTED_SCHEME, payer=payer) + + if payload.accepted.network != requirements.network: + return VerifyResponse(is_valid=False, invalid_reason=ERR_NETWORK_MISMATCH, payer=payer) + + network = str(requirements.network) + token_address = normalize_address(requirements.asset) + permit2_address = normalize_address(get_permit2_address(network)) + proxy_address = normalize_address(get_x402_exact_permit2_proxy_address(network)) + + if normalize_address(permit2_payload.permit2_authorization.spender) != proxy_address: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_INVALID_SPENDER, payer=payer + ) + + if normalize_address(permit2_payload.permit2_authorization.witness.to) != normalize_address( + requirements.pay_to + ): + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_RECIPIENT_MISMATCH, payer=payer + ) + + expected_facilitator = (requirements.extra or {}).get("permit2FacilitatorAddress") + if not expected_facilitator: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_INVALID_FACILITATOR, payer=payer + ) + if normalize_address( + permit2_payload.permit2_authorization.witness.facilitator + ) != normalize_address(str(expected_facilitator)): + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_INVALID_FACILITATOR, payer=payer + ) + + now = int(time.time()) + if int(permit2_payload.permit2_authorization.deadline) < now + 6: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_DEADLINE_EXPIRED, payer=payer + ) + + if int(permit2_payload.permit2_authorization.witness.valid_after) > now: + return VerifyResponse(is_valid=False, invalid_reason=ERR_PERMIT2_NOT_YET_VALID, payer=payer) + + if int(permit2_payload.permit2_authorization.permitted_amount) != int(requirements.amount): + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_AMOUNT_MISMATCH, payer=payer + ) + + if normalize_address(permit2_payload.permit2_authorization.permitted_token) != token_address: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_TOKEN_MISMATCH, payer=payer + ) + + typed_fields: dict[str, list[TypedDataField]] = {} + for type_name, fields in PERMIT2_WITNESS_TYPES.items(): + typed_fields[type_name] = [TypedDataField(name=f["name"], type=f["type"]) for f in fields] + + chain_id = get_evm_chain_id(network) + domain = TypedDataDomain( + name="Permit2", version=None, chain_id=chain_id, verifying_contract=permit2_address + ) + message = { + "permitted": { + "token": normalize_address(permit2_payload.permit2_authorization.permitted_token), + "amount": int(permit2_payload.permit2_authorization.permitted_amount), + }, + "spender": normalize_address(permit2_payload.permit2_authorization.spender), + "nonce": int(permit2_payload.permit2_authorization.nonce), + "deadline": int(permit2_payload.permit2_authorization.deadline), + "witness": { + "to": normalize_address(permit2_payload.permit2_authorization.witness.to), + "facilitator": normalize_address( + permit2_payload.permit2_authorization.witness.facilitator + ), + "validAfter": int(permit2_payload.permit2_authorization.witness.valid_after), + }, + } + + try: + is_valid = signer.verify_typed_data( + address=payer, + domain=domain, + types=typed_fields, + primary_type="PermitWitnessTransferFrom", + message=message, + signature=hex_to_bytes(permit2_payload.signature), + ) + if not is_valid: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_INVALID_SIGNATURE, payer=payer + ) + except Exception: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_INVALID_SIGNATURE, payer=payer + ) + + # Check allowance (best-effort) + try: + allowance = signer.read_contract( + token_address, ERC20_ALLOWANCE_ABI, "allowance", payer, permit2_address + ) + if int(allowance) < int(requirements.amount): + has_extension, extension_error = _verify_gas_sponsoring_extensions( + payload, requirements, payer, permit2_address, context + ) + if extension_error is not None: + return extension_error + if not has_extension: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_ALLOWANCE_REQUIRED, payer=payer + ) + except Exception: + has_extension, extension_error = _verify_gas_sponsoring_extensions( + payload, requirements, payer, permit2_address, context + ) + if extension_error is not None: + return extension_error + if not has_extension: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_ALLOWANCE_REQUIRED, payer=payer + ) + + # Check balance (best-effort) + try: + balance = signer.read_contract(token_address, BALANCE_OF_ABI, "balanceOf", payer) + if int(balance) < int(requirements.amount): + return VerifyResponse( + is_valid=False, + invalid_reason="insufficient_funds", + invalid_message=( + f"Insufficient funds. Required: {requirements.amount}, Available: {balance}" + ), + payer=payer, + ) + except Exception: + pass + + return VerifyResponse(is_valid=True, payer=payer) + + +def settle_permit2( + signer: FacilitatorEvmSigner, + payload: PaymentPayload, + requirements: PaymentRequirements, + permit2_payload: ExactPermit2Payload, + context: FacilitatorContext | None = None, +) -> SettleResponse: + """Settle a Permit2 payment on-chain.""" + payer = permit2_payload.permit2_authorization.from_address + verify_result = verify_permit2(signer, payload, requirements, permit2_payload, context) + if not verify_result.is_valid: + return SettleResponse( + success=False, + error_reason=verify_result.invalid_reason or ERR_UNSUPPORTED_SCHEME, + transaction="", + network=str(requirements.network), + payer=payer, + ) + + proxy_address = get_x402_exact_permit2_proxy_address(str(requirements.network)) + + eip2612_info = extract_eip2612_gas_sponsoring_info(payload.extensions) + if eip2612_info and validate_eip2612_gas_sponsoring_info(eip2612_info): + return _settle_with_eip2612(signer, payload, permit2_payload, eip2612_info) + + erc20_info = extract_erc20_approval_gas_sponsoring_info(payload.extensions) + if ( + erc20_info + and validate_erc20_approval_gas_sponsoring_info(erc20_info) + and context is not None + ): + extension = context.get_extension(ERC20_APPROVAL_GAS_SPONSORING.key) + if isinstance(extension, Erc20ApprovalGasSponsoringExtension) and extension.signer: + return _settle_with_erc20_approval( + extension.signer, payload, permit2_payload, erc20_info + ) + try: + tx = signer.write_contract( + proxy_address, + x402ExactPermit2ProxyABI, + "settle", + { + "permitted": { + "token": normalize_address( + permit2_payload.permit2_authorization.permitted_token + ), + "amount": int(permit2_payload.permit2_authorization.permitted_amount), + }, + "nonce": int(permit2_payload.permit2_authorization.nonce), + "deadline": int(permit2_payload.permit2_authorization.deadline), + }, + normalize_address(payer), + { + "to": normalize_address(permit2_payload.permit2_authorization.witness.to), + "facilitator": normalize_address( + permit2_payload.permit2_authorization.witness.facilitator + ), + "validAfter": int(permit2_payload.permit2_authorization.witness.valid_after), + }, + hex_to_bytes(permit2_payload.signature), + ) + + receipt = signer.wait_for_transaction_receipt(tx) + if receipt.status != 1: + return SettleResponse( + success=False, + error_reason="invalid_transaction_state", + transaction=tx, + network=str(requirements.network), + payer=payer, + ) + + return SettleResponse( + success=True, + transaction=tx, + network=str(requirements.network), + payer=payer, + ) + except Exception as e: + error_reason = _map_settle_error(str(e)) + return SettleResponse( + success=False, + error_reason=error_reason, + error_message=str(e), + transaction="", + network=str(requirements.network), + payer=payer, + ) + + +def _sign_permit2_authorization( + signer: ClientEvmSigner, + authorization: Permit2Authorization, + requirements: PaymentRequirements, +) -> str: + chain_id = get_evm_chain_id(str(requirements.network)) + permit2_address = normalize_address(get_permit2_address(str(requirements.network))) + + domain = TypedDataDomain( + name="Permit2", version=None, chain_id=chain_id, verifying_contract=permit2_address + ) + types = PERMIT2_WITNESS_TYPES + message = { + "permitted": { + "token": normalize_address(authorization.permitted_token), + "amount": int(authorization.permitted_amount), + }, + "spender": normalize_address(authorization.spender), + "nonce": int(authorization.nonce), + "deadline": int(authorization.deadline), + "witness": { + "to": normalize_address(authorization.witness.to), + "facilitator": normalize_address(authorization.witness.facilitator), + "validAfter": int(authorization.witness.valid_after), + }, + } + + typed_fields: dict[str, list[TypedDataField]] = {} + for type_name, fields in types.items(): + typed_fields[type_name] = [TypedDataField(name=f["name"], type=f["type"]) for f in fields] + + sig_bytes = signer.sign_typed_data( + domain=domain, + types=typed_fields, + primary_type="PermitWitnessTransferFrom", + message=message, + ) + return "0x" + sig_bytes.hex() + + +def _verify_gas_sponsoring_extensions( + payload: PaymentPayload, + requirements: PaymentRequirements, + payer: str, + permit2_address: str, + context: FacilitatorContext | None, +) -> tuple[bool, VerifyResponse | None]: + eip2612_info = extract_eip2612_gas_sponsoring_info(payload.extensions) + if eip2612_info: + if not validate_eip2612_gas_sponsoring_info(eip2612_info): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_EIP2612_EXTENSION_FORMAT, payer=payer + ) + if normalize_address(eip2612_info.from_address) != normalize_address(payer): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_EIP2612_FROM_MISMATCH, payer=payer + ) + if normalize_address(eip2612_info.asset) != normalize_address(requirements.asset): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_EIP2612_ASSET_MISMATCH, payer=payer + ) + if normalize_address(eip2612_info.spender) != normalize_address(permit2_address): + return True, VerifyResponse( + is_valid=False, + invalid_reason=ERR_EIP2612_SPENDER_NOT_PERMIT2, + payer=payer, + ) + now = int(time.time()) + if int(eip2612_info.deadline) < now + 6: + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_EIP2612_DEADLINE_EXPIRED, payer=payer + ) + return True, None + + erc20_info = extract_erc20_approval_gas_sponsoring_info(payload.extensions) + if not erc20_info: + return False, None + + if context is None or context.get_extension(ERC20_APPROVAL_GAS_SPONSORING.key) is None: + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_ALLOWANCE_REQUIRED, payer=payer + ) + + if not validate_erc20_approval_gas_sponsoring_info(erc20_info): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_ERC20_APPROVAL_EXTENSION_FORMAT, payer=payer + ) + if normalize_address(erc20_info.from_address) != normalize_address(payer): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_ERC20_APPROVAL_FROM_MISMATCH, payer=payer + ) + if normalize_address(erc20_info.asset) != normalize_address(requirements.asset): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_ERC20_APPROVAL_ASSET_MISMATCH, payer=payer + ) + if normalize_address(erc20_info.spender) != normalize_address(permit2_address): + return True, VerifyResponse( + is_valid=False, + invalid_reason=ERR_ERC20_APPROVAL_SPENDER_NOT_PERMIT2, + payer=payer, + ) + + tx_info = _parse_erc20_approval_signed_transaction(erc20_info.signed_transaction) + if tx_info is None: + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_ERC20_APPROVAL_TX_PARSE_FAILED, payer=payer + ) + + tx_to = tx_info.get("to") + if not tx_to or normalize_address(tx_to) != normalize_address(requirements.asset): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_ERC20_APPROVAL_TX_WRONG_TARGET, payer=payer + ) + + tx_data = str(tx_info.get("data", "")).lower() + if not tx_data.startswith("0x095ea7b3"): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_ERC20_APPROVAL_TX_WRONG_SELECTOR, payer=payer + ) + + decoded_spender = _decode_erc20_approval_spender(tx_data) + if decoded_spender is None: + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_ERC20_APPROVAL_TX_INVALID_CALLDATA, payer=payer + ) + if normalize_address(decoded_spender) != normalize_address(permit2_address): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_ERC20_APPROVAL_TX_WRONG_SPENDER, payer=payer + ) + + recovered = _recover_erc20_approval_signer(erc20_info.signed_transaction) + if recovered is None: + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_ERC20_APPROVAL_TX_INVALID_SIGNATURE, payer=payer + ) + if normalize_address(recovered) != normalize_address(payer): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_ERC20_APPROVAL_TX_SIGNER_MISMATCH, payer=payer + ) + return True, None + + +def _settle_with_eip2612( + signer: FacilitatorEvmSigner, + payload: PaymentPayload, + permit2_payload: ExactPermit2Payload, + info: Any, +) -> SettleResponse: + payer = permit2_payload.permit2_authorization.from_address + proxy_address = get_x402_exact_permit2_proxy_address(str(payload.accepted.network)) + try: + v, r, s = _split_eip2612_signature(str(info.signature)) + tx = signer.write_contract( + proxy_address, + x402ExactPermit2ProxyABI, + "settleWithPermit", + { + "value": int(info.amount), + "deadline": int(info.deadline), + "r": r, + "s": s, + "v": v, + }, + { + "permitted": { + "token": normalize_address( + permit2_payload.permit2_authorization.permitted_token + ), + "amount": int(permit2_payload.permit2_authorization.permitted_amount), + }, + "nonce": int(permit2_payload.permit2_authorization.nonce), + "deadline": int(permit2_payload.permit2_authorization.deadline), + }, + normalize_address(payer), + { + "to": normalize_address(permit2_payload.permit2_authorization.witness.to), + "facilitator": normalize_address( + permit2_payload.permit2_authorization.witness.facilitator + ), + "validAfter": int(permit2_payload.permit2_authorization.witness.valid_after), + }, + hex_to_bytes(permit2_payload.signature), + ) + receipt = signer.wait_for_transaction_receipt(tx) + if receipt.status != 1: + return SettleResponse( + success=False, + error_reason="invalid_transaction_state", + transaction=tx, + network=str(payload.accepted.network), + payer=payer, + ) + return SettleResponse( + success=True, + transaction=tx, + network=str(payload.accepted.network), + payer=payer, + ) + except Exception as e: + return SettleResponse( + success=False, + error_reason=_map_settle_error(str(e)), + error_message=str(e), + transaction="", + network=str(payload.accepted.network), + payer=payer, + ) + + +def _settle_with_erc20_approval( + signer: Any, + payload: PaymentPayload, + permit2_payload: ExactPermit2Payload, + info: Any, +) -> SettleResponse: + payer = permit2_payload.permit2_authorization.from_address + proxy_address = get_x402_exact_permit2_proxy_address(str(payload.accepted.network)) + try: + tx_hash = signer.send_raw_transaction(info.signed_transaction) + receipt = signer.wait_for_transaction_receipt(tx_hash) + if receipt.status != 1: + return SettleResponse( + success=False, + error_reason=ERR_ERC20_APPROVAL_TX_FAILED, + transaction=tx_hash, + network=str(payload.accepted.network), + payer=payer, + ) + + tx = signer.write_contract( + proxy_address, + x402ExactPermit2ProxyABI, + "settle", + { + "permitted": { + "token": normalize_address( + permit2_payload.permit2_authorization.permitted_token + ), + "amount": int(permit2_payload.permit2_authorization.permitted_amount), + }, + "nonce": int(permit2_payload.permit2_authorization.nonce), + "deadline": int(permit2_payload.permit2_authorization.deadline), + }, + normalize_address(payer), + { + "to": normalize_address(permit2_payload.permit2_authorization.witness.to), + "facilitator": normalize_address( + permit2_payload.permit2_authorization.witness.facilitator + ), + "validAfter": int(permit2_payload.permit2_authorization.witness.valid_after), + }, + hex_to_bytes(permit2_payload.signature), + ) + settle_receipt = signer.wait_for_transaction_receipt(tx) + if settle_receipt.status != 1: + return SettleResponse( + success=False, + error_reason="invalid_transaction_state", + transaction=tx, + network=str(payload.accepted.network), + payer=payer, + ) + return SettleResponse( + success=True, + transaction=tx, + network=str(payload.accepted.network), + payer=payer, + ) + except Exception as e: + return SettleResponse( + success=False, + error_reason=_map_settle_error(str(e)), + error_message=str(e), + transaction="", + network=str(payload.accepted.network), + payer=payer, + ) + + +def _split_eip2612_signature(signature: str) -> tuple[int, str, str]: + sig = signature.removeprefix("0x") + if len(sig) != 130: + raise ValueError("invalid EIP-2612 signature length") + r = "0x" + sig[:64] + s = "0x" + sig[64:128] + v = int(sig[128:130], 16) + return v, r, s + + +def _map_settle_error(message: str) -> str: + if "Permit2612AmountMismatch" in message: + return ERR_PERMIT2_2612_AMOUNT_MISMATCH + if "InvalidAmount" in message: + return ERR_PERMIT2_INVALID_AMOUNT + if "InvalidDestination" in message: + return ERR_PERMIT2_INVALID_DESTINATION + if "InvalidOwner" in message: + return ERR_PERMIT2_INVALID_OWNER + if "PaymentTooEarly" in message: + return ERR_PERMIT2_PAYMENT_TOO_EARLY + if "InvalidNonce" in message: + return ERR_PERMIT2_INVALID_NONCE + if "InvalidSignature" in message or "SignatureExpired" in message: + return ERR_PERMIT2_INVALID_SIGNATURE + return ERR_TRANSACTION_FAILED + + +def _parse_erc20_approval_signed_transaction(signed_tx: str) -> dict[str, str] | None: + try: + parsed = TypedTransaction.from_bytes(HexBytes(signed_tx)) + tx = parsed.as_dict() + to_val = tx.get("to") + data_val = tx.get("data") + + to_hex: str | None = None + if isinstance(to_val, (bytes, bytearray)): + to_hex = "0x" + bytes(to_val).hex() + elif isinstance(to_val, str): + to_hex = to_val + + data_hex: str | None = None + if isinstance(data_val, (bytes, bytearray)): + data_hex = "0x" + bytes(data_val).hex() + elif isinstance(data_val, str): + data_hex = data_val + + if not to_hex or data_hex is None: + return None + return {"to": to_hex, "data": data_hex} + except Exception: + return None + + +def _decode_erc20_approval_spender(data_hex: str) -> str | None: + cleaned = data_hex.removeprefix("0x") + if not cleaned.startswith("095ea7b3") or len(cleaned) < 8 + 64 + 64: + return None + spender_word = cleaned[8 : 8 + 64] + return "0x" + spender_word[24:].lower() + + +def _recover_erc20_approval_signer(signed_tx: str) -> str | None: + try: + return str(Account.recover_transaction(HexBytes(signed_tx))) + except Exception: + return None diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/exact/v1/client.py b/python/x402/src/bankofai/x402/mechanisms/evm/exact/v1/client.py index 26a771d6..3a5e9f8a 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/exact/v1/client.py +++ b/python/x402/src/bankofai/x402/mechanisms/evm/exact/v1/client.py @@ -46,6 +46,7 @@ def __init__(self, signer: ClientEvmSigner): def create_payment_payload( self, requirements: PaymentRequirementsV1, + context=None, ) -> dict[str, Any]: """Create signed EIP-3009 inner payload (V1 format). diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/signers.py b/python/x402/src/bankofai/x402/mechanisms/evm/signers.py index eec1bde9..d6a77a93 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/signers.py +++ b/python/x402/src/bankofai/x402/mechanisms/evm/signers.py @@ -21,6 +21,7 @@ from .constants import EIP1271_MAGIC_VALUE, IS_VALID_SIGNATURE_ABI, TX_STATUS_SUCCESS from .types import TransactionReceipt, TypedDataDomain, TypedDataField +from .utils import resolve_evm_rpc_url # ERC20 ABI for balance checks _ERC20_BALANCE_ABI = [ @@ -61,14 +62,25 @@ class EthAccountSigner: account: eth_account LocalAccount instance. """ - def __init__(self, account: LocalAccount) -> None: + def __init__( + self, + account: LocalAccount, + rpc_url: str | None = None, + network: str | None = None, + ) -> None: """Initialize signer with eth_account LocalAccount. Args: account: eth_account LocalAccount instance (from Account.from_key, Account.from_mnemonic, etc.). + rpc_url: Optional Ethereum RPC endpoint for nonce + gas data. + network: Optional CAIP-2 network (e.g., eip155:84532) used to + resolve chain-specific default RPC URL when rpc_url is not set. """ self._account = account + if rpc_url is None: + rpc_url = resolve_evm_rpc_url(network) + self._w3 = Web3(Web3.HTTPProvider(rpc_url)) if rpc_url else None @property def address(self) -> str: @@ -110,10 +122,11 @@ def sign_typed_data( if isinstance(domain, TypedDataDomain): domain_dict = { "name": domain.name, - "version": domain.version, "chainId": domain.chain_id, "verifyingContract": domain.verifying_contract, } + if domain.version: + domain_dict["version"] = domain.version else: domain_dict = domain @@ -125,6 +138,46 @@ def sign_typed_data( ) return bytes(signed.signature) + def get_transaction_count(self, address: str) -> int: + """Get transaction count (nonce) for address.""" + if not self._w3: + raise ValueError("RPC URL required for get_transaction_count") + return int(self._w3.eth.get_transaction_count(Web3.to_checksum_address(address))) + + def get_gas_price(self) -> int: + """Get current gas price for legacy transactions.""" + if not self._w3: + raise ValueError("RPC URL required for get_gas_price") + return int(self._w3.eth.gas_price) + + def estimate_fees_per_gas(self) -> tuple[int, int] | None: + """Estimate EIP-1559 fees (maxFeePerGas, maxPriorityFeePerGas). + + Returns None if the connected network does not expose baseFeePerGas. + """ + if not self._w3: + raise ValueError("RPC URL required for estimate_fees_per_gas") + try: + block = self._w3.eth.get_block("pending") + base_fee = block.get("baseFeePerGas") + if base_fee is None: + return None + max_priority_fee = int(self._w3.eth.max_priority_fee) + max_fee = int(base_fee) * 2 + max_priority_fee + return max_fee, max_priority_fee + except Exception: + return None + + def sign_transaction(self, tx: dict[str, Any]) -> bytes: + """Sign an EIP-1559 transaction dict and return raw bytes.""" + signed = self._account.sign_transaction(tx) + raw = getattr(signed, "rawTransaction", None) + if raw is None: + raw = getattr(signed, "raw_transaction", None) + if raw is None: + raise AttributeError("SignedTransaction missing raw transaction bytes") + return raw if isinstance(raw, (bytes, bytearray)) else bytes(raw) + class FacilitatorWeb3Signer: """Facilitator-side EVM signer using web3.py. @@ -249,14 +302,15 @@ def verify_typed_data( True if signature is valid. """ # Build full types including EIP712Domain - full_types: dict[str, list[dict[str, str]]] = { - "EIP712Domain": [ - {"name": "name", "type": "string"}, - {"name": "version", "type": "string"}, - {"name": "chainId", "type": "uint256"}, - {"name": "verifyingContract", "type": "address"}, - ] - } + domain_fields = [ + {"name": "name", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ] + if domain.version: + domain_fields.insert(1, {"name": "version", "type": "string"}) + + full_types: dict[str, list[dict[str, str]]] = {"EIP712Domain": domain_fields} for type_name, fields in types.items(): full_types[type_name] = [ {"name": f.name, "type": f.type} if isinstance(f, TypedDataField) else f @@ -269,15 +323,18 @@ def verify_typed_data( msg_copy["nonce"] = "0x" + msg_copy["nonce"].hex() try: + domain_dict = { + "name": domain.name, + "chainId": domain.chain_id, + "verifyingContract": domain.verifying_contract, + } + if domain.version: + domain_dict["version"] = domain.version + typed_data = { "types": full_types, "primaryType": primary_type, - "domain": { - "name": domain.name, - "version": domain.version, - "chainId": domain.chain_id, - "verifyingContract": domain.verifying_contract, - }, + "domain": domain_dict, "message": msg_copy, } diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/types.py b/python/x402/src/bankofai/x402/mechanisms/evm/types.py index e6d18f55..2c5e832c 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/types.py +++ b/python/x402/src/bankofai/x402/mechanisms/evm/types.py @@ -67,9 +67,88 @@ def from_dict(cls, data: dict[str, Any]) -> "ExactEIP3009Payload": ) +@dataclass +class Permit2Witness: + """Permit2 witness data structure.""" + + to: str + facilitator: str + valid_after: str + + +@dataclass +class Permit2Authorization: + """Permit2 authorization parameters.""" + + from_address: str + permitted_token: str + permitted_amount: str + spender: str + nonce: str + deadline: str + witness: Permit2Witness + + +@dataclass +class ExactPermit2Payload: + """Permit2 payload for tokens using the Permit2 + x402Permit2Proxy flow.""" + + permit2_authorization: Permit2Authorization + signature: str + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + "signature": self.signature, + "permit2Authorization": { + "from": self.permit2_authorization.from_address, + "permitted": { + "token": self.permit2_authorization.permitted_token, + "amount": self.permit2_authorization.permitted_amount, + }, + "spender": self.permit2_authorization.spender, + "nonce": self.permit2_authorization.nonce, + "deadline": self.permit2_authorization.deadline, + "witness": { + "to": self.permit2_authorization.witness.to, + "facilitator": self.permit2_authorization.witness.facilitator, + "validAfter": self.permit2_authorization.witness.valid_after, + }, + }, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ExactPermit2Payload": + """Create from dictionary.""" + auth = data.get("permit2Authorization", {}) + permitted = auth.get("permitted", {}) + witness = auth.get("witness", {}) + return cls( + permit2_authorization=Permit2Authorization( + from_address=auth.get("from", ""), + permitted_token=permitted.get("token", ""), + permitted_amount=permitted.get("amount", ""), + spender=auth.get("spender", ""), + nonce=auth.get("nonce", ""), + deadline=auth.get("deadline", ""), + witness=Permit2Witness( + to=witness.get("to", ""), + facilitator=witness.get("facilitator", ""), + valid_after=witness.get("validAfter", ""), + ), + ), + signature=data.get("signature", ""), + ) + + # Type aliases for V1/V2 compatibility ExactEvmPayloadV1 = ExactEIP3009Payload -ExactEvmPayloadV2 = ExactEIP3009Payload +ExactEvmPayloadV2 = ExactEIP3009Payload | ExactPermit2Payload + + +def is_permit2_payload(data: dict[str, Any]) -> bool: + """Return True if raw payload dict is a Permit2 payload.""" + return "permit2Authorization" in data @dataclass @@ -77,7 +156,7 @@ class TypedDataDomain: """EIP-712 domain separator.""" name: str - version: str + version: str | None chain_id: int verifying_contract: str diff --git a/python/x402/src/bankofai/x402/mechanisms/evm/utils.py b/python/x402/src/bankofai/x402/mechanisms/evm/utils.py index 223ca528..7752add7 100644 --- a/python/x402/src/bankofai/x402/mechanisms/evm/utils.py +++ b/python/x402/src/bankofai/x402/mechanisms/evm/utils.py @@ -94,6 +94,45 @@ def get_asset_info(network: str, asset_address: str) -> AssetInfo: raise ValueError(f"Token {asset_address} is not a registered asset for network {network}.") +def resolve_evm_rpc_url( + network: str | None, + explicit_rpc_url: str | None = None, +) -> str | None: + """Resolve RPC URL for an EVM network with deterministic precedence. + + Precedence: + 1. explicit_rpc_url argument + 2. EVM_RPC_URL_ (for e.g., EVM_RPC_URL_97) + 3. EVM_RPC_URL / WEB3_PROVIDER_URL + 4. network config default_rpc_url + 5. None + """ + if explicit_rpc_url: + return explicit_rpc_url + + chain_id: str | None = None + if network and network.startswith("eip155:"): + parts = network.split(":", 1) + if len(parts) == 2 and parts[1].isdigit(): + chain_id = parts[1] + + if chain_id: + chain_specific = os.environ.get(f"EVM_RPC_URL_{chain_id}") + if chain_specific: + return chain_specific + + generic = os.environ.get("EVM_RPC_URL") or os.environ.get("WEB3_PROVIDER_URL") + if generic: + return generic + + if network: + config = NETWORK_CONFIGS.get(network) + if config: + return config.get("default_rpc_url") + + return None + + def is_valid_network(network: str) -> bool: """Check if network is a valid eip155 network identifier. @@ -121,6 +160,11 @@ def create_nonce() -> str: return "0x" + os.urandom(32).hex() +def create_permit2_nonce() -> str: + """Generate random uint256 nonce for Permit2 as a decimal string.""" + return str(int.from_bytes(os.urandom(32), "big")) + + def normalize_address(address: str) -> str: """Normalize Ethereum address to checksummed format. diff --git a/python/x402/src/bankofai/x402/mechanisms/svm/exact/client.py b/python/x402/src/bankofai/x402/mechanisms/svm/exact/client.py index 21037c0a..abc75ce1 100644 --- a/python/x402/src/bankofai/x402/mechanisms/svm/exact/client.py +++ b/python/x402/src/bankofai/x402/mechanisms/svm/exact/client.py @@ -85,6 +85,7 @@ def _get_client(self, network: str) -> SolanaClient: def create_payment_payload( self, requirements: PaymentRequirements, + context=None, ) -> dict[str, Any]: """Create signed SPL TransferChecked inner payload. diff --git a/python/x402/src/bankofai/x402/mechanisms/svm/exact/v1/client.py b/python/x402/src/bankofai/x402/mechanisms/svm/exact/v1/client.py index 0058f3fd..5886af80 100644 --- a/python/x402/src/bankofai/x402/mechanisms/svm/exact/v1/client.py +++ b/python/x402/src/bankofai/x402/mechanisms/svm/exact/v1/client.py @@ -89,6 +89,7 @@ def _get_client(self, network: str) -> SolanaClient: def create_payment_payload( self, requirements: PaymentRequirementsV1, + context=None, ) -> dict[str, Any]: """Create signed SPL TransferChecked inner payload (V1 format). diff --git a/python/x402/src/bankofai/x402/mechanisms/tron/__init__.py b/python/x402/src/bankofai/x402/mechanisms/tron/__init__.py index 77cdda5e..f3aedebc 100644 --- a/python/x402/src/bankofai/x402/mechanisms/tron/__init__.py +++ b/python/x402/src/bankofai/x402/mechanisms/tron/__init__.py @@ -2,9 +2,16 @@ from .constants import ( AUTHORIZATION_TYPES, + PERMIT2_ADDRESSES, + PERMIT2_WITNESS_TYPES, SCHEME_EXACT, TRON_CHAIN_IDS, TRON_DEFAULT_ASSETS, + TRON_NETWORK_CONFIGS, + X402_PERMIT2_PROXY_ADDRESSES, + AssetInfo, + NetworkConfig, + x402ExactPermit2ProxyABI, ) from .exact import ( ExactTronClientScheme, @@ -14,22 +21,62 @@ register_exact_tron_facilitator, register_exact_tron_server, ) -from .signers import ClientTronSigner, FacilitatorTronSigner -from .utils import get_tron_chain_id, normalize_address_for_signing, tron_address_to_evm +from .signer import ClientTronSigner, FacilitatorTronSigner +from .signers import ClientTronSigner as ClientTronSignerImpl +from .signers import FacilitatorTronSigner as FacilitatorTronSignerImpl +from .types import ( + ExactEIP3009Authorization, + ExactEIP3009Payload, + ExactPermit2Payload, + ExactTronPayloadV1, + ExactTronPayloadV2, + Permit2Authorization, + Permit2Witness, + is_permit2_payload, +) +from .utils import ( + create_nonce, + get_asset_info, + get_network_config, + get_tron_chain_id, + normalize_address_for_signing, + tron_address_to_evm, +) __all__ = [ # Constants "TRON_CHAIN_IDS", "TRON_DEFAULT_ASSETS", + "TRON_NETWORK_CONFIGS", "AUTHORIZATION_TYPES", + "PERMIT2_WITNESS_TYPES", "SCHEME_EXACT", + "PERMIT2_ADDRESSES", + "X402_PERMIT2_PROXY_ADDRESSES", + "x402ExactPermit2ProxyABI", + "AssetInfo", + "NetworkConfig", # Signers "FacilitatorTronSigner", "ClientTronSigner", + "FacilitatorTronSignerImpl", + "ClientTronSignerImpl", # Utils "get_tron_chain_id", + "get_network_config", + "get_asset_info", + "create_nonce", "normalize_address_for_signing", "tron_address_to_evm", + # Types + "ExactEIP3009Authorization", + "ExactEIP3009Payload", + "ExactPermit2Payload", + "ExactTronPayloadV1", + "ExactTronPayloadV2", + "Permit2Authorization", + "Permit2Witness", + "is_permit2_payload", # Schemes "ExactTronClientScheme", "ExactTronFacilitatorScheme", diff --git a/python/x402/src/bankofai/x402/mechanisms/tron/constants.py b/python/x402/src/bankofai/x402/mechanisms/tron/constants.py index 6f233780..143f5d58 100644 --- a/python/x402/src/bankofai/x402/mechanisms/tron/constants.py +++ b/python/x402/src/bankofai/x402/mechanisms/tron/constants.py @@ -1,6 +1,15 @@ -"""TRON constants for the exact payment scheme.""" +"""TRON mechanism constants - network configs, ABIs, error codes.""" -from typing import Any +from typing import Any, TypedDict + +# Scheme identifier +SCHEME_EXACT = "exact" + +# Default validity period (1 hour in seconds) +DEFAULT_VALIDITY_PERIOD = 3600 + +# Default validity buffer (10 minutes before now for clock skew) +DEFAULT_VALIDITY_BUFFER = 600 # TRON chain IDs for TIP-712 signing TRON_CHAIN_IDS: dict[str, int] = { @@ -9,28 +18,66 @@ "tron:nile": 3448148188, # 0xcd8690dc } -# Default stablecoins per network (USDT) -TRON_DEFAULT_ASSETS: dict[str, dict[str, Any]] = { + +class _AssetInfoRequired(TypedDict): + address: str + name: str + version: str + decimals: int + + +class AssetInfo(_AssetInfoRequired, total=False): + asset_transfer_method: str + + +class _NetworkConfigRequired(TypedDict): + chain_id: int + + +class NetworkConfig(_NetworkConfigRequired, total=False): + default_asset: AssetInfo + + +TRON_NETWORK_CONFIGS: dict[str, NetworkConfig] = { "tron:mainnet": { - "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", - "name": "Tether USD", - "version": "1", - "decimals": 6, + "chain_id": TRON_CHAIN_IDS["tron:mainnet"], + "default_asset": { + "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "name": "Tether USD", + "version": "1", + "decimals": 6, + }, }, "tron:nile": { - "address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", - "name": "Tether USD", - "version": "1", - "decimals": 6, + "chain_id": TRON_CHAIN_IDS["tron:nile"], + "default_asset": { + "address": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + "name": "Tether USD", + "version": "1", + "decimals": 6, + }, }, "tron:shasta": { - "address": "TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs", - "name": "Tether USD", - "version": "1", - "decimals": 6, + "chain_id": TRON_CHAIN_IDS["tron:shasta"], + "default_asset": { + "address": "TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs", + "name": "Tether USD", + "version": "1", + "decimals": 6, + }, }, } +# Default fee limit used for TRON contract calls (1,000 TRX). +DEFAULT_FEE_LIMIT_SUN = 1_000_000_000 + +# Backwards-compatible alias for defaults +TRON_DEFAULT_ASSETS: dict[str, dict[str, Any]] = { + network: config["default_asset"] # type: ignore[index] + for network, config in TRON_NETWORK_CONFIGS.items() + if config.get("default_asset") +} + # TIP-712 type definitions for TransferWithAuthorization AUTHORIZATION_TYPES: dict[str, list[dict[str, str]]] = { "TransferWithAuthorization": [ @@ -43,8 +90,43 @@ ] } -SCHEME_EXACT = "exact" -DEFAULT_FEE_LIMIT_SUN = 1_000_000_000 # 1000 TRX +# TIP-712 type definitions for Permit2 PermitWitnessTransferFrom +PERMIT2_WITNESS_TYPES: dict[str, list[dict[str, str]]] = { + "PermitWitnessTransferFrom": [ + {"name": "permitted", "type": "TokenPermissions"}, + {"name": "spender", "type": "address"}, + {"name": "nonce", "type": "uint256"}, + {"name": "deadline", "type": "uint256"}, + {"name": "witness", "type": "Witness"}, + ], + "TokenPermissions": [ + {"name": "token", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "Witness": [ + {"name": "to", "type": "address"}, + {"name": "facilitator", "type": "address"}, + {"name": "validAfter", "type": "uint256"}, + ], +} + +# Permit2 contract addresses per TRON network +PERMIT2_ADDRESSES: dict[str, str] = { + "tron:mainnet": "TTJxU3P8rHycAyFY4kVtGNfmnMH4ezcuM9", + "tron:nile": "TYQuuhGbEMxF7nZxUHV3uHJxAVVAegNU9h", +} + +# x402ExactPermit2Proxy contract addresses +X402_PERMIT2_PROXY_ADDRESSES: dict[str, str] = { + "tron:mainnet": "TSm6MSWHHBeABh22uqX7SU7QUweav4Cyy6", + "tron:nile": "TCd2ZSwbJBAdgFfP5d3gkhKcGs47WNZLLi", +} + +# x402UptoPermit2Proxy contract addresses +X402_UPTO_PERMIT2_PROXY_ADDRESSES: dict[str, str] = { + "tron:mainnet": "TGHEYAovw8fZz1bgnVgRtgrdGLbagFZYq5", + "tron:nile": "TSForFRqxmZdJ6Yfx2rNaFykhuQLc9cTMR", +} # Error messages ERR_INVALID_SCHEME = "invalid_scheme" @@ -72,40 +154,67 @@ ERR_PERMIT2_INVALID_SIGNATURE = "permit2_invalid_signature" ERR_PERMIT2_ALLOWANCE_REQUIRED = "permit2_allowance_required" -# Permit2 contract addresses per TRON network -PERMIT2_ADDRESSES: dict[str, str] = { - "tron:mainnet": "TTJxU3P8rHycAyFY4kVtGNfmnMH4ezcuM9", - "tron:nile": "TYQuuhGbEMxF7nZxUHV3uHJxAVVAegNU9h", -} +# TRC-20 approval gas sponsoring errors +ERR_TRC20_APPROVAL_FORMAT = "invalid_trc20_approval_format" +ERR_TRC20_APPROVAL_FROM_MISMATCH = "invalid_trc20_approval_from_mismatch" +ERR_TRC20_APPROVAL_ASSET_MISMATCH = "invalid_trc20_approval_asset_mismatch" +ERR_TRC20_APPROVAL_SPENDER_NOT_PERMIT2 = "invalid_trc20_approval_spender_not_permit2" +ERR_TRC20_APPROVAL_TX_MISSING_DATA = "invalid_trc20_approval_tx_missing_data" +ERR_TRC20_APPROVAL_TX_WRONG_TARGET = "invalid_trc20_approval_tx_wrong_target" +ERR_TRC20_APPROVAL_TX_WRONG_SELECTOR = "invalid_trc20_approval_tx_wrong_selector" +ERR_TRC20_APPROVAL_TX_WRONG_SPENDER = "invalid_trc20_approval_tx_wrong_spender" +ERR_TRC20_APPROVAL_TX_WRONG_AMOUNT = "invalid_trc20_approval_tx_wrong_amount" +ERR_TRC20_APPROVAL_TX_INVALID_SIGNATURE = "invalid_trc20_approval_tx_invalid_signature" -# x402ExactPermit2Proxy contract addresses -X402_PERMIT2_PROXY_ADDRESSES: dict[str, str] = { - "tron:mainnet": "TSm6MSWHHBeABh22uqX7SU7QUweav4Cyy6", - "tron:nile": "TCd2ZSwbJBAdgFfP5d3gkhKcGs47WNZLLi", -} +# x402ExactPermit2Proxy ABI - settle function for exact payment scheme. +x402ExactPermit2ProxyABI = [ + { + "type": "function", + "name": "settle", + "inputs": [ + { + "name": "permit", + "type": "tuple", + "components": [ + { + "name": "permitted", + "type": "tuple", + "components": [ + {"name": "token", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + }, + {"name": "nonce", "type": "uint256"}, + {"name": "deadline", "type": "uint256"}, + ], + }, + {"name": "owner", "type": "address"}, + { + "name": "witness", + "type": "tuple", + "components": [ + {"name": "to", "type": "address"}, + {"name": "facilitator", "type": "address"}, + {"name": "validAfter", "type": "uint256"}, + ], + }, + {"name": "signature", "type": "bytes"}, + ], + "outputs": [], + "stateMutability": "nonpayable", + } +] -# x402UptoPermit2Proxy contract addresses -X402_UPTO_PERMIT2_PROXY_ADDRESSES: dict[str, str] = { - "tron:mainnet": "TGHEYAovw8fZz1bgnVgRtgrdGLbagFZYq5", - "tron:nile": "TSForFRqxmZdJ6Yfx2rNaFykhuQLc9cTMR", -} - -# TIP-712 type definitions for Permit2 PermitWitnessTransferFrom -PERMIT2_WITNESS_TYPES: dict[str, list[dict[str, str]]] = { - "PermitWitnessTransferFrom": [ - {"name": "permitted", "type": "TokenPermissions"}, - {"name": "spender", "type": "address"}, - {"name": "nonce", "type": "uint256"}, - {"name": "deadline", "type": "uint256"}, - {"name": "witness", "type": "Witness"}, - ], - "TokenPermissions": [ - {"name": "token", "type": "address"}, - {"name": "amount", "type": "uint256"}, - ], - "Witness": [ - {"name": "to", "type": "address"}, - {"name": "facilitator", "type": "address"}, - {"name": "validAfter", "type": "uint256"}, - ], -} +# TRC-20 allowance ABI (view) for Permit2 approvals. +TRC20_ALLOWANCE_ABI = [ + { + "type": "function", + "name": "allowance", + "stateMutability": "view", + "inputs": [ + {"name": "owner", "type": "address"}, + {"name": "spender", "type": "address"}, + ], + "outputs": [{"name": "allowance", "type": "uint256"}], + } +] diff --git a/python/x402/src/bankofai/x402/mechanisms/tron/exact/client.py b/python/x402/src/bankofai/x402/mechanisms/tron/exact/client.py index 478d723c..eeae7eff 100644 --- a/python/x402/src/bankofai/x402/mechanisms/tron/exact/client.py +++ b/python/x402/src/bankofai/x402/mechanisms/tron/exact/client.py @@ -5,6 +5,8 @@ import time from typing import Any +from ....extensions.trc20_approval_gas_sponsoring import TRC20_APPROVAL_GAS_SPONSORING +from ....interfaces import PaymentPayloadContext from ....schemas import PaymentRequirements from ..constants import ( AUTHORIZATION_TYPES, @@ -13,91 +15,118 @@ SCHEME_EXACT, X402_PERMIT2_PROXY_ADDRESSES, ) -from ..signers import ClientTronSigner +from ..signer import ClientTronSigner +from ..types import ( + ExactEIP3009Authorization, + ExactEIP3009Payload, + ExactPermit2Payload, + Permit2Authorization, + Permit2Witness, +) from ..utils import create_nonce, get_tron_chain_id, normalize_address_for_signing +from .trc20approval import sign_trc20_approval_transaction class ExactTronClientScheme: - """TRON client implementation for the Exact payment scheme (V2). - - Attributes: - scheme: The scheme identifier ("exact"). - """ + """TRON client implementation for the Exact payment scheme (V2).""" scheme = SCHEME_EXACT def __init__(self, signer: ClientTronSigner): - """Create ExactTronClientScheme. - - Args: - signer: TRON signer for payment authorizations. - """ self._signer = signer def create_payment_payload( - self, - requirements: PaymentRequirements, - ) -> dict[str, Any]: - """Create signed TIP-712 inner payload (Permit2 or EIP3009). - - Args: - requirements: Payment requirements from server. - - Returns: - Inner payload dict (authorization + signature). - """ + self, requirements: PaymentRequirements, context: PaymentPayloadContext | None = None + ) -> dict[str, Any] | tuple[dict[str, Any], dict[str, Any]]: extra = requirements.extra or {} - method = extra.get("assetTransferMethod", "eip3009") + asset_transfer_method = extra.get("assetTransferMethod", "transferWithAuthorization") + if asset_transfer_method in {"tip712", "eip3009"}: + asset_transfer_method = "transferWithAuthorization" + + if asset_transfer_method == "permit2": + payload = self._create_permit2_payload(requirements) + extensions = self._try_build_trc20_approval_extension(requirements, context) + if extensions: + return payload, extensions + return payload - if method == "permit2": - return self._create_permit2_payload(requirements) - else: - return self._create_eip3009_payload(requirements) + return self._create_eip3009_payload(requirements) def _create_eip3009_payload(self, requirements: PaymentRequirements) -> dict[str, Any]: - """Create TIP-712 TransferWithAuthorization payload.""" nonce = create_nonce() now = int(time.time()) - valid_after = 0 - valid_before = now + (requirements.max_timeout_seconds or 3600) - - authorization = { - "from": self._signer.address, - "to": requirements.pay_to, - "value": str(requirements.amount), - "validAfter": str(valid_after), - "validBefore": str(valid_before), - "nonce": nonce, - } + + authorization = ExactEIP3009Authorization( + from_address=normalize_address_for_signing(self._signer.address), + to=normalize_address_for_signing(requirements.pay_to), + value=str(requirements.amount), + valid_after=str(now - 600), + valid_before=str(now + (requirements.max_timeout_seconds or 3600)), + nonce=nonce, + ) signature = self._sign_eip3009(authorization, requirements) + payload = ExactEIP3009Payload(authorization=authorization, signature=signature) + return payload.to_dict() + + def _try_build_trc20_approval_extension( + self, requirements: PaymentRequirements, context: PaymentPayloadContext | None + ) -> dict[str, Any] | None: + if context is None or not context.extensions: + return None + if TRC20_APPROVAL_GAS_SPONSORING.key not in context.extensions: + return None + if not hasattr(self._signer, "build_trigger_smart_contract_transaction") or not hasattr( + self._signer, "sign_transaction" + ): + return None + + permit2_address = PERMIT2_ADDRESSES.get(str(requirements.network)) + if not permit2_address: + return None - return { - "authorization": authorization, - "signature": signature, - } + try: + allowance = self._signer.read_contract( + address=requirements.asset, + function_name="allowance", + args=[self._signer.address, permit2_address], + ) + if int(str(allowance)) >= int(str(requirements.amount)): + return None + except Exception: + # If allowance cannot be read, still try to provide the approval transaction. + pass + + info = sign_trc20_approval_transaction( + self._signer, + requirements.asset, + str(requirements.network), + ) + return {TRC20_APPROVAL_GAS_SPONSORING.key: {"info": info, "schema": {}}} def _sign_eip3009( - self, authorization: dict[str, Any], requirements: PaymentRequirements + self, authorization: ExactEIP3009Authorization, requirements: PaymentRequirements ) -> str: - """Sign TIP-712 domain and message.""" extra = requirements.extra or {} - chain_id = get_tron_chain_id(str(requirements.network)) + if "name" not in extra or "version" not in extra: + raise ValueError( + f"TIP-712 domain parameters (name, version) required for {requirements.asset}" + ) domain = { - "name": extra.get("name", "Tether USD"), - "version": extra.get("version", "1"), - "chainId": chain_id, + "name": extra["name"], + "version": extra["version"], + "chainId": get_tron_chain_id(str(requirements.network)), "verifyingContract": normalize_address_for_signing(requirements.asset), } message = { - "from": normalize_address_for_signing(authorization["from"]), - "to": normalize_address_for_signing(authorization["to"]), - "value": int(authorization["value"]), - "validAfter": int(authorization["validAfter"]), - "validBefore": int(authorization["validBefore"]), - "nonce": bytes.fromhex(authorization["nonce"].removeprefix("0x")), + "from": normalize_address_for_signing(authorization.from_address), + "to": normalize_address_for_signing(authorization.to), + "value": int(authorization.value), + "validAfter": int(authorization.valid_after), + "validBefore": int(authorization.valid_before), + "nonce": bytes.fromhex(authorization.nonce.removeprefix("0x")), } return self._signer.sign_typed_data( @@ -108,9 +137,7 @@ def _sign_eip3009( ) def _create_permit2_payload(self, requirements: PaymentRequirements) -> dict[str, Any]: - """Create TIP-712 Permit2 payload.""" now = int(time.time()) - nonce = create_nonce() network = str(requirements.network) permit2_address = PERMIT2_ADDRESSES.get(network) @@ -127,37 +154,31 @@ def _create_permit2_payload(self, requirements: PaymentRequirements) -> dict[str requirements.extra or {} ).get("facilitatorAddress") if not facilitator_address: - raise ValueError( - "Permit2 facilitator address is required in payment requirements extra" - ) - - permit2_authorization = { - "from": normalize_address_for_signing(self._signer.address), - "permitted": { - "token": normalize_address_for_signing(requirements.asset), - "amount": str(requirements.amount), - }, - "spender": normalize_address_for_signing(proxy_address), - "nonce": nonce, - "deadline": str(now + (requirements.max_timeout_seconds or 3600)), - "witness": { - "to": normalize_address_for_signing(requirements.pay_to), - "facilitator": normalize_address_for_signing(str(facilitator_address)), - "validAfter": str(now - 600), - }, - } + raise ValueError("Permit2 facilitator address required in payment requirements extra") + + permit2_authorization = Permit2Authorization( + from_address=normalize_address_for_signing(self._signer.address), + permitted_token=normalize_address_for_signing(requirements.asset), + permitted_amount=str(requirements.amount), + spender=normalize_address_for_signing(proxy_address), + nonce=create_nonce(), + deadline=str(now + (requirements.max_timeout_seconds or 3600)), + witness=Permit2Witness( + to=normalize_address_for_signing(requirements.pay_to), + facilitator=normalize_address_for_signing(str(facilitator_address)), + valid_after=str(now - 600), + ), + ) signature = self._sign_permit2(permit2_authorization, requirements) - - return { - "permit2Authorization": permit2_authorization, - "signature": signature, - } + payload = ExactPermit2Payload( + permit2_authorization=permit2_authorization, signature=signature + ) + return payload.to_dict() def _sign_permit2( - self, permit2_authorization: dict[str, Any], requirements: PaymentRequirements + self, authorization: Permit2Authorization, requirements: PaymentRequirements ) -> str: - """Sign a PermitWitnessTransferFrom payload.""" network = str(requirements.network) permit2_address = PERMIT2_ADDRESSES.get(network) if not permit2_address: @@ -171,16 +192,16 @@ def _sign_permit2( message = { "permitted": { - "token": permit2_authorization["permitted"]["token"], - "amount": int(permit2_authorization["permitted"]["amount"]), + "token": authorization.permitted_token, + "amount": int(authorization.permitted_amount), }, - "spender": permit2_authorization["spender"], - "nonce": int(str(permit2_authorization["nonce"]), 0), - "deadline": int(permit2_authorization["deadline"]), + "spender": authorization.spender, + "nonce": int(authorization.nonce, 0), + "deadline": int(authorization.deadline), "witness": { - "to": permit2_authorization["witness"]["to"], - "facilitator": permit2_authorization["witness"]["facilitator"], - "validAfter": int(permit2_authorization["witness"]["validAfter"]), + "to": authorization.witness.to, + "facilitator": authorization.witness.facilitator, + "validAfter": int(authorization.witness.valid_after), }, } diff --git a/python/x402/src/bankofai/x402/mechanisms/tron/exact/eip3009.py b/python/x402/src/bankofai/x402/mechanisms/tron/exact/eip3009.py index 7973c554..7efc1f8e 100644 --- a/python/x402/src/bankofai/x402/mechanisms/tron/exact/eip3009.py +++ b/python/x402/src/bankofai/x402/mechanisms/tron/exact/eip3009.py @@ -1,7 +1,4 @@ -"""TRON eip3009 (TransferWithAuthorization) facilitator logic. - -Mirrors the TypeScript verifyEIP3009() / settleEIP3009() functions. -""" +"""TRON TIP-712 (TransferWithAuthorization) facilitator logic.""" import time from typing import Any @@ -22,8 +19,12 @@ ERR_VALUE_MISMATCH, SCHEME_EXACT, ) -from ..signers import FacilitatorTronSigner -from ..utils import get_tron_chain_id, normalize_address_for_signing +from ..signer import FacilitatorTronSigner +from ..utils import ( + get_tron_chain_id, + normalize_address_for_contract_call, + normalize_address_for_signing, +) def verify_eip3009( @@ -32,27 +33,20 @@ def verify_eip3009( requirements: PaymentRequirements, raw: dict[str, Any], ) -> VerifyResponse: - """Verify a TIP-712 TransferWithAuthorization payment payload. - - Mirrors the TypeScript verifyEIP3009() function exactly. - """ + """Verify a TIP-712 TransferWithAuthorization payment payload.""" auth = raw.get("authorization", {}) payer = auth.get("from", "") - # Scheme check if payload.accepted.scheme != SCHEME_EXACT or requirements.scheme != SCHEME_EXACT: return VerifyResponse(is_valid=False, invalid_reason=ERR_INVALID_SCHEME, payer=payer) - # TIP-712 domain params extra = requirements.extra or {} if "name" not in extra or "version" not in extra: return VerifyResponse(is_valid=False, invalid_reason=ERR_MISSING_TIP712_DOMAIN, payer=payer) - # Network match if str(payload.accepted.network) != str(requirements.network): return VerifyResponse(is_valid=False, invalid_reason=ERR_NETWORK_MISMATCH, payer=payer) - # Build TIP-712 domain and message try: chain_id = get_tron_chain_id(str(requirements.network)) except ValueError: @@ -72,7 +66,7 @@ def verify_eip3009( "value": int(str(auth.get("value", 0))), "validAfter": int(str(auth.get("validAfter", 0))), "validBefore": int(str(auth.get("validBefore", 0))), - "nonce": bytes.fromhex(nonce_hex.removeprefix("0x")), # bytes32 + "nonce": bytes.fromhex(nonce_hex.removeprefix("0x")), } signature = str(raw.get("signature", "")) @@ -87,35 +81,34 @@ def verify_eip3009( if not is_valid: return VerifyResponse(is_valid=False, invalid_reason=ERR_INVALID_SIGNATURE, payer=payer) - # Recipient check if normalize_address_for_signing(auth.get("to", "")) != normalize_address_for_signing( requirements.pay_to ): return VerifyResponse(is_valid=False, invalid_reason=ERR_RECIPIENT_MISMATCH, payer=payer) - # Timing check now = int(time.time()) if int(auth.get("validBefore", 0)) < now + 6: return VerifyResponse(is_valid=False, invalid_reason=ERR_VALID_BEFORE_EXPIRED, payer=payer) if int(auth.get("validAfter", 0)) > now: return VerifyResponse(is_valid=False, invalid_reason=ERR_VALID_AFTER_FUTURE, payer=payer) - # Amount check if int(auth.get("value", 0)) != int(requirements.amount): return VerifyResponse(is_valid=False, invalid_reason=ERR_VALUE_MISMATCH, payer=payer) - # Balance check (best-effort) try: + payer_for_contract = normalize_address_for_contract_call(str(auth.get("from", ""))) balance = signer.read_contract( address=requirements.asset, function_name="balanceOf", - args=[auth.get("from", "")], + args=[payer_for_contract], ) if int(str(balance)) < int(str(requirements.amount)): return VerifyResponse( is_valid=False, invalid_reason=ERR_INSUFFICIENT_FUNDS, - invalid_message=f"Insufficient funds. Required: {requirements.amount}, Available: {balance}", + invalid_message=( + f"Insufficient funds. Required: {requirements.amount}, Available: {balance}" + ), payer=payer, ) except Exception: @@ -130,15 +123,11 @@ def settle_eip3009( requirements: PaymentRequirements, raw: dict[str, Any], ) -> SettleResponse: - """Settle a TIP-712 TransferWithAuthorization payment on-chain. - - Mirrors the TypeScript settleEIP3009() function. - """ + """Settle a TIP-712 TransferWithAuthorization payment on-chain.""" auth = raw.get("authorization", {}) payer = auth.get("from", "") network = str(requirements.network) - # Re-verify verify_result = verify_eip3009(signer, payload, requirements, raw) if not verify_result.is_valid: return SettleResponse( @@ -149,20 +138,21 @@ def settle_eip3009( payer=payer, ) - # Parse signature into v, r, s clean_sig = str(raw.get("signature", "")).removeprefix("0x") - r = bytes.fromhex(clean_sig[:64]) # bytes32 - s = bytes.fromhex(clean_sig[64:128]) # bytes32 - v = int(clean_sig[128:130], 16) # uint8 + r = bytes.fromhex(clean_sig[:64]) + s = bytes.fromhex(clean_sig[64:128]) + v = int(clean_sig[128:130], 16) nonce_bytes = bytes.fromhex(str(auth.get("nonce", "0x" + "00" * 32)).removeprefix("0x")) try: + from_for_contract = normalize_address_for_contract_call(str(auth.get("from", ""))) + to_for_contract = normalize_address_for_contract_call(str(auth.get("to", ""))) tx = signer.write_contract( address=requirements.asset, function_name="transferWithAuthorization", args=[ - str(auth.get("from", "")), - str(auth.get("to", "")), + from_for_contract, + to_for_contract, int(str(auth.get("value", 0))), int(str(auth.get("validAfter", 0))), int(str(auth.get("validBefore", 0))), @@ -184,7 +174,6 @@ def settle_eip3009( payer=payer, ) return SettleResponse(success=True, transaction=tx, network=network, payer=payer) - except Exception as e: return SettleResponse( success=False, diff --git a/python/x402/src/bankofai/x402/mechanisms/tron/exact/facilitator.py b/python/x402/src/bankofai/x402/mechanisms/tron/exact/facilitator.py index 72ad5e54..8a345d17 100644 --- a/python/x402/src/bankofai/x402/mechanisms/tron/exact/facilitator.py +++ b/python/x402/src/bankofai/x402/mechanisms/tron/exact/facilitator.py @@ -1,41 +1,17 @@ -"""TRON facilitator scheme for the Exact payment mechanism (v2 Python SDK). - -Mirrors ExactTronScheme from TypeScript: routes between TIP-712 (eip3009) -and Permit2 based on the payload structure (permit2Authorization vs authorization). -""" +"""TRON facilitator scheme for the Exact payment mechanism (v2 Python SDK).""" from typing import Any -from ....schemas import ( - PaymentPayload, - PaymentRequirements, - SettleResponse, - VerifyResponse, -) -from ..constants import ( - SCHEME_EXACT, - X402_PERMIT2_PROXY_ADDRESSES, -) -from ..signers import FacilitatorTronSigner +from ....schemas import PaymentPayload, PaymentRequirements, SettleResponse, VerifyResponse +from ..constants import SCHEME_EXACT +from ..signer import FacilitatorTronSigner +from ..types import ExactPermit2Payload, is_permit2_payload from .eip3009 import settle_eip3009, verify_eip3009 from .permit2 import settle_permit2, verify_permit2 -def _is_permit2_payload(raw: dict[str, Any]) -> bool: - """Return True if the raw payload uses the Permit2 path (has permit2Authorization).""" - return "permit2Authorization" in raw - - class ExactTronScheme: - """TRON facilitator for the Exact payment scheme. - - Thin router that delegates to TIP-712 (eip3009) or Permit2 - based on payload type — identical to the TypeScript ExactTronScheme. - - Attributes: - scheme: Always "exact". - caip_family: Always "tron:*". - """ + """TRON facilitator for the Exact payment scheme.""" scheme = SCHEME_EXACT caip_family = "tron:*" @@ -44,19 +20,12 @@ def __init__(self, signer: FacilitatorTronSigner) -> None: self._signer = signer def get_extra(self, network: str) -> dict[str, Any] | None: - """Return supported asset transfer methods and Permit2 proxy address.""" - supported_methods = ["eip3009"] signers = self._signer.get_addresses() - if X402_PERMIT2_PROXY_ADDRESSES.get(network): - supported_methods.append("permit2") - - extra: dict[str, Any] = {"supportedAssetTransferMethods": supported_methods} - if signers and X402_PERMIT2_PROXY_ADDRESSES.get(network): - extra["permit2FacilitatorAddress"] = signers[0] - return extra + if signers: + return {"permit2FacilitatorAddress": signers[0]} + return None def get_signers(self, network: str) -> list[str]: - """Return facilitator wallet addresses.""" return list(self._signer.get_addresses()) def verify( @@ -65,10 +34,15 @@ def verify( requirements: PaymentRequirements, context: Any = None, ) -> VerifyResponse: - """Verify — routes to Permit2 or eip3009 based on payload type.""" raw: dict[str, Any] = payload.payload or {} - if _is_permit2_payload(raw): - return verify_permit2(self._signer, payload, requirements, raw) + if is_permit2_payload(raw): + return verify_permit2( + self._signer, + payload, + requirements, + ExactPermit2Payload.from_dict(raw), + context, + ) return verify_eip3009(self._signer, payload, requirements, raw) def settle( @@ -77,8 +51,13 @@ def settle( requirements: PaymentRequirements, context: Any = None, ) -> SettleResponse: - """Settle — routes to Permit2 or eip3009 based on payload type.""" raw: dict[str, Any] = payload.payload or {} - if _is_permit2_payload(raw): - return settle_permit2(self._signer, payload, requirements, raw) + if is_permit2_payload(raw): + return settle_permit2( + self._signer, + payload, + requirements, + ExactPermit2Payload.from_dict(raw), + context, + ) return settle_eip3009(self._signer, payload, requirements, raw) diff --git a/python/x402/src/bankofai/x402/mechanisms/tron/exact/permit2.py b/python/x402/src/bankofai/x402/mechanisms/tron/exact/permit2.py index 2b74b693..e41dabb0 100644 --- a/python/x402/src/bankofai/x402/mechanisms/tron/exact/permit2.py +++ b/python/x402/src/bankofai/x402/mechanisms/tron/exact/permit2.py @@ -1,11 +1,15 @@ -"""TRON Permit2 facilitator logic: verify and settle Permit2 payments. - -Mirrors the TypeScript verifyPermit2() / settlePermit2() functions exactly. -""" +"""TRON Permit2 facilitator logic: verify and settle Permit2 payments.""" import time from typing import Any +from ....extensions.trc20_approval_gas_sponsoring import ( + TRC20_APPROVAL_GAS_SPONSORING, + Trc20ApprovalGasSponsoringExtension, + extract_trc20_approval_gas_sponsoring_info, + validate_trc20_approval_gas_sponsoring_info, +) +from ....interfaces import FacilitatorContext from ....schemas import PaymentPayload, PaymentRequirements, SettleResponse, VerifyResponse from ..constants import ( ERR_INSUFFICIENT_FUNDS, @@ -23,39 +27,49 @@ ERR_PERMIT2_RECIPIENT_MISMATCH, ERR_PERMIT2_TOKEN_MISMATCH, ERR_TRANSACTION_FAILED, + ERR_TRC20_APPROVAL_ASSET_MISMATCH, + ERR_TRC20_APPROVAL_FORMAT, + ERR_TRC20_APPROVAL_FROM_MISMATCH, + ERR_TRC20_APPROVAL_SPENDER_NOT_PERMIT2, + ERR_TRC20_APPROVAL_TX_INVALID_SIGNATURE, + ERR_TRC20_APPROVAL_TX_MISSING_DATA, + ERR_TRC20_APPROVAL_TX_WRONG_AMOUNT, + ERR_TRC20_APPROVAL_TX_WRONG_SELECTOR, + ERR_TRC20_APPROVAL_TX_WRONG_SPENDER, + ERR_TRC20_APPROVAL_TX_WRONG_TARGET, PERMIT2_ADDRESSES, PERMIT2_WITNESS_TYPES, X402_PERMIT2_PROXY_ADDRESSES, ) -from ..signers import FacilitatorTronSigner -from ..utils import get_tron_chain_id, normalize_address_for_signing +from ..signer import FacilitatorTronSigner +from ..types import ExactPermit2Payload +from ..utils import ( + get_tron_chain_id, + normalize_address_for_contract_call, + normalize_address_for_signing, +) def verify_permit2( signer: FacilitatorTronSigner, payload: PaymentPayload, requirements: PaymentRequirements, - permit2_payload: dict[str, Any], + permit2_payload: ExactPermit2Payload, + context: FacilitatorContext | None = None, ) -> VerifyResponse: - """Verify a Permit2 payment payload on TRON. - - Mirrors the TS verifyPermit2() function. - """ - auth = permit2_payload.get("permit2Authorization", {}) - payer = auth.get("from", "") + """Verify a Permit2 payment payload on TRON.""" + auth = permit2_payload.permit2_authorization + payer = auth.from_address facilitator_addresses = [normalize_address_for_signing(a) for a in signer.get_addresses()] network = str(requirements.network) - # Scheme check if payload.accepted.scheme != "exact" or requirements.scheme != "exact": return VerifyResponse(is_valid=False, invalid_reason=ERR_INVALID_SCHEME, payer=payer) - # Network match if str(payload.accepted.network) != network: return VerifyResponse(is_valid=False, invalid_reason=ERR_NETWORK_MISMATCH, payer=payer) - # Permit2 contract addresses permit2_address = PERMIT2_ADDRESSES.get(network) proxy_address = X402_PERMIT2_PROXY_ADDRESSES.get(network) if not permit2_address or not proxy_address: @@ -66,68 +80,58 @@ def verify_permit2( normalized_proxy = normalize_address_for_signing(proxy_address) token_address = normalize_address_for_signing(requirements.asset) - # Spender must be x402Permit2Proxy - spender = normalize_address_for_signing(auth.get("spender", "")) - if spender != normalized_proxy: + if normalize_address_for_signing(auth.spender) != normalized_proxy: return VerifyResponse( is_valid=False, invalid_reason=ERR_INVALID_PERMIT2_SPENDER, payer=payer ) - # Recipient check - witness = auth.get("witness", {}) - payload_to = normalize_address_for_signing(witness.get("to", "")) + payload_to = normalize_address_for_signing(auth.witness.to) required_to = normalize_address_for_signing(requirements.pay_to) if payload_to != required_to: return VerifyResponse( is_valid=False, invalid_reason=ERR_PERMIT2_RECIPIENT_MISMATCH, payer=payer ) - # Facilitator check - payload_facilitator = normalize_address_for_signing(witness.get("facilitator", "")) + payload_facilitator = normalize_address_for_signing(auth.witness.facilitator) if payload_facilitator not in facilitator_addresses: return VerifyResponse( is_valid=False, invalid_reason=ERR_INVALID_PERMIT2_FACILITATOR, payer=payer ) - # Timing checks now = int(time.time()) - if int(auth.get("deadline", 0)) < now + 6: + if int(auth.deadline) < now + 6: return VerifyResponse( is_valid=False, invalid_reason=ERR_PERMIT2_DEADLINE_EXPIRED, payer=payer ) - if int(witness.get("validAfter", 0)) > now: + if int(auth.witness.valid_after) > now: return VerifyResponse(is_valid=False, invalid_reason=ERR_PERMIT2_NOT_YET_VALID, payer=payer) - # Amount check - permitted = auth.get("permitted", {}) - if int(permitted.get("amount", 0)) != int(requirements.amount): + if int(auth.permitted_amount) != int(requirements.amount): return VerifyResponse( is_valid=False, invalid_reason=ERR_PERMIT2_AMOUNT_MISMATCH, payer=payer ) - # Token check - if normalize_address_for_signing(permitted.get("token", "")) != token_address: + if normalize_address_for_signing(auth.permitted_token) != token_address: return VerifyResponse( is_valid=False, invalid_reason=ERR_PERMIT2_TOKEN_MISMATCH, payer=payer ) - # Signature verification try: chain_id = get_tron_chain_id(network) normalized_permit2 = normalize_address_for_signing(permit2_address) domain = {"name": "Permit2", "chainId": chain_id, "verifyingContract": normalized_permit2} message = { "permitted": { - "token": str(permitted.get("token", "")), - "amount": int(str(permitted.get("amount", 0))), + "token": auth.permitted_token, + "amount": int(auth.permitted_amount), }, - "spender": str(auth.get("spender", "")), - "nonce": int(str(auth.get("nonce", 0)), 0), - "deadline": int(str(auth.get("deadline", 0))), + "spender": auth.spender, + "nonce": int(auth.nonce, 0), + "deadline": int(auth.deadline), "witness": { - "to": str(witness.get("to", "")), - "facilitator": str(witness.get("facilitator", "")), - "validAfter": int(str(witness.get("validAfter", 0))), + "to": auth.witness.to, + "facilitator": auth.witness.facilitator, + "validAfter": int(auth.witness.valid_after), }, } is_valid = signer.verify_typed_data( @@ -136,7 +140,7 @@ def verify_permit2( types=PERMIT2_WITNESS_TYPES, primary_type="PermitWitnessTransferFrom", message=message, - signature=permit2_payload.get("signature", ""), + signature=permit2_payload.signature, ) if not is_valid: return VerifyResponse( @@ -147,32 +151,45 @@ def verify_permit2( is_valid=False, invalid_reason=ERR_PERMIT2_INVALID_SIGNATURE, payer=payer ) - # Allowance check (best-effort) try: + payer_for_contract = normalize_address_for_contract_call(payer) + permit2_for_contract = normalize_address_for_contract_call(permit2_address) allowance = signer.read_contract( address=requirements.asset, function_name="allowance", - args=[payer, permit2_address], + args=[payer_for_contract, permit2_for_contract], ) if int(str(allowance)) < int(str(requirements.amount)): - return VerifyResponse( - is_valid=False, invalid_reason=ERR_PERMIT2_ALLOWANCE_REQUIRED, payer=payer + has_extension, extension_error = _verify_trc20_approval_extension( + signer, payload, requirements, payer, permit2_address, context ) + if extension_error is not None: + return extension_error + if not has_extension: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_ALLOWANCE_REQUIRED, payer=payer + ) except Exception: - pass + has_extension, extension_error = _verify_trc20_approval_extension( + signer, payload, requirements, payer, permit2_address, context + ) + if extension_error is not None: + return extension_error - # Balance check (best-effort) try: + payer_for_contract = normalize_address_for_contract_call(payer) balance = signer.read_contract( address=requirements.asset, function_name="balanceOf", - args=[payer], + args=[payer_for_contract], ) if int(str(balance)) < int(str(requirements.amount)): return VerifyResponse( is_valid=False, invalid_reason=ERR_INSUFFICIENT_FUNDS, - invalid_message=f"Insufficient funds. Required: {requirements.amount}, Available: {balance}", + invalid_message=( + f"Insufficient funds. Required: {requirements.amount}, Available: {balance}" + ), payer=payer, ) except Exception: @@ -185,18 +202,15 @@ def settle_permit2( signer: FacilitatorTronSigner, payload: PaymentPayload, requirements: PaymentRequirements, - permit2_payload: dict[str, Any], + permit2_payload: ExactPermit2Payload, + context: FacilitatorContext | None = None, ) -> SettleResponse: - """Settle a Permit2 payment on TRON via x402Permit2Proxy.settle(). - - Mirrors the TS settlePermit2() function. - """ - auth = permit2_payload.get("permit2Authorization", {}) - payer = auth.get("from", "") + """Settle a Permit2 payment on TRON via x402Permit2Proxy.settle().""" + auth = permit2_payload.permit2_authorization + payer = auth.from_address network = str(requirements.network) - # Re-verify - verify_result = verify_permit2(signer, payload, requirements, permit2_payload) + verify_result = verify_permit2(signer, payload, requirements, permit2_payload, context) if not verify_result.is_valid: return SettleResponse( success=False, @@ -207,65 +221,59 @@ def settle_permit2( ) proxy_address = X402_PERMIT2_PROXY_ADDRESSES[network] - permitted = auth.get("permitted", {}) - witness = auth.get("witness", {}) - signature_hex = str(permit2_payload.get("signature", "")) - signature_bytes = bytes.fromhex(signature_hex.removeprefix("0x")) - - # We have to manually encode this because TronPy's `trx_abi` doesn't - # currently recursively parse tuple structures perfectly without throwing "ABIEncoderV2 used." - # We bypass TronPy's trx_abi completely because it lacks proper ABIEncoderV2 tuple support + trc20_info = extract_trc20_approval_gas_sponsoring_info(payload.extensions) + if trc20_info and context is not None: + needs_approval = True + try: + permit2_address = PERMIT2_ADDRESSES.get(network) + if permit2_address: + payer_for_contract = normalize_address_for_contract_call(payer) + permit2_for_contract = normalize_address_for_contract_call(permit2_address) + allowance = signer.read_contract( + address=requirements.asset, + function_name="allowance", + args=[payer_for_contract, permit2_for_contract], + ) + needs_approval = int(str(allowance)) < int(str(requirements.amount)) + except Exception: + needs_approval = True + + if needs_approval: + extension = context.get_extension(TRC20_APPROVAL_GAS_SPONSORING.key) + if isinstance(extension, Trc20ApprovalGasSponsoringExtension) and extension.signer: + settle_result = _settle_with_trc20_approval( + extension.signer, payload, requirements, permit2_payload, trc20_info + ) + if settle_result is not None: + return settle_result try: - from eth_abi import encode - from eth_utils import keccak - - # TRON addresses must be converted to 0x-prefixed hex for eth_abi - def evm_addr(addr: str) -> str: - return normalize_address_for_signing(addr) - - permit_evm = ( - (evm_addr(permitted.get("token", "")), int(str(permitted.get("amount", 0)))), - int(str(auth.get("nonce", 0)), 0), - int(str(auth.get("deadline", 0))), - ) - witness_evm = ( - evm_addr(witness.get("to", "")), - evm_addr(witness.get("facilitator", "")), - int(str(witness.get("validAfter", 0))), - ) - payer_evm = evm_addr(payer) - - signature = ( - "settle(((address,uint256),uint256,uint256),address,(address,address,uint256),bytes)" - ) - selector = keccak(text=signature)[:4].hex() - - types = [ - "((address,uint256),uint256,uint256)", - "address", - "(address,address,uint256)", - "bytes", - ] - encoded_args = encode(types, [permit_evm, payer_evm, witness_evm, signature_bytes]).hex() - - from tronpy.keys import to_hex_address - - txn = signer._client.trx._build_transaction( - "TriggerSmartContract", - { - "owner_address": to_hex_address(signer._address), - "contract_address": to_hex_address(proxy_address), - "data": selector + encoded_args, - "call_value": 0, - }, + from ..constants import x402ExactPermit2ProxyABI + + signature_hex = str(permit2_payload.signature) + signature_bytes = bytes.fromhex(signature_hex.removeprefix("0x")) + tx = signer.write_contract_with_abi( + address=proxy_address, + function_name="settle", + args=[ + ( + ( + normalize_address_for_contract_call(auth.permitted_token), + int(str(auth.permitted_amount)), + ), + int(str(auth.nonce), 0), + int(str(auth.deadline)), + ), + normalize_address_for_contract_call(payer), + ( + normalize_address_for_contract_call(auth.witness.to), + normalize_address_for_contract_call(auth.witness.facilitator), + int(str(auth.witness.valid_after)), + ), + signature_bytes, + ], + abi=x402ExactPermit2ProxyABI, ) - # Apply fee limit - txn = txn.fee_limit(1_000_000_000) - # Build, sign and broadcast - signed_txn = txn.build().sign(signer._pk) - result = signed_txn.broadcast() - tx = str(result.txid) receipt = signer.wait_for_transaction_receipt(tx) if receipt.status != "success": @@ -276,13 +284,8 @@ def evm_addr(addr: str) -> str: network=network, payer=payer, ) - return SettleResponse(success=True, transaction=tx, network=network, payer=payer) - except Exception as e: - import traceback - - traceback.print_exc() return SettleResponse( success=False, error_reason=ERR_TRANSACTION_FAILED, @@ -293,42 +296,192 @@ def evm_addr(addr: str) -> str: ) -def _get_permit2_proxy_abi() -> list[dict[str, Any]]: - """Return the x402Permit2Proxy ABI for the settle function.""" - return [ - { - "type": "function", - "name": "settle", - "inputs": [ - { - "name": "permit", - "type": "tuple", - "components": [ - { - "name": "permitted", - "type": "tuple", - "components": [ - {"name": "token", "type": "address"}, - {"name": "amount", "type": "uint256"}, - ], - }, - {"name": "nonce", "type": "uint256"}, - {"name": "deadline", "type": "uint256"}, - ], - }, - {"name": "owner", "type": "address"}, - { - "name": "witness", - "type": "tuple", - "components": [ - {"name": "to", "type": "address"}, - {"name": "facilitator", "type": "address"}, - {"name": "validAfter", "type": "uint256"}, - ], - }, - {"name": "signature", "type": "bytes"}, - ], - "outputs": [], - "stateMutability": "nonpayable", - } - ] +def _verify_trc20_approval_extension( + signer: FacilitatorTronSigner, + payload: PaymentPayload, + requirements: PaymentRequirements, + payer: str, + permit2_address: str, + context: FacilitatorContext | None, +) -> tuple[bool, VerifyResponse | None]: + info = extract_trc20_approval_gas_sponsoring_info(payload.extensions) + if not info: + return False, None + + if context is None or context.get_extension(TRC20_APPROVAL_GAS_SPONSORING.key) is None: + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_PERMIT2_ALLOWANCE_REQUIRED, payer=payer + ) + + if not validate_trc20_approval_gas_sponsoring_info(info): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_TRC20_APPROVAL_FORMAT, payer=payer + ) + + if normalize_address_for_signing(info.from_address) != normalize_address_for_signing(payer): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_TRC20_APPROVAL_FROM_MISMATCH, payer=payer + ) + + if normalize_address_for_signing(info.asset) != normalize_address_for_signing( + requirements.asset + ): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_TRC20_APPROVAL_ASSET_MISMATCH, payer=payer + ) + + if normalize_address_for_signing(info.spender) != normalize_address_for_signing( + permit2_address + ): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_TRC20_APPROVAL_SPENDER_NOT_PERMIT2, payer=payer + ) + + tx = info.signed_transaction + if not isinstance(tx, dict): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_TRC20_APPROVAL_TX_MISSING_DATA, payer=payer + ) + + tx_value = _get_approval_transaction_value(tx) + if ( + not tx_value.get("owner_address") + or not tx_value.get("contract_address") + or not tx_value.get("data") + ): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_TRC20_APPROVAL_TX_MISSING_DATA, payer=payer + ) + + if normalize_address_for_signing(tx_value["owner_address"]) != normalize_address_for_signing( + payer + ): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_TRC20_APPROVAL_FROM_MISMATCH, payer=payer + ) + + if normalize_address_for_signing(tx_value["contract_address"]) != normalize_address_for_signing( + requirements.asset + ): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_TRC20_APPROVAL_TX_WRONG_TARGET, payer=payer + ) + + data = str(tx_value["data"]).lower() + if not data.startswith(_APPROVE_SELECTOR): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_TRC20_APPROVAL_TX_WRONG_SELECTOR, payer=payer + ) + + decoded = _decode_approval_calldata(data) + if not decoded: + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_TRC20_APPROVAL_TX_WRONG_SELECTOR, payer=payer + ) + + if normalize_address_for_signing(decoded["spender"]) != normalize_address_for_signing( + permit2_address + ): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_TRC20_APPROVAL_TX_WRONG_SPENDER, payer=payer + ) + + if decoded["amount"] != _MAX_UINT256 or info.amount != str(_MAX_UINT256): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_TRC20_APPROVAL_TX_WRONG_AMOUNT, payer=payer + ) + + try: + sign_weight = signer.get_sign_weight(tx) + if not _is_tron_signature_valid(sign_weight): + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_TRC20_APPROVAL_TX_INVALID_SIGNATURE, payer=payer + ) + except Exception: + return True, VerifyResponse( + is_valid=False, invalid_reason=ERR_TRC20_APPROVAL_TX_INVALID_SIGNATURE, payer=payer + ) + + return True, None + + +def _settle_with_trc20_approval( + signer: Any, + payload: PaymentPayload, + requirements: PaymentRequirements, + permit2_payload: ExactPermit2Payload, + info: Any, +) -> SettleResponse | None: + payer = permit2_payload.permit2_authorization.from_address + network = str(requirements.network) + try: + tx_hash = signer.send_raw_transaction(info.signed_transaction) + receipt = signer.wait_for_transaction_receipt(tx_hash) + if receipt.status != "success": + return SettleResponse( + success=False, + error_reason=ERR_TRC20_APPROVAL_TX_INVALID_SIGNATURE, + transaction=tx_hash, + network=network, + payer=payer, + ) + except Exception as e: + return SettleResponse( + success=False, + error_reason=ERR_TRC20_APPROVAL_TX_INVALID_SIGNATURE, + error_message=str(e), + transaction="", + network=network, + payer=payer, + ) + return None + + +_APPROVE_SELECTOR = "095ea7b3" +_MAX_UINT256 = (1 << 256) - 1 + + +def _get_approval_transaction_value(tx: dict[str, Any]) -> dict[str, Any]: + raw_data = tx.get("raw_data") or {} + contract_list = raw_data.get("contract") or [] + contract = contract_list[0] if contract_list else {} + parameter = contract.get("parameter") or {} + value = parameter.get("value") or {} + return { + "owner_address": value.get("owner_address"), + "contract_address": value.get("contract_address"), + "data": value.get("data"), + } + + +def _decode_approval_calldata(data: str) -> dict[str, Any] | None: + cleaned = data.removeprefix("0x") + if not cleaned.startswith(_APPROVE_SELECTOR): + return None + if len(cleaned) < 8 + 64 + 64: + return None + spender_word = cleaned[8 : 8 + 64] + amount_word = cleaned[8 + 64 : 8 + 128] + spender = "0x" + spender_word[24:].lower() + amount = int(amount_word, 16) + return {"spender": spender, "amount": amount} + + +def _is_tron_signature_valid(sign_weight: Any) -> bool: + if not isinstance(sign_weight, dict): + return False + result = ( + sign_weight.get("transaction", {}) + .get("result", {}) + .get("result", sign_weight.get("result", {}).get("result")) + ) + if isinstance(result, bool): + return result + current_weight = sign_weight.get("current_weight") + threshold = (sign_weight.get("permission") or {}).get("threshold") + if isinstance(current_weight, int) and isinstance(threshold, int): + return current_weight >= threshold + approved_list = sign_weight.get("approved_list") + if isinstance(approved_list, list): + return len(approved_list) > 0 + return False diff --git a/python/x402/src/bankofai/x402/mechanisms/tron/exact/server.py b/python/x402/src/bankofai/x402/mechanisms/tron/exact/server.py index 86c764b0..f33daa40 100644 --- a/python/x402/src/bankofai/x402/mechanisms/tron/exact/server.py +++ b/python/x402/src/bankofai/x402/mechanisms/tron/exact/server.py @@ -1,61 +1,33 @@ -"""TRON server scheme for ExactTronScheme (v2 Python SDK). - -Parses prices and enhances payment requirements with TIP-712 domain info. -""" +"""TRON server implementation for the Exact payment scheme (V2).""" +import re from collections.abc import Callable -from typing import Any from ....schemas import AssetAmount, Network, PaymentRequirements, Price, SupportedKind -from ..constants import SCHEME_EXACT, TRON_DEFAULT_ASSETS - +from ..constants import SCHEME_EXACT +from ..utils import get_asset_info, get_network_config -class ExactTronServerScheme: - """TRON server implementation for the Exact payment scheme. +MoneyParser = Callable[[float, str], AssetAmount | None] - Handles price parsing (USD/Money → TRC-20 atomic amount) and - enhances payment requirements with TIP-712 domain parameters. - Attributes: - scheme: Always "exact". - """ +class ExactTronServerScheme: + """TRON server implementation for the Exact payment scheme (V2).""" scheme = SCHEME_EXACT - def __init__(self) -> None: - """Create ExactTronServerScheme.""" - self._money_parsers: list[Callable[[float, str], AssetAmount | None]] = [] - - def register_money_parser( - self, parser: Callable[[float, str], AssetAmount | None] - ) -> "ExactTronServerScheme": - """Register a custom money parser. - - Args: - parser: Callable(decimal_amount, network_str) → AssetAmount | None. + def __init__(self): + self._money_parsers: list[MoneyParser] = [] - Returns: - Self for chaining. - """ + def register_money_parser(self, parser: MoneyParser) -> "ExactTronServerScheme": self._money_parsers.append(parser) return self def parse_price(self, price: Price, network: Network) -> AssetAmount: - """Parse a price into an asset amount. - - Args: - price: Price to parse (USD string, number, or AssetAmount dict). - network: TRON network identifier. - - Returns: - AssetAmount with amount, asset, and optional extra fields. - """ - # Already an AssetAmount dict 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=str(price["amount"]), + amount=price["amount"], asset=price["asset"], extra=price.get("extra", {}), ) @@ -65,16 +37,12 @@ def parse_price(self, price: Price, network: Network) -> AssetAmount: raise ValueError(f"Asset address required for AssetAmount on {network}") return price - # Parse Money to decimal - decimal_amount = self._parse_money_to_decimal(price) - - # Try custom parsers + 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 - # Default: USDT on this network return self._default_money_conversion(decimal_amount, str(network)) def enhance_payment_requirements( @@ -83,76 +51,56 @@ def enhance_payment_requirements( supported_kind: SupportedKind, extension_keys: list[str], ) -> PaymentRequirements: - """Add TIP-712 domain parameters and default asset to requirements. + config = get_network_config(str(requirements.network)) - Args: - requirements: Base payment requirements. - supported_kind: Supported kind from facilitator. - extension_keys: Extension keys (unused). + if not requirements.asset: + default = config.get("default_asset") + if not default or not default.get("address"): + raise ValueError( + f"No default stablecoin configured for network {requirements.network}" + ) + requirements.asset = default["address"] - Returns: - Enhanced payment requirements. - """ - network_str = str(requirements.network) - asset_info = TRON_DEFAULT_ASSETS.get(network_str) - - # Default asset - if not requirements.asset and asset_info: - requirements.asset = asset_info["address"] - - # Convert decimal amount to atomic units if needed - if asset_info and "." in requirements.amount: - decimals = asset_info["decimals"] - requirements.amount = str(int(float(requirements.amount) * (10**decimals))) + try: + asset_info = get_asset_info(str(requirements.network), requirements.asset) + except ValueError: + asset_info = None - # Add TIP-712 domain params if requirements.extra is None: requirements.extra = {} - if asset_info: + + if asset_info is not None: if "name" not in requirements.extra: requirements.extra["name"] = asset_info["name"] if "version" not in requirements.extra: requirements.extra["version"] = asset_info["version"] - - facilitator_extra = supported_kind.extra or {} - if "assetTransferMethod" not in requirements.extra and facilitator_extra.get( - "supportedAssetTransferMethods" - ): - supported_methods = facilitator_extra["supportedAssetTransferMethods"] - if "permit2" in supported_methods: - requirements.extra["assetTransferMethod"] = "permit2" - elif "eip3009" in supported_methods: - requirements.extra["assetTransferMethod"] = "eip3009" - - if ( - requirements.extra.get("assetTransferMethod") == "permit2" - and "permit2FacilitatorAddress" not in requirements.extra - and facilitator_extra.get("permit2FacilitatorAddress") - ): - requirements.extra["permit2FacilitatorAddress"] = facilitator_extra[ - "permit2FacilitatorAddress" - ] + atm = asset_info.get("asset_transfer_method") + if "assetTransferMethod" not in requirements.extra and atm: + requirements.extra["assetTransferMethod"] = atm return requirements - def _parse_money_to_decimal(self, money: Any) -> float: - """Parse USD string ('$1.50', '1.50') or number to decimal float.""" - if isinstance(money, (int, float)): - return float(money) - clean = str(money).lstrip("$").strip() - try: - return float(clean) - except ValueError: - raise ValueError(f"Invalid money format: {money}") from None - def _default_money_conversion(self, amount: float, network: str) -> AssetAmount: - """Convert decimal USD amount to USDT AssetAmount on this network.""" - asset_info = TRON_DEFAULT_ASSETS.get(network) - if not asset_info: - raise ValueError(f"No default asset configured for TRON network {network}") - token_amount = int(amount * (10 ** asset_info["decimals"])) - return AssetAmount( - amount=str(token_amount), - asset=asset_info["address"], - extra={"name": asset_info["name"], "version": asset_info["version"]}, - ) + config = get_network_config(network) + asset = config.get("default_asset") + if not asset or not asset.get("address"): + raise ValueError(f"No default stablecoin configured for network {network}") + + token_amount = int(amount * (10 ** asset["decimals"])) + extra: dict = {"name": asset["name"], "version": asset["version"]} + atm = asset.get("asset_transfer_method") + if atm: + extra["assetTransferMethod"] = atm + + return AssetAmount(amount=str(token_amount), asset=asset["address"], extra=extra) + + +def _parse_money_to_decimal(money: str | float | int) -> float: + if isinstance(money, int | float): + return float(money) + + clean = str(money).strip() + clean = clean.lstrip("$") + clean = re.sub(r"\s*(USD|USDT|usd|usdt)\s*$", "", clean) + clean = clean.strip() + return float(clean) diff --git a/python/x402/src/bankofai/x402/mechanisms/tron/exact/trc20approval.py b/python/x402/src/bankofai/x402/mechanisms/tron/exact/trc20approval.py new file mode 100644 index 00000000..4cc550e9 --- /dev/null +++ b/python/x402/src/bankofai/x402/mechanisms/tron/exact/trc20approval.py @@ -0,0 +1,51 @@ +"""TRON client helper for sponsored TRC-20 Permit2 approvals.""" + +from __future__ import annotations + +from typing import Any + +from ....extensions.trc20_approval_gas_sponsoring import ( + TRC20_APPROVAL_GAS_SPONSORING_VERSION, +) +from ..constants import DEFAULT_FEE_LIMIT_SUN, PERMIT2_ADDRESSES + +MAX_UINT256 = (1 << 256) - 1 + + +def sign_trc20_approval_transaction( + signer: Any, token_address: str, network: str +) -> dict[str, Any]: + if not hasattr(signer, "build_trigger_smart_contract_transaction") or not hasattr( + signer, "sign_transaction" + ): + raise ValueError("TRON signer does not support approval transaction signing") + + spender = PERMIT2_ADDRESSES.get(network) + if not spender: + raise ValueError(f"No Permit2 contract address configured for network {network}") + + unsigned_tx = signer.build_trigger_smart_contract_transaction( + contract_address=token_address, + function_selector="approve(address,uint256)", + parameters=[ + {"type": "address", "value": spender}, + {"type": "uint256", "value": MAX_UINT256}, + ], + fee_limit=DEFAULT_FEE_LIMIT_SUN, + call_value=0, + owner_address=signer.address, + ) + + signed_tx = signer.sign_transaction(unsigned_tx) + signatures = signed_tx.get("signature") if isinstance(signed_tx, dict) else None + if not signatures: + raise ValueError("Failed to sign TRON approval transaction") + + return { + "from": signer.address, + "asset": token_address, + "spender": spender, + "amount": str(MAX_UINT256), + "signedTransaction": signed_tx, + "version": TRC20_APPROVAL_GAS_SPONSORING_VERSION, + } diff --git a/python/x402/src/bankofai/x402/mechanisms/tron/signer.py b/python/x402/src/bankofai/x402/mechanisms/tron/signer.py new file mode 100644 index 00000000..51e21a7f --- /dev/null +++ b/python/x402/src/bankofai/x402/mechanisms/tron/signer.py @@ -0,0 +1,75 @@ +"""TRON signer protocol definitions.""" + +from typing import Any, Protocol + + +class ClientTronSigner(Protocol): + """Client-side TRON signer for payment authorizations.""" + + @property + def address(self) -> str: ... + + def sign_typed_data( + self, + domain: dict[str, Any], + types: dict[str, list[dict[str, str]]], + primary_type: str, + message: dict[str, Any], + ) -> str: ... + + def read_contract( + self, + address: str, + function_name: str, + args: list[Any] | None = None, + ) -> Any: ... + + def build_trigger_smart_contract_transaction(self, **kwargs: Any) -> Any: ... + + def sign_transaction(self, transaction: Any) -> Any: ... + + +class FacilitatorTronSigner(Protocol): + """Facilitator-side TRON signer for verification and settlement.""" + + def get_addresses(self) -> list[str]: ... + + def read_contract( + self, + address: str, + function_name: str, + args: list[Any] | None = None, + ) -> Any: ... + + def verify_typed_data( + self, + address: str, + domain: dict[str, Any], + types: dict[str, list[dict[str, str]]], + primary_type: str, + message: dict[str, Any], + signature: str, + ) -> bool: ... + + def write_contract( + self, + address: str, + function_name: str, + args: list[Any], + fee_limit: int = 1_000_000_000, + ) -> str: ... + + def write_contract_with_abi( + self, + address: str, + function_name: str, + args: list[Any], + abi: list[dict[str, Any]], + fee_limit: int = 1_000_000_000, + ) -> str: ... + + def wait_for_transaction_receipt(self, tx_hash: str): ... + + def send_raw_transaction(self, signed_transaction: dict[str, Any]) -> str: ... + + def get_sign_weight(self, transaction: Any) -> Any: ... diff --git a/python/x402/src/bankofai/x402/mechanisms/tron/signers.py b/python/x402/src/bankofai/x402/mechanisms/tron/signers.py index bd72be55..c7bbb7a7 100644 --- a/python/x402/src/bankofai/x402/mechanisms/tron/signers.py +++ b/python/x402/src/bankofai/x402/mechanisms/tron/signers.py @@ -197,45 +197,10 @@ def write_contract_with_abi( Returns: Transaction hash string (txid). """ - # For ABIEncoderV2 contracts, we need to manually encode the function call - # and use triggersmartcontract directly - from tronpy.abi import trx_abi - - # Find the function ABI - func_abi = None - for item in abi: - if item.get("name") == function_name and item.get("type") == "function": - func_abi = item - break - - if not func_abi: - raise ValueError(f"Function {function_name} not found in ABI") - - def _get_type_string(param: dict[str, Any]) -> str: - if param.get("type", "").startswith("tuple"): - components = param.get("components", []) - inner = ",".join(_get_type_string(c) for c in components) - # handle tuple[] or tuple[2] - suffix = param["type"][5:] - return f"({inner}){suffix}" - return str(param["type"]) - - # Format function signature like: settle(((address,uint256),uint256,uint256),address,(address,address,uint256),bytes) - type_strings = [_get_type_string(inp) for inp in func_abi.get("inputs", [])] - signature = f"({','.join(type_strings)})" - func_selector = f"{function_name}{signature}" - - # Encode the function call args - parameter = trx_abi.encode_single(signature, tuple(args)).hex() - - # Build transaction using triggersmartcontract - txn = self._client.trx.trigger_smart_contract( - owner_address=self._address, - contract_address=address, - function_selector=func_selector, - parameter=parameter, - fee_limit=fee_limit, - ) + contract = self._client.get_contract(address) + contract.abi = abi + func = getattr(contract.functions, function_name) + txn = func(*args).with_owner(self._address).fee_limit(fee_limit).build() # Sign and broadcast signed_txn = txn.sign(self._pk) @@ -243,7 +208,7 @@ def _get_type_string(param: dict[str, Any]) -> str: return str(result.txid) def wait_for_transaction_receipt( - self, tx_hash: str, max_attempts: int = 30 + self, tx_hash: str, max_attempts: int = 120 ) -> TronTransactionReceipt: """Poll until the transaction is confirmed.""" for _ in range(max_attempts): @@ -259,6 +224,24 @@ def wait_for_transaction_receipt( time.sleep(1) return TronTransactionReceipt(status="pending", tx_hash=tx_hash) + def send_raw_transaction(self, signed_transaction: dict[str, Any]) -> str: + """Broadcast a signed transaction.""" + from tronpy.tron import Transaction + + tx: Any = signed_transaction + if isinstance(signed_transaction, dict): + tx = Transaction.from_json(signed_transaction, client=self._client) + result = tx.broadcast() + return str(result.txid) + + def get_sign_weight(self, transaction: Any) -> Any: + """Ask the node to validate the signed transaction signatures.""" + from tronpy.tron import Transaction + + if isinstance(transaction, dict): + transaction = Transaction.from_json(transaction, client=self._client) + return self._client.get_sign_weight(transaction) + # --------------------------------------------------------------------------- # Client signer @@ -317,3 +300,60 @@ def read_contract( contract = self._client.get_contract(address) func = getattr(contract.functions, function_name) return func(*(args or [])) + + def build_trigger_smart_contract_transaction(self, **kwargs: Any) -> Any: + """Build a trigger smart contract transaction.""" + contract_address = kwargs.get("contract_address") + function_selector = kwargs.get("function_selector") + parameters = kwargs.get("parameters") or [] + owner_address = kwargs.get("owner_address") or self._address + fee_limit = kwargs.get("fee_limit") + call_value = kwargs.get("call_value", 0) + + if not contract_address or not function_selector: + raise AttributeError("TRON client does not support trigger smart contract transactions") + + method_name = str(function_selector).split("(", 1)[0] + contract = self._client.get_contract(contract_address) + func = getattr(contract.functions, method_name, None) + if func is None: + raise AttributeError("TRON client does not support trigger smart contract transactions") + + args: list[Any] = [] + for param in parameters: + value = param.get("value") + if param.get("type") == "uint256" and isinstance(value, str): + try: + value = int(value) + except ValueError: + pass + args.append(value) + txn_builder = func(*args).with_owner(owner_address) + if fee_limit is not None: + txn_builder = txn_builder.fee_limit(int(fee_limit)) + if call_value: + txn_builder = txn_builder.with_transfer(int(call_value)) + txn = txn_builder.build() + return txn + + def sign_transaction(self, transaction: dict[str, Any]) -> dict[str, Any]: + """Sign a raw transaction dict.""" + if hasattr(transaction, "sign"): + signed = transaction.sign(self._pk) + try: + return signed.to_json() + except Exception: + return signed # type: ignore[return-value] + if isinstance(transaction, dict): + from tronpy.tron import Transaction + + signed = Transaction.from_json(transaction, client=self._client).sign(self._pk) + try: + return signed.to_json() + except Exception: + return signed # type: ignore[return-value] + txn = self._client.trx.sign(transaction, self._pk) + try: + return txn.to_json() + except Exception: + return dict(txn) diff --git a/python/x402/src/bankofai/x402/mechanisms/tron/types.py b/python/x402/src/bankofai/x402/mechanisms/tron/types.py new file mode 100644 index 00000000..042afa9a --- /dev/null +++ b/python/x402/src/bankofai/x402/mechanisms/tron/types.py @@ -0,0 +1,128 @@ +"""TRON-specific payload and data types.""" + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class ExactEIP3009Authorization: + """TIP-712 TransferWithAuthorization data.""" + + from_address: str + to: str + value: str + valid_after: str + valid_before: str + nonce: str + + +@dataclass +class ExactEIP3009Payload: + """Exact payment payload for TRON networks.""" + + authorization: ExactEIP3009Authorization + signature: str | None = None + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = { + "authorization": { + "from": self.authorization.from_address, + "to": self.authorization.to, + "value": self.authorization.value, + "validAfter": self.authorization.valid_after, + "validBefore": self.authorization.valid_before, + "nonce": self.authorization.nonce, + } + } + if self.signature: + result["signature"] = self.signature + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ExactEIP3009Payload": + auth = data.get("authorization", {}) + return cls( + authorization=ExactEIP3009Authorization( + from_address=auth.get("from", ""), + to=auth.get("to", ""), + value=auth.get("value", ""), + valid_after=auth.get("validAfter", ""), + valid_before=auth.get("validBefore", ""), + nonce=auth.get("nonce", ""), + ), + signature=data.get("signature"), + ) + + +@dataclass +class Permit2Witness: + to: str + facilitator: str + valid_after: str + + +@dataclass +class Permit2Authorization: + from_address: str + permitted_token: str + permitted_amount: str + spender: str + nonce: str + deadline: str + witness: Permit2Witness + + +@dataclass +class ExactPermit2Payload: + permit2_authorization: Permit2Authorization + signature: str + + def to_dict(self) -> dict[str, Any]: + return { + "signature": self.signature, + "permit2Authorization": { + "from": self.permit2_authorization.from_address, + "permitted": { + "token": self.permit2_authorization.permitted_token, + "amount": self.permit2_authorization.permitted_amount, + }, + "spender": self.permit2_authorization.spender, + "nonce": self.permit2_authorization.nonce, + "deadline": self.permit2_authorization.deadline, + "witness": { + "to": self.permit2_authorization.witness.to, + "facilitator": self.permit2_authorization.witness.facilitator, + "validAfter": self.permit2_authorization.witness.valid_after, + }, + }, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ExactPermit2Payload": + auth = data.get("permit2Authorization", {}) + permitted = auth.get("permitted", {}) + witness = auth.get("witness", {}) + return cls( + permit2_authorization=Permit2Authorization( + from_address=auth.get("from", ""), + permitted_token=permitted.get("token", ""), + permitted_amount=permitted.get("amount", ""), + spender=auth.get("spender", ""), + nonce=auth.get("nonce", ""), + deadline=auth.get("deadline", ""), + witness=Permit2Witness( + to=witness.get("to", ""), + facilitator=witness.get("facilitator", ""), + valid_after=witness.get("validAfter", ""), + ), + ), + signature=data.get("signature", ""), + ) + + +ExactTronPayloadV1 = ExactEIP3009Payload +ExactTronPayloadV2 = ExactEIP3009Payload | ExactPermit2Payload + + +def is_permit2_payload(data: dict[str, Any]) -> bool: + return "permit2Authorization" in data diff --git a/python/x402/src/bankofai/x402/mechanisms/tron/utils.py b/python/x402/src/bankofai/x402/mechanisms/tron/utils.py index 5414abd8..545e53ac 100644 --- a/python/x402/src/bankofai/x402/mechanisms/tron/utils.py +++ b/python/x402/src/bankofai/x402/mechanisms/tron/utils.py @@ -1,6 +1,8 @@ """TRON utility functions — address conversion and chain helpers.""" -from .constants import TRON_CHAIN_IDS +import os + +from .constants import TRON_CHAIN_IDS, TRON_NETWORK_CONFIGS, AssetInfo, NetworkConfig def get_tron_chain_id(network: str) -> int: @@ -13,15 +15,32 @@ def get_tron_chain_id(network: str) -> int: return chain_id +def get_network_config(network: str) -> NetworkConfig: + """Get configuration for a TRON network identifier.""" + if network in TRON_NETWORK_CONFIGS: + return TRON_NETWORK_CONFIGS[network] + if network.startswith("tron:"): + raise ValueError(f"Unknown TRON network: {network}") + raise ValueError(f"Unsupported network format: {network} (expected tron:*)") + + +def get_asset_info(network: str, asset_address: str) -> AssetInfo: + """Get asset info by address.""" + config = get_network_config(network) + default = config.get("default_asset") + if default and default["address"].lower() == asset_address.lower(): + return default + raise ValueError(f"Token {asset_address} is not a registered asset for network {network}.") + + def tron_address_to_evm(address: str) -> str: """Convert TRON Base58Check address to 0x-prefixed EVM hex address.""" - from tronpy.keys import to_hex_address # type: ignore # tronpy helper + from tronpy.keys import to_hex_address # type: ignore if address.startswith("0x"): return address.lower() if address.startswith("41") and len(address) == 42: return f"0x{address[2:].lower()}" - # Base58Check → hex via tronpy hex_addr = to_hex_address(address) # returns "41xxxx..." return f"0x{hex_addr[2:].lower()}" @@ -37,6 +56,19 @@ def normalize_address_for_signing(address: str) -> str: raise ValueError(f"Unrecognized address format: {address}") +def normalize_address_for_contract_call(address: str) -> str: + """Normalize address to TRON base58 format for tronpy contract calls.""" + from tronpy.keys import to_base58check_address # type: ignore + + if address.startswith("T") and len(address) == 34: + return address + if address.startswith("0x") and len(address) == 42: + return to_base58check_address("41" + address[2:]) + if address.startswith("41") and len(address) == 42: + return to_base58check_address(address) + raise ValueError(f"Unrecognized address format: {address}") + + def is_tron_address(address: str) -> bool: """Return True if address looks like a TRON Base58Check address.""" return address.startswith("T") and len(address) == 34 @@ -44,6 +76,4 @@ def is_tron_address(address: str) -> bool: def create_nonce() -> str: """Generate a random 32-byte hex nonce (0x-prefixed).""" - import os - return "0x" + os.urandom(32).hex() diff --git a/python/x402/tests/unit/core/test_client.py b/python/x402/tests/unit/core/test_client.py index 348715d4..bcb985b4 100644 --- a/python/x402/tests/unit/core/test_client.py +++ b/python/x402/tests/unit/core/test_client.py @@ -27,7 +27,7 @@ def __init__(self, scheme: str = "mock"): self.scheme = scheme self.create_calls: list = [] - def create_payment_payload(self, requirements): + def create_payment_payload(self, requirements, context=None): self.create_calls.append(requirements) return {"mock": "payload", "network": requirements.network} @@ -40,7 +40,7 @@ class MockSchemeClientV1: def __init__(self, scheme: str = "mock-v1"): self.scheme = scheme - def create_payment_payload(self, requirements): + def create_payment_payload(self, requirements, context=None): return {"mock": "v1-payload", "network": requirements.network} @@ -487,6 +487,81 @@ def test_auto_adaptive_sync_v1_then_v2(self): assert result_v2.x402_version == 2 +class TestClientExtensionMerging: + """Tests for client-side extension merging.""" + + @pytest.mark.asyncio + async def test_client_merges_scheme_extensions(self): + from bankofai.x402.schemas import PaymentPayload, PaymentRequired, PaymentRequirements + + class MockSchemeClientWithExtensions(MockSchemeClient): + def create_payment_payload(self, requirements, context=None): + return {"mock": "payload"}, {"extA": {"info": {"ok": True}}} + + client = x402Client() + client.register("eip155:8453", MockSchemeClientWithExtensions()) + + payment_required = PaymentRequired( + x402_version=2, + accepts=[ + PaymentRequirements( + scheme="mock", + network="eip155:8453", + asset="0x0000000000000000000000000000000000000000", + amount="1000000", + pay_to="0x1234567890123456789012345678901234567890", + max_timeout_seconds=300, + ), + ], + extensions={"serverExt": {"info": {"server": True}}}, + ) + + result = await client.create_payment_payload(payment_required) + + assert isinstance(result, PaymentPayload) + assert result.extensions is not None + assert "serverExt" in result.extensions + assert "extA" in result.extensions + + @pytest.mark.asyncio + async def test_client_passes_context_extensions_to_scheme(self): + from bankofai.x402.schemas import PaymentPayload, PaymentRequired, PaymentRequirements + + class MockSchemeClientWithContext(MockSchemeClient): + def __init__(self): + super().__init__() + self.last_context = None + + def create_payment_payload(self, requirements, context=None): + self.last_context = context + return {"mock": "payload"} + + client = x402Client() + mock = MockSchemeClientWithContext() + client.register("eip155:8453", mock) + + payment_required = PaymentRequired( + x402_version=2, + accepts=[ + PaymentRequirements( + scheme="mock", + network="eip155:8453", + asset="0x0000000000000000000000000000000000000000", + amount="1000000", + pay_to="0x1234567890123456789012345678901234567890", + max_timeout_seconds=300, + ), + ], + extensions={"serverExt": {"info": {"server": True}}}, + ) + + result = await client.create_payment_payload(payment_required) + + assert isinstance(result, PaymentPayload) + assert mock.last_context is not None + assert mock.last_context.extensions == payment_required.extensions + + class TestX402ClientV1Hooks: """Tests for hook execution on v1 path.""" diff --git a/python/x402/tests/unit/extensions/test_eip2612_gas_sponsoring.py b/python/x402/tests/unit/extensions/test_eip2612_gas_sponsoring.py new file mode 100644 index 00000000..6467965b --- /dev/null +++ b/python/x402/tests/unit/extensions/test_eip2612_gas_sponsoring.py @@ -0,0 +1,34 @@ +"""Tests for EIP-2612 gas sponsoring extension.""" + +from bankofai.x402.extensions.eip2612_gas_sponsoring import ( + EIP2612_GAS_SPONSORING, + declare_eip2612_gas_sponsoring_extension, + extract_eip2612_gas_sponsoring_info, + validate_eip2612_gas_sponsoring_info, +) + + +def test_declare_extension(): + ext = declare_eip2612_gas_sponsoring_extension() + assert EIP2612_GAS_SPONSORING.key in ext + + +def test_extract_and_validate_info(): + payload_ext = { + EIP2612_GAS_SPONSORING.key: { + "info": { + "from": "0x123", + "asset": "0xabc", + "spender": "0xdef", + "amount": "100", + "nonce": "1", + "deadline": "999999", + "signature": "0x" + "11" * 65, + "version": "1", + }, + "schema": {}, + } + } + info = extract_eip2612_gas_sponsoring_info(payload_ext) + assert info is not None + assert validate_eip2612_gas_sponsoring_info(info) is True diff --git a/python/x402/tests/unit/extensions/test_erc20_approval_gas_sponsoring.py b/python/x402/tests/unit/extensions/test_erc20_approval_gas_sponsoring.py new file mode 100644 index 00000000..e6ec8578 --- /dev/null +++ b/python/x402/tests/unit/extensions/test_erc20_approval_gas_sponsoring.py @@ -0,0 +1,32 @@ +"""Tests for ERC-20 approval gas sponsoring extension.""" + +from bankofai.x402.extensions.erc20_approval_gas_sponsoring import ( + ERC20_APPROVAL_GAS_SPONSORING, + declare_erc20_approval_gas_sponsoring_extension, + extract_erc20_approval_gas_sponsoring_info, + validate_erc20_approval_gas_sponsoring_info, +) + + +def test_declare_extension(): + ext = declare_erc20_approval_gas_sponsoring_extension() + assert ERC20_APPROVAL_GAS_SPONSORING.key in ext + + +def test_extract_and_validate_info(): + payload_ext = { + ERC20_APPROVAL_GAS_SPONSORING.key: { + "info": { + "from": "0x123", + "asset": "0xabc", + "spender": "0xdef", + "amount": "100", + "signedTransaction": "0x" + "11" * 10, + "version": "1", + }, + "schema": {}, + } + } + info = extract_erc20_approval_gas_sponsoring_info(payload_ext) + assert info is not None + assert validate_erc20_approval_gas_sponsoring_info(info) is True diff --git a/python/x402/tests/unit/mechanisms/evm/test_client.py b/python/x402/tests/unit/mechanisms/evm/test_client.py index 23329eb7..c354e1a3 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_client.py +++ b/python/x402/tests/unit/mechanisms/evm/test_client.py @@ -8,6 +8,11 @@ pytest.skip("EVM client requires eth_account", allow_module_level=True) +from bankofai.x402.extensions.eip2612_gas_sponsoring import EIP2612_GAS_SPONSORING +from bankofai.x402.extensions.erc20_approval_gas_sponsoring import ( + ERC20_APPROVAL_GAS_SPONSORING, +) +from bankofai.x402.interfaces import PaymentPayloadContext from bankofai.x402.mechanisms.evm.exact import ExactEvmClientScheme from bankofai.x402.mechanisms.evm.signers import EthAccountSigner from bankofai.x402.mechanisms.evm.utils import get_asset_info @@ -75,6 +80,86 @@ def test_should_accept_v2_requirements_with_amount_field(self): assert requirements.amount == "500000" assert client.scheme == "exact" + def test_permit2_builds_eip2612_extension(self): + class DummySigner: + address = "0x1234567890123456789012345678901234567890" + + def sign_typed_data(self, *args, **kwargs): + return b"\x01" * 65 + + def read_contract(self, address, abi, function_name, *args): + if function_name == "allowance": + return 0 + if function_name == "nonces": + return 1 + return 0 + + client = ExactEvmClientScheme(DummySigner()) + requirements = PaymentRequirements( + scheme="exact", + network="eip155:8453", + asset="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + amount="1000", + pay_to="0x0987654321098765432109876543210987654321", + max_timeout_seconds=3600, + extra={ + "name": "USD Coin", + "version": "2", + "assetTransferMethod": "permit2", + "permit2FacilitatorAddress": "0x1111111111111111111111111111111111111111", + }, + ) + + context = PaymentPayloadContext(extensions={EIP2612_GAS_SPONSORING.key: {}}) + result = client.create_payment_payload(requirements, context) + + assert isinstance(result, tuple) + payload, extensions = result + assert "permit2Authorization" in payload + assert EIP2612_GAS_SPONSORING.key in extensions + + def test_permit2_builds_erc20_extension(self): + class DummySigner: + address = "0x1234567890123456789012345678901234567890" + + def sign_typed_data(self, *args, **kwargs): + return b"\x01" * 65 + + def read_contract(self, address, abi, function_name, *args): + if function_name == "allowance": + return 0 + return 0 + + def sign_transaction(self, tx): + return b"\x02" * 10 + + def get_transaction_count(self, address): + return 1 + + client = ExactEvmClientScheme(DummySigner()) + requirements = PaymentRequirements( + scheme="exact", + network="eip155:8453", + asset="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + amount="1000", + pay_to="0x0987654321098765432109876543210987654321", + max_timeout_seconds=3600, + extra={ + "name": "USD Coin", + "version": "2", + "assetTransferMethod": "permit2", + "permit2FacilitatorAddress": "0x1111111111111111111111111111111111111111", + }, + ) + + context = PaymentPayloadContext(extensions={ERC20_APPROVAL_GAS_SPONSORING.key: {}}) + result = client.create_payment_payload(requirements, context) + + assert isinstance(result, tuple) + payload, extensions = result + assert "permit2Authorization" in payload + assert ERC20_APPROVAL_GAS_SPONSORING.key in extensions + def test_requirements_must_have_eip712_domain(self): """Requirements must have EIP-712 domain in extra.""" account = Account.create() @@ -174,8 +259,29 @@ def test_raw_local_account_can_sign_payload(self): payload = client.create_payment_payload(requirements) - assert isinstance(payload, dict) assert "authorization" in payload assert "signature" in payload - assert payload["signature"].startswith("0x") - assert len(payload["signature"]) > 2 # not just "0x" + + def test_local_account_uses_network_specific_rpc_resolution(self, monkeypatch): + account = Account.create() + monkeypatch.delenv("EVM_RPC_URL", raising=False) + monkeypatch.delenv("WEB3_PROVIDER_URL", raising=False) + monkeypatch.setenv("EVM_RPC_URL_84532", "https://example-base-sepolia.local") + + client = ExactEvmClientScheme(signer=account) + + requirements = PaymentRequirements( + scheme="exact", + network="eip155:84532", + asset="0x036CbD53842c5426634e7929541eC2318f3dCF7e", + amount="500000", + pay_to="0x0987654321098765432109876543210987654321", + max_timeout_seconds=3600, + extra={"name": "USDC", "version": "2"}, + ) + client.create_payment_payload(requirements) + + network_signer = client._network_signers["eip155:84532"] + assert isinstance(network_signer, EthAccountSigner) + assert network_signer._w3 is not None + assert network_signer._w3.provider.endpoint_uri == "https://example-base-sepolia.local" diff --git a/python/x402/tests/unit/mechanisms/evm/test_facilitator.py b/python/x402/tests/unit/mechanisms/evm/test_facilitator.py index 2b6868ca..315675d9 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_facilitator.py +++ b/python/x402/tests/unit/mechanisms/evm/test_facilitator.py @@ -411,14 +411,14 @@ def test_caip_family_attribute(self): assert facilitator.caip_family == "eip155:*" - def test_get_extra_returns_none(self): - """get_extra should return None for EVM.""" + def test_get_extra_returns_permit2_facilitator_address(self): + """get_extra should expose Permit2 facilitator metadata for EVM.""" signer = MockFacilitatorSigner() facilitator = ExactEvmFacilitatorScheme(signer) extra = facilitator.get_extra("eip155:8453") - assert extra is None + assert extra == {"permit2FacilitatorAddress": signer.get_addresses()[0]} def test_get_signers_returns_signer_addresses(self): """get_signers should return list of signer addresses.""" diff --git a/python/x402/tests/unit/mechanisms/evm/test_permit2_extensions.py b/python/x402/tests/unit/mechanisms/evm/test_permit2_extensions.py new file mode 100644 index 00000000..71b4ef40 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/evm/test_permit2_extensions.py @@ -0,0 +1,190 @@ +"""Tests for EVM Permit2 gas sponsoring extensions.""" + +from eth_abi import encode +from eth_account import Account +from eth_utils import keccak + +from bankofai.x402.extensions.eip2612_gas_sponsoring import EIP2612_GAS_SPONSORING +from bankofai.x402.extensions.erc20_approval_gas_sponsoring import ( + ERC20_APPROVAL_GAS_SPONSORING, + Erc20ApprovalGasSponsoringExtension, +) +from bankofai.x402.interfaces import FacilitatorContext +from bankofai.x402.mechanisms.evm import ( + get_evm_chain_id, + get_permit2_address, + get_x402_exact_permit2_proxy_address, +) +from bankofai.x402.mechanisms.evm.exact.permit2 import verify_permit2 +from bankofai.x402.mechanisms.evm.types import ExactPermit2Payload +from bankofai.x402.schemas import PaymentPayload, PaymentRequirements + +PERMIT2_FACILITATOR_ADDRESS = "0x1111111111111111111111111111111111111111" + + +class DummySigner: + def __init__(self, balance: int = 10**9): + self._balance = balance + + def get_addresses(self): + return ["0xF000000000000000000000000000000000000001"] + + def read_contract(self, address, abi, function_name, *args): + if function_name == "allowance": + return 0 + if function_name == "balanceOf": + return self._balance + return 0 + + def verify_typed_data(self, *args, **kwargs): + return True + + def write_contract(self, *args, **kwargs): + return "0x" + "00" * 32 + + def wait_for_transaction_receipt(self, tx_hash): + from bankofai.x402.mechanisms.evm.types import TransactionReceipt + + return TransactionReceipt(status=1, block_number=1, tx_hash=tx_hash) + + +def _build_requirements(): + return PaymentRequirements( + scheme="exact", + network="eip155:8453", + asset="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + amount="1000", + pay_to="0x0987654321098765432109876543210987654321", + max_timeout_seconds=3600, + extra={ + "name": "USD Coin", + "version": "2", + "assetTransferMethod": "permit2", + "permit2FacilitatorAddress": PERMIT2_FACILITATOR_ADDRESS, + }, + ) + + +def _build_permit2_payload(requirements: PaymentRequirements, payer: str) -> dict: + return { + "signature": "0x" + "11" * 65, + "permit2Authorization": { + "from": payer, + "permitted": {"token": requirements.asset, "amount": requirements.amount}, + "spender": get_x402_exact_permit2_proxy_address(str(requirements.network)), + "nonce": "1", + "deadline": str(9999999999), + "witness": { + "to": requirements.pay_to, + "facilitator": requirements.extra["permit2FacilitatorAddress"], + "validAfter": "0", + }, + }, + } + + +def _build_signed_erc20_approval_tx( + private_key: str, token: str, spender: str, chain_id: int +) -> str: + account = Account.from_key(private_key) + selector = keccak(text="approve(address,uint256)")[:4] + calldata = selector + encode(["address", "uint256"], [spender, 2**256 - 1]) + signed = account.sign_transaction( + { + "to": token, + "data": "0x" + calldata.hex(), + "value": 0, + "nonce": 1, + "gas": 100000, + "maxFeePerGas": 1_000_000_000, + "maxPriorityFeePerGas": 100_000_000, + "chainId": chain_id, + } + ) + raw = getattr(signed, "raw_transaction", None) or getattr(signed, "rawTransaction", None) + return "0x" + bytes(raw).hex() + + +def test_verify_permit2_accepts_eip2612_extension(): + requirements = _build_requirements() + payload_dict = _build_permit2_payload( + requirements, payer="0x1234567890123456789012345678901234567890" + ) + + payload = PaymentPayload( + x402_version=2, + accepted=requirements, + payload=payload_dict, + extensions={ + EIP2612_GAS_SPONSORING.key: { + "info": { + "from": "0x1234567890123456789012345678901234567890", + "asset": requirements.asset, + "spender": get_permit2_address(str(requirements.network)), + "amount": requirements.amount, + "nonce": "1", + "deadline": str(9999999999), + "signature": "0x" + "11" * 65, + "version": "1", + }, + "schema": {}, + } + }, + ) + + result = verify_permit2( + DummySigner(), + payload, + requirements, + ExactPermit2Payload.from_dict(payload_dict), + ) + assert result.is_valid is True + + +def test_verify_permit2_accepts_erc20_extension_with_context(): + requirements = _build_requirements() + payer_key = "0x59c6995e998f97a5a0044976f7ad0dc2f1b362f8c6f7f3d9f9a6b8b63f7f3f4f" + payer = Account.from_key(payer_key).address + payload_dict = _build_permit2_payload(requirements, payer=payer) + signed_tx = _build_signed_erc20_approval_tx( + payer_key, + requirements.asset, + get_permit2_address(str(requirements.network)), + get_evm_chain_id(str(requirements.network)), + ) + + payload = PaymentPayload( + x402_version=2, + accepted=requirements, + payload=payload_dict, + extensions={ + ERC20_APPROVAL_GAS_SPONSORING.key: { + "info": { + "from": payer, + "asset": requirements.asset, + "spender": get_permit2_address(str(requirements.network)), + "amount": requirements.amount, + "signedTransaction": signed_tx, + "version": "1", + }, + "schema": {}, + } + }, + ) + + context = FacilitatorContext( + { + ERC20_APPROVAL_GAS_SPONSORING.key: Erc20ApprovalGasSponsoringExtension( + key=ERC20_APPROVAL_GAS_SPONSORING.key + ) + } + ) + + result = verify_permit2( + DummySigner(), + payload, + requirements, + ExactPermit2Payload.from_dict(payload_dict), + context, + ) + assert result.is_valid is True diff --git a/python/x402/tests/unit/mechanisms/evm/test_signer.py b/python/x402/tests/unit/mechanisms/evm/test_signer.py index 303507ba..a1b0640d 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_signer.py +++ b/python/x402/tests/unit/mechanisms/evm/test_signer.py @@ -69,6 +69,50 @@ def test_should_sign_typed_data(self): assert isinstance(signature, bytes) assert len(signature) >= 65 # ECDSA signature is 65 bytes + def test_should_sign_transaction(self): + account = Account.create() + signer = EthAccountSigner(account) + tx = { + "to": account.address, + "value": 0, + "nonce": 0, + "gas": 21000, + "maxFeePerGas": 1, + "maxPriorityFeePerGas": 1, + "chainId": 1, + } + signed = signer.sign_transaction(tx) + assert isinstance(signed, (bytes, bytearray)) + assert len(signed) > 0 + + def test_should_use_env_rpc_url_default(self, monkeypatch): + account = Account.create() + monkeypatch.setenv("EVM_RPC_URL", "https://bsc-testnet-rpc.publicnode.com") + signer = EthAccountSigner(account) + assert signer._w3 is not None + + def test_should_use_chain_specific_env_rpc_url(self, monkeypatch): + account = Account.create() + monkeypatch.delenv("EVM_RPC_URL", raising=False) + monkeypatch.delenv("WEB3_PROVIDER_URL", raising=False) + monkeypatch.setenv("EVM_RPC_URL_97", "https://example-bsc.local") + + signer = EthAccountSigner(account, network="eip155:97") + + assert signer._w3 is not None + assert signer._w3.provider.endpoint_uri == "https://example-bsc.local" + + def test_should_use_network_default_rpc_url(self, monkeypatch): + account = Account.create() + monkeypatch.delenv("EVM_RPC_URL", raising=False) + monkeypatch.delenv("WEB3_PROVIDER_URL", raising=False) + monkeypatch.delenv("EVM_RPC_URL_84532", raising=False) + + signer = EthAccountSigner(account, network="eip155:84532") + + assert signer._w3 is not None + assert signer._w3.provider.endpoint_uri == "https://sepolia.base.org" + class TestFacilitatorWeb3Signer: """Test FacilitatorWeb3Signer facilitator-side signer.""" diff --git a/python/x402/tests/unit/mechanisms/evm/test_types.py b/python/x402/tests/unit/mechanisms/evm/test_types.py index d3b764ba..02ff6d91 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_types.py +++ b/python/x402/tests/unit/mechanisms/evm/test_types.py @@ -1,10 +1,13 @@ """Tests for EVM payload types.""" +from typing import get_args + from bankofai.x402.mechanisms.evm import ( ExactEIP3009Authorization, ExactEIP3009Payload, ExactEvmPayloadV1, ExactEvmPayloadV2, + ExactPermit2Payload, ) @@ -178,10 +181,12 @@ def test_v1_should_be_alias_of_eip3009_payload(self): """V1 should be alias of ExactEIP3009Payload.""" assert ExactEvmPayloadV1 is ExactEIP3009Payload - def test_v2_should_be_alias_of_eip3009_payload(self): - """V2 should be alias of ExactEIP3009Payload.""" - assert ExactEvmPayloadV2 is ExactEIP3009Payload + def test_v2_should_include_eip3009_and_permit2_payloads(self): + """V2 should support both EIP-3009 and Permit2 payload variants.""" + args = get_args(ExactEvmPayloadV2) + assert ExactEIP3009Payload in args + assert ExactPermit2Payload in args - def test_v1_and_v2_should_be_same(self): - """V1 and V2 should be the same type.""" - assert ExactEvmPayloadV1 is ExactEvmPayloadV2 + def test_v1_and_v2_should_differ(self): + """V1 remains EIP-3009-only while V2 is a discriminated union.""" + assert ExactEvmPayloadV1 is not ExactEvmPayloadV2 diff --git a/python/x402/tests/unit/mechanisms/tron/__init__.py b/python/x402/tests/unit/mechanisms/tron/__init__.py new file mode 100644 index 00000000..31f66dc1 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tron/__init__.py @@ -0,0 +1 @@ +"""Unit tests for TRON mechanisms.""" diff --git a/python/x402/tests/unit/mechanisms/tron/test_client.py b/python/x402/tests/unit/mechanisms/tron/test_client.py new file mode 100644 index 00000000..f861b3d0 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tron/test_client.py @@ -0,0 +1,94 @@ +"""Tests for TRON Exact client scheme.""" + +import pytest + +from bankofai.x402.extensions.trc20_approval_gas_sponsoring import TRC20_APPROVAL_GAS_SPONSORING +from bankofai.x402.interfaces import PaymentPayloadContext +from bankofai.x402.mechanisms.tron.exact import ExactTronClientScheme +from bankofai.x402.schemas import PaymentRequirements + + +class DummySigner: + address = "0x" + "11" * 20 + + def sign_typed_data(self, *args, **kwargs): + return "0x" + "aa" * 65 + + def read_contract(self, address: str, function_name: str, args=None): + if function_name == "allowance": + return 0 + return 0 + + def build_trigger_smart_contract_transaction(self, **kwargs): + return {"raw_data": {"contract": [{"parameter": {"value": {}}}]}} + + def sign_transaction(self, transaction): + return {"raw_data": transaction.get("raw_data", {}), "signature": ["0x01"]} + + +def _base_requirements(extra: dict | None = None) -> PaymentRequirements: + return PaymentRequirements( + scheme="exact", + network="tron:nile", + asset="0x" + "22" * 20, + amount="1000", + pay_to="0x" + "33" * 20, + max_timeout_seconds=3600, + extra=extra or {}, + ) + + +def test_create_eip3009_payload(): + client = ExactTronClientScheme(DummySigner()) + requirements = _base_requirements(extra={"name": "USDT", "version": "1"}) + payload = client.create_payment_payload(requirements) + assert "authorization" in payload + assert payload["authorization"]["from"] == DummySigner.address + assert payload["authorization"]["to"] == requirements.pay_to + assert payload["signature"].startswith("0x") + + +def test_create_permit2_payload(monkeypatch): + import bankofai.x402.mechanisms.tron.exact.client as tron_client + + monkeypatch.setitem(tron_client.PERMIT2_ADDRESSES, "tron:nile", "0x" + "44" * 20) + monkeypatch.setitem(tron_client.X402_PERMIT2_PROXY_ADDRESSES, "tron:nile", "0x" + "55" * 20) + + client = ExactTronClientScheme(DummySigner()) + requirements = _base_requirements( + extra={ + "assetTransferMethod": "permit2", + "permit2FacilitatorAddress": "0x" + "66" * 20, + } + ) + + payload = client.create_payment_payload(requirements) + assert "permit2Authorization" in payload + assert payload["permit2Authorization"]["spender"] == "0x" + "55" * 20 + + +def test_create_permit2_payload_with_trc20_extension(monkeypatch): + import bankofai.x402.mechanisms.tron.exact.client as tron_client + + monkeypatch.setitem(tron_client.PERMIT2_ADDRESSES, "tron:nile", "0x" + "44" * 20) + monkeypatch.setitem(tron_client.X402_PERMIT2_PROXY_ADDRESSES, "tron:nile", "0x" + "55" * 20) + + client = ExactTronClientScheme(DummySigner()) + requirements = _base_requirements( + extra={ + "assetTransferMethod": "permit2", + "permit2FacilitatorAddress": "0x" + "66" * 20, + } + ) + context = PaymentPayloadContext(extensions={TRC20_APPROVAL_GAS_SPONSORING.key: {}}) + + payload, extensions = client.create_payment_payload(requirements, context) + assert "permit2Authorization" in payload + assert TRC20_APPROVAL_GAS_SPONSORING.key in extensions + + +def test_create_payload_requires_tip712_domain(): + client = ExactTronClientScheme(DummySigner()) + requirements = _base_requirements(extra={}) + with pytest.raises(ValueError, match="TIP-712 domain"): + client.create_payment_payload(requirements) diff --git a/python/x402/tests/unit/mechanisms/tron/test_eip3009.py b/python/x402/tests/unit/mechanisms/tron/test_eip3009.py new file mode 100644 index 00000000..b2753911 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tron/test_eip3009.py @@ -0,0 +1,106 @@ +"""Tests for TRON EIP-3009 facilitator logic.""" + +import time + +from bankofai.x402.mechanisms.tron.constants import ERR_INVALID_SIGNATURE +from bankofai.x402.mechanisms.tron.exact.eip3009 import settle_eip3009, verify_eip3009 +from bankofai.x402.schemas import PaymentPayload, PaymentRequirements + + +class DummySigner: + def __init__(self, valid_signature: bool = True): + self._valid_signature = valid_signature + + def verify_typed_data(self, *args, **kwargs): + return self._valid_signature + + def read_contract(self, *args, **kwargs): + return 10**12 + + def write_contract(self, *args, **kwargs): + return "0x" + "00" * 32 + + def wait_for_transaction_receipt(self, tx_hash: str): + class Receipt: + status = "success" + + return Receipt() + + +class Base58WriteSigner(DummySigner): + def read_contract(self, address, function_name, args=None): + args = args or [] + for arg in args: + if isinstance(arg, str): + assert arg.startswith("T") and len(arg) == 34 + return super().read_contract(address, function_name, args) + + def write_contract(self, address, function_name, args, fee_limit=1_000_000_000): + for arg in args[:2]: + if isinstance(arg, str): + assert arg.startswith("T") and len(arg) == 34 + return super().write_contract(address, function_name, args, fee_limit) + + +def _requirements() -> PaymentRequirements: + return PaymentRequirements( + scheme="exact", + network="tron:nile", + asset="0x" + "11" * 20, + amount="1000", + pay_to="0x" + "22" * 20, + max_timeout_seconds=3600, + extra={"name": "USDT", "version": "1"}, + ) + + +def _raw_payload(req: PaymentRequirements) -> dict: + now = int(time.time()) + return { + "authorization": { + "from": "0x" + "33" * 20, + "to": req.pay_to, + "value": req.amount, + "validAfter": str(now - 600), + "validBefore": str(now + 3600), + "nonce": "0x" + "11" * 32, + }, + "signature": "0x" + "aa" * 65, + } + + +def test_verify_eip3009_valid(): + req = _requirements() + raw = _raw_payload(req) + payload = PaymentPayload(x402_version=2, payload=raw, accepted=req) + result = verify_eip3009(DummySigner(valid_signature=True), payload, req, raw) + assert result.is_valid is True + + +def test_verify_eip3009_invalid_signature(): + req = _requirements() + raw = _raw_payload(req) + payload = PaymentPayload(x402_version=2, payload=raw, accepted=req) + result = verify_eip3009(DummySigner(valid_signature=False), payload, req, raw) + assert result.is_valid is False + assert result.invalid_reason == ERR_INVALID_SIGNATURE + + +def test_settle_eip3009_success(): + req = _requirements() + raw = _raw_payload(req) + payload = PaymentPayload(x402_version=2, payload=raw, accepted=req) + result = settle_eip3009(DummySigner(valid_signature=True), payload, req, raw) + assert result.success is True + + +def test_eip3009_uses_base58_addresses_for_contract_calls(): + req = _requirements() + raw = _raw_payload(req) + payload = PaymentPayload(x402_version=2, payload=raw, accepted=req) + + verify_result = verify_eip3009(Base58WriteSigner(valid_signature=True), payload, req, raw) + assert verify_result.is_valid is True + + settle_result = settle_eip3009(Base58WriteSigner(valid_signature=True), payload, req, raw) + assert settle_result.success is True diff --git a/python/x402/tests/unit/mechanisms/tron/test_facilitator.py b/python/x402/tests/unit/mechanisms/tron/test_facilitator.py new file mode 100644 index 00000000..51454564 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tron/test_facilitator.py @@ -0,0 +1,79 @@ +"""Tests for TRON facilitator scheme.""" + +import time + +from bankofai.x402.mechanisms.tron.exact import ExactTronFacilitatorScheme +from bankofai.x402.schemas import PaymentPayload, PaymentRequirements + + +class DummySigner: + def __init__(self): + self._address = "0x" + "aa" * 20 + + def get_addresses(self): + return [self._address] + + def verify_typed_data(self, *args, **kwargs): + return True + + def read_contract(self, *args, **kwargs): + return 10**12 + + def write_contract(self, *args, **kwargs): + return "0x" + "00" * 32 + + def wait_for_transaction_receipt(self, tx_hash: str): + class Receipt: + status = "success" + + return Receipt() + + +def _requirements() -> PaymentRequirements: + return PaymentRequirements( + scheme="exact", + network="tron:nile", + asset="0x" + "11" * 20, + amount="1000", + pay_to="0x" + "22" * 20, + max_timeout_seconds=3600, + extra={"name": "USDT", "version": "1"}, + ) + + +def _payload(req: PaymentRequirements) -> PaymentPayload: + now = int(time.time()) + raw = { + "authorization": { + "from": "0x" + "33" * 20, + "to": req.pay_to, + "value": req.amount, + "validAfter": str(now - 600), + "validBefore": str(now + 3600), + "nonce": "0x" + "11" * 32, + }, + "signature": "0x" + "aa" * 65, + } + return PaymentPayload(x402_version=2, payload=raw, accepted=req) + + +def test_get_extra_returns_facilitator_address(): + scheme = ExactTronFacilitatorScheme(DummySigner()) + extra = scheme.get_extra("tron:nile") + assert extra == {"permit2FacilitatorAddress": scheme.get_signers("tron:nile")[0]} + + +def test_verify_eip3009_path(): + scheme = ExactTronFacilitatorScheme(DummySigner()) + req = _requirements() + payload = _payload(req) + result = scheme.verify(payload, req) + assert result.is_valid is True + + +def test_settle_eip3009_path(): + scheme = ExactTronFacilitatorScheme(DummySigner()) + req = _requirements() + payload = _payload(req) + result = scheme.settle(payload, req) + assert result.success is True diff --git a/python/x402/tests/unit/mechanisms/tron/test_permit2.py b/python/x402/tests/unit/mechanisms/tron/test_permit2.py new file mode 100644 index 00000000..c8b00178 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tron/test_permit2.py @@ -0,0 +1,333 @@ +"""Tests for TRON Permit2 facilitator logic.""" + +import time + +from bankofai.x402.extensions.trc20_approval_gas_sponsoring import ( + TRC20_APPROVAL_GAS_SPONSORING, + create_trc20_approval_gas_sponsoring_extension, +) +from bankofai.x402.interfaces import FacilitatorContext +from bankofai.x402.mechanisms.tron.constants import ERR_PERMIT2_ALLOWANCE_REQUIRED +from bankofai.x402.mechanisms.tron.exact.permit2 import settle_permit2, verify_permit2 +from bankofai.x402.mechanisms.tron.types import ExactPermit2Payload +from bankofai.x402.schemas import PaymentPayload, PaymentRequirements + + +class DummySigner: + def __init__(self, allowance: int, balance: int = 10**12, valid_signature: bool = True): + self._allowance = allowance + self._balance = balance + self._valid_signature = valid_signature + self._address = "0x" + "99" * 20 + + def get_addresses(self): + return [self._address] + + def verify_typed_data(self, *args, **kwargs): + return self._valid_signature + + def read_contract(self, address: str, function_name: str, args=None): + if function_name == "allowance": + return self._allowance + if function_name == "balanceOf": + return self._balance + return 0 + + def write_contract_with_abi(self, *args, **kwargs): + return "0x" + "00" * 32 + + def wait_for_transaction_receipt(self, tx_hash: str): + class Receipt: + status = "success" + + return Receipt() + + def get_sign_weight(self, transaction): + return {"result": {"result": True}} + + +class Base58ArgsSigner(DummySigner): + def read_contract(self, address: str, function_name: str, args=None): + args = args or [] + if function_name in {"allowance", "balanceOf"}: + for arg in args: + if isinstance(arg, str) and not (arg.startswith("T") and len(arg) == 34): + raise ValueError("EncodingTypeError") + return super().read_contract(address, function_name, args) + + +class FailingApprovalSigner: + def send_raw_transaction(self, signed_transaction): + raise AssertionError( + "send_raw_transaction should not be called when allowance is sufficient" + ) + + def wait_for_transaction_receipt(self, tx_hash: str): + raise AssertionError( + "wait_for_transaction_receipt should not be called when allowance is sufficient" + ) + + +class Base58WriteSigner(Base58ArgsSigner): + def write_contract_with_abi(self, address, function_name, args, abi, fee_limit=1_000_000_000): + permit_tuple = args[0] + payer = args[1] + witness = args[2] + + token = permit_tuple[0][0] + assert isinstance(token, str) and token.startswith("T") and len(token) == 34 + assert isinstance(payer, str) and payer.startswith("T") and len(payer) == 34 + assert isinstance(witness[0], str) and witness[0].startswith("T") and len(witness[0]) == 34 + assert isinstance(witness[1], str) and witness[1].startswith("T") and len(witness[1]) == 34 + return "0x" + "00" * 32 + + +def _requirements() -> PaymentRequirements: + return PaymentRequirements( + scheme="exact", + network="tron:nile", + asset="0x" + "11" * 20, + amount="1000", + pay_to="0x" + "22" * 20, + max_timeout_seconds=3600, + ) + + +def _payload_dict(req: PaymentRequirements, signer_address: str) -> dict: + now = int(time.time()) + return { + "signature": "0x" + "11" * 65, + "permit2Authorization": { + "from": "0x" + "33" * 20, + "permitted": {"token": req.asset, "amount": req.amount}, + "spender": "0x" + "55" * 20, + "nonce": "1", + "deadline": str(now + 3600), + "witness": { + "to": req.pay_to, + "facilitator": signer_address, + "validAfter": str(now - 600), + }, + }, + } + + +def test_verify_permit2_valid(monkeypatch): + import bankofai.x402.mechanisms.tron.exact.permit2 as tron_permit2 + + monkeypatch.setitem(tron_permit2.PERMIT2_ADDRESSES, "tron:nile", "0x" + "44" * 20) + monkeypatch.setitem(tron_permit2.X402_PERMIT2_PROXY_ADDRESSES, "tron:nile", "0x" + "55" * 20) + + req = _requirements() + signer = DummySigner(allowance=10**9) + payload_dict = _payload_dict(req, signer.get_addresses()[0]) + payload = PaymentPayload(x402_version=2, payload=payload_dict, accepted=req) + + result = verify_permit2( + signer, + payload, + req, + ExactPermit2Payload.from_dict(payload_dict), + ) + assert result.is_valid is True + + +def test_verify_permit2_allowance_required(monkeypatch): + import bankofai.x402.mechanisms.tron.exact.permit2 as tron_permit2 + + monkeypatch.setitem(tron_permit2.PERMIT2_ADDRESSES, "tron:nile", "0x" + "44" * 20) + monkeypatch.setitem(tron_permit2.X402_PERMIT2_PROXY_ADDRESSES, "tron:nile", "0x" + "55" * 20) + + req = _requirements() + signer = DummySigner(allowance=0) + payload_dict = _payload_dict(req, signer.get_addresses()[0]) + payload = PaymentPayload(x402_version=2, payload=payload_dict, accepted=req) + + result = verify_permit2( + signer, + payload, + req, + ExactPermit2Payload.from_dict(payload_dict), + ) + assert result.is_valid is False + assert result.invalid_reason == ERR_PERMIT2_ALLOWANCE_REQUIRED + + +def test_settle_permit2_success(monkeypatch): + import bankofai.x402.mechanisms.tron.exact.permit2 as tron_permit2 + + monkeypatch.setitem(tron_permit2.PERMIT2_ADDRESSES, "tron:nile", "0x" + "44" * 20) + monkeypatch.setitem(tron_permit2.X402_PERMIT2_PROXY_ADDRESSES, "tron:nile", "0x" + "55" * 20) + + req = _requirements() + signer = DummySigner(allowance=10**9) + payload_dict = _payload_dict(req, signer.get_addresses()[0]) + payload = PaymentPayload(x402_version=2, payload=payload_dict, accepted=req) + + result = settle_permit2( + signer, + payload, + req, + ExactPermit2Payload.from_dict(payload_dict), + ) + assert result.success is True + + +def test_verify_permit2_allows_trc20_extension(monkeypatch): + import bankofai.x402.mechanisms.tron.exact.permit2 as tron_permit2 + + monkeypatch.setitem(tron_permit2.PERMIT2_ADDRESSES, "tron:nile", "0x" + "44" * 20) + monkeypatch.setitem(tron_permit2.X402_PERMIT2_PROXY_ADDRESSES, "tron:nile", "0x" + "55" * 20) + + req = _requirements() + signer = DummySigner(allowance=0) + payload_dict = _payload_dict(req, signer.get_addresses()[0]) + + spender_word = ("0x" + "44" * 20).removeprefix("0x").rjust(64, "0") + amount_word = hex((1 << 256) - 1).removeprefix("0x").rjust(64, "0") + approval_data = "095ea7b3" + spender_word + amount_word + + payload = PaymentPayload( + x402_version=2, + payload=payload_dict, + accepted=req, + extensions={ + TRC20_APPROVAL_GAS_SPONSORING.key: { + "info": { + "from": payload_dict["permit2Authorization"]["from"], + "asset": req.asset, + "spender": "0x" + "44" * 20, + "amount": str((1 << 256) - 1), + "signedTransaction": { + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "owner_address": payload_dict["permit2Authorization"][ + "from" + ], + "contract_address": req.asset, + "data": approval_data, + } + } + } + ] + }, + "signature": ["0x01"], + }, + "version": "1", + } + } + }, + ) + + context = FacilitatorContext( + {TRC20_APPROVAL_GAS_SPONSORING.key: create_trc20_approval_gas_sponsoring_extension(signer)} + ) + + result = verify_permit2( + signer, + payload, + req, + ExactPermit2Payload.from_dict(payload_dict), + context, + ) + assert result.is_valid is True + + +def test_verify_permit2_ignores_server_declared_extension_when_allowance_sufficient(monkeypatch): + import bankofai.x402.mechanisms.tron.exact.permit2 as tron_permit2 + + monkeypatch.setitem(tron_permit2.PERMIT2_ADDRESSES, "tron:nile", "0x" + "44" * 20) + monkeypatch.setitem(tron_permit2.X402_PERMIT2_PROXY_ADDRESSES, "tron:nile", "0x" + "55" * 20) + + req = _requirements() + signer = Base58ArgsSigner(allowance=10**9) + payload_dict = _payload_dict(req, signer.get_addresses()[0]) + payload = PaymentPayload( + x402_version=2, + payload=payload_dict, + accepted=req, + extensions={ + TRC20_APPROVAL_GAS_SPONSORING.key: { + "info": {"description": "TRC-20 approval gas sponsoring (Permit2)", "version": "1"}, + "schema": {}, + } + }, + ) + + result = verify_permit2( + signer, + payload, + req, + ExactPermit2Payload.from_dict(payload_dict), + ) + assert result.is_valid is True + + +def test_settle_permit2_skips_trc20_approval_when_allowance_sufficient(monkeypatch): + import bankofai.x402.mechanisms.tron.exact.permit2 as tron_permit2 + + monkeypatch.setitem(tron_permit2.PERMIT2_ADDRESSES, "tron:nile", "0x" + "44" * 20) + monkeypatch.setitem(tron_permit2.X402_PERMIT2_PROXY_ADDRESSES, "tron:nile", "0x" + "55" * 20) + + req = _requirements() + signer = Base58ArgsSigner(allowance=10**9) + payload_dict = _payload_dict(req, signer.get_addresses()[0]) + + payload = PaymentPayload( + x402_version=2, + payload=payload_dict, + accepted=req, + extensions={ + TRC20_APPROVAL_GAS_SPONSORING.key: { + "info": { + "from": payload_dict["permit2Authorization"]["from"], + "asset": req.asset, + "spender": "0x" + "44" * 20, + "amount": str((1 << 256) - 1), + "signedTransaction": {"raw_data": {"contract": []}, "signature": ["0x01"]}, + "version": "1", + }, + "schema": {}, + } + }, + ) + + context = FacilitatorContext( + { + TRC20_APPROVAL_GAS_SPONSORING.key: create_trc20_approval_gas_sponsoring_extension( + FailingApprovalSigner() + ) + } + ) + + result = settle_permit2( + signer, + payload, + req, + ExactPermit2Payload.from_dict(payload_dict), + context, + ) + assert result.success is True + + +def test_settle_permit2_uses_base58_addresses_in_contract_call(monkeypatch): + import bankofai.x402.mechanisms.tron.exact.permit2 as tron_permit2 + + monkeypatch.setitem(tron_permit2.PERMIT2_ADDRESSES, "tron:nile", "0x" + "44" * 20) + monkeypatch.setitem(tron_permit2.X402_PERMIT2_PROXY_ADDRESSES, "tron:nile", "0x" + "55" * 20) + + req = _requirements() + signer = Base58WriteSigner(allowance=10**9) + payload_dict = _payload_dict(req, signer.get_addresses()[0]) + payload = PaymentPayload(x402_version=2, payload=payload_dict, accepted=req) + + result = settle_permit2( + signer, + payload, + req, + ExactPermit2Payload.from_dict(payload_dict), + ) + assert result.success is True diff --git a/python/x402/tests/unit/mechanisms/tron/test_server.py b/python/x402/tests/unit/mechanisms/tron/test_server.py new file mode 100644 index 00000000..52e9409b --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tron/test_server.py @@ -0,0 +1,52 @@ +"""Tests for TRON Exact server scheme.""" + +import pytest + +from bankofai.x402.mechanisms.tron import get_network_config +from bankofai.x402.mechanisms.tron.exact import ExactTronServerScheme +from bankofai.x402.schemas import AssetAmount, PaymentRequirements, SupportedKind + + +def test_parse_price_default_asset(): + server = ExactTronServerScheme() + network = "tron:nile" + result = server.parse_price("$0.10", network) + assert result.amount == "100000" + assert result.asset == get_network_config(network)["default_asset"]["address"] + assert result.extra == {"name": "Tether USD", "version": "1"} + + +def test_parse_price_asset_amount(): + server = ExactTronServerScheme() + network = "tron:nile" + asset_amount = AssetAmount(amount="123", asset="0x" + "11" * 20, extra={"foo": "bar"}) + result = server.parse_price(asset_amount, network) + assert result.amount == "123" + assert result.asset == asset_amount.asset + assert result.extra == {"foo": "bar"} + + +def test_parse_price_missing_asset_raises(): + server = ExactTronServerScheme() + with pytest.raises(ValueError, match="Asset address required"): + server.parse_price({"amount": "123"}, "tron:nile") + + +def test_enhance_payment_requirements_adds_domain(): + server = ExactTronServerScheme() + network = "tron:nile" + requirements = PaymentRequirements( + scheme="exact", + network=network, + asset="", + amount="1000", + pay_to="0x" + "22" * 20, + max_timeout_seconds=3600, + extra={}, + ) + supported_kind = SupportedKind(x402_version=2, scheme="exact", network=network, extra={}) + result = server.enhance_payment_requirements(requirements, supported_kind, []) + assert result.asset == get_network_config(network)["default_asset"]["address"] + assert result.extra is not None + assert result.extra["name"] == "Tether USD" + assert result.extra["version"] == "1" diff --git a/python/x402/tests/unit/mechanisms/tron/test_types.py b/python/x402/tests/unit/mechanisms/tron/test_types.py new file mode 100644 index 00000000..3fe9cec2 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tron/test_types.py @@ -0,0 +1,61 @@ +"""Tests for TRON payload types.""" + +from bankofai.x402.mechanisms.tron import ( + ExactEIP3009Authorization, + ExactEIP3009Payload, + ExactPermit2Payload, + ExactTronPayloadV1, + Permit2Authorization, + Permit2Witness, + is_permit2_payload, +) + + +class TestExactEIP3009Payload: + def test_round_trip(self): + auth = ExactEIP3009Authorization( + from_address="0x" + "11" * 20, + to="0x" + "22" * 20, + value="1000", + valid_after="1", + valid_before="2", + nonce="0x" + "aa" * 32, + ) + payload = ExactEIP3009Payload(authorization=auth, signature="0x" + "bb" * 65) + restored = ExactEIP3009Payload.from_dict(payload.to_dict()) + assert restored.authorization.from_address == auth.from_address + assert restored.authorization.to == auth.to + assert restored.authorization.value == auth.value + assert restored.signature == payload.signature + + +class TestExactPermit2Payload: + def test_round_trip(self): + auth = Permit2Authorization( + from_address="0x" + "11" * 20, + permitted_token="0x" + "22" * 20, + permitted_amount="1000", + spender="0x" + "33" * 20, + nonce="1", + deadline="2", + witness=Permit2Witness( + to="0x" + "44" * 20, + facilitator="0x" + "55" * 20, + valid_after="3", + ), + ) + payload = ExactPermit2Payload(permit2_authorization=auth, signature="0x" + "cc" * 65) + restored = ExactPermit2Payload.from_dict(payload.to_dict()) + assert restored.permit2_authorization.from_address == auth.from_address + assert restored.permit2_authorization.permitted_token == auth.permitted_token + assert restored.permit2_authorization.witness.facilitator == auth.witness.facilitator + assert restored.signature == payload.signature + + +class TestHelpers: + def test_v1_alias(self): + assert ExactTronPayloadV1 is ExactEIP3009Payload + + def test_is_permit2_payload(self): + assert is_permit2_payload({"permit2Authorization": {}}) is True + assert is_permit2_payload({"authorization": {}}) is False diff --git a/python/x402/tests/unit/mechanisms/tron/test_utils.py b/python/x402/tests/unit/mechanisms/tron/test_utils.py new file mode 100644 index 00000000..054c8cc3 --- /dev/null +++ b/python/x402/tests/unit/mechanisms/tron/test_utils.py @@ -0,0 +1,58 @@ +"""Tests for TRON utilities.""" + +import pytest + +from bankofai.x402.mechanisms.tron.constants import TRON_CHAIN_IDS, TRON_NETWORK_CONFIGS +from bankofai.x402.mechanisms.tron.utils import ( + create_nonce, + get_asset_info, + get_network_config, + get_tron_chain_id, + normalize_address_for_signing, +) + + +def test_get_tron_chain_id(): + assert get_tron_chain_id("tron:nile") == TRON_CHAIN_IDS["tron:nile"] + + +def test_get_tron_chain_id_invalid(): + with pytest.raises(ValueError): + get_tron_chain_id("eip155:1") + + +def test_get_network_config(): + cfg = get_network_config("tron:mainnet") + assert cfg["chain_id"] == TRON_NETWORK_CONFIGS["tron:mainnet"]["chain_id"] + + +def test_get_network_config_unknown(): + with pytest.raises(ValueError): + get_network_config("tron:unknown") + + +def test_get_asset_info_default(): + default = TRON_NETWORK_CONFIGS["tron:nile"]["default_asset"] + asset = get_asset_info("tron:nile", default["address"]) + assert asset["address"] == default["address"] + + +def test_normalize_address_for_signing_accepts_hex(): + addr = "0x" + "AA" * 20 + assert normalize_address_for_signing(addr) == addr.lower() + + +def test_normalize_address_for_signing_accepts_41_prefix(): + addr = "41" + "11" * 20 + assert normalize_address_for_signing(addr) == "0x" + "11" * 20 + + +def test_normalize_address_for_signing_invalid(): + with pytest.raises(ValueError): + normalize_address_for_signing("not-an-address") + + +def test_create_nonce_format(): + nonce = create_nonce() + assert nonce.startswith("0x") + assert len(nonce) == 66 diff --git a/python/x402/uv.lock b/python/x402/uv.lock index e01011de..7de48411 100644 --- a/python/x402/uv.lock +++ b/python/x402/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 1 requires-python = ">=3.10" resolution-markers = [ "python_full_version == '3.14.*'", @@ -347,11 +346,10 @@ requires-dist = [ { 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 = "tronpy", marker = "extra == 'tron'", specifier = ">=0.4.0" }, + { name = "tronpy", marker = "extra == 'tron'", specifier = ">=0.6.0,<0.7.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "web3", marker = "extra == 'evm'", specifier = ">=7.0.0" }, ] -provides-extras = ["httpx", "requests", "flask", "fastapi", "tron", "evm", "svm", "mcp", "extensions", "clients", "servers", "mechanisms", "all"] [package.metadata.requires-dev] dev = [ diff --git a/typescript/packages/mcp/package.json b/typescript/packages/mcp/package.json index 8e0a12f1..b9b78426 100644 --- a/typescript/packages/mcp/package.json +++ b/typescript/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-mcp", - "version": "2.6.0-beta.9", + "version": "2.6.0-beta.10", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mcp/src/command/mcp-server.ts b/typescript/packages/mcp/src/command/mcp-server.ts index aa36dd95..8f07a205 100644 --- a/typescript/packages/mcp/src/command/mcp-server.ts +++ b/typescript/packages/mcp/src/command/mcp-server.ts @@ -58,18 +58,55 @@ async function main(): Promise { version, }); + const balanceArgsSchema = z + .object({ + network: z.string().optional(), + asset: z.string().optional(), + token: z.string().optional(), + pair: z.string().optional(), + }) + .strict(); + + const payArgsSchema = z + .object({ + url: z.string().url(), + method: z.string().optional(), + data: z.string().optional(), + query: z.string().optional(), + headers: z.string().optional(), + network: z.string().optional(), + asset: z.string().optional(), + token: z.string().optional(), + pair: z.string().optional(), + max_amount: z.string().optional(), + correlation_id: z.string().optional(), + }) + .strict(); + + const approveArgsSchema = z + .object({ + url: z.string().url(), + method: z.string().optional(), + data: z.string().optional(), + query: z.string().optional(), + headers: z.string().optional(), + network: z.string().optional(), + asset: z.string().optional(), + token: z.string().optional(), + pair: z.string().optional(), + max_amount: z.string().optional(), + }) + .strict(); + server.tool("x402_status", "Show configured x402 wallet status.", {}, async () => { return toTextResult(runCli(["status"])); }); - server.tool( + server.registerTool( "x402_balance", - "Show configured x402 wallet balances.", { - network: z.string().optional(), - asset: z.string().optional(), - token: z.string().optional(), - pair: z.string().optional(), + description: "Show configured x402 wallet balances.", + inputSchema: balanceArgsSchema, }, async args => { const commandArgs = ["balance"]; @@ -81,21 +118,11 @@ async function main(): Promise { }, ); - server.tool( + server.registerTool( "x402_pay", - "Call an x402-protected URL and automatically complete payment.", { - url: z.string().url(), - method: z.string().optional(), - data: z.string().optional(), - query: z.string().optional(), - headers: z.string().optional(), - network: z.string().optional(), - asset: z.string().optional(), - token: z.string().optional(), - pair: z.string().optional(), - max_amount: z.string().optional(), - correlation_id: z.string().optional(), + description: "Call an x402-protected URL and automatically complete payment.", + inputSchema: payArgsSchema, }, async args => { const commandArgs = ["pay", args.url]; @@ -114,20 +141,11 @@ async function main(): Promise { }, ); - server.tool( + server.registerTool( "x402_approve", - "Approve Permit2 allowance for the selected x402 payment option.", { - url: z.string().url(), - method: z.string().optional(), - data: z.string().optional(), - query: z.string().optional(), - headers: z.string().optional(), - network: z.string().optional(), - asset: z.string().optional(), - token: z.string().optional(), - pair: z.string().optional(), - max_amount: z.string().optional(), + description: "Approve Permit2 allowance for the selected x402 payment option.", + inputSchema: approveArgsSchema, }, async args => { const commandArgs = ["approve", args.url]; diff --git a/typescript/packages/mcp/src/command/runtime.ts b/typescript/packages/mcp/src/command/runtime.ts index cecc0890..cb66663d 100644 --- a/typescript/packages/mcp/src/command/runtime.ts +++ b/typescript/packages/mcp/src/command/runtime.ts @@ -94,6 +94,25 @@ const DEFAULT_PAYMENT_ASSETS: Partial< }, }; +const NETWORK_ALIASES: Record = { + mainnet: "tron:mainnet", + nile: "tron:nile", + shasta: "tron:shasta", + "tron:mainnet": "tron:mainnet", + "tron:nile": "tron:nile", + "tron:shasta": "tron:shasta", + tron_mainnet: "tron:mainnet", + tron_nile: "tron:nile", + tron_shasta: "tron:shasta", + bsc: "eip155:56", + "bsc-mainnet": "eip155:56", + bsc_mainnet: "eip155:56", + "eip155:56": "eip155:56", + "bsc-testnet": "eip155:97", + bsc_testnet: "eip155:97", + "eip155:97": "eip155:97", +}; + export type ParsedCliOptions = Record; function readJsonFile(file: string): Record | undefined { @@ -293,22 +312,16 @@ function resolvePreferredNetwork(network?: string): string | undefined { return undefined; } - if (network.startsWith("tron:") || network.startsWith("eip155:")) { - return network; - } + return NETWORK_ALIASES[network.trim().toLowerCase()]; +} - switch (network) { - case "mainnet": - case "nile": - case "shasta": - return `tron:${network}`; - case "bsc": - return "eip155:56"; - case "bsc-testnet": - return "eip155:97"; - default: - return undefined; +function requireSupportedNetwork(network: string, optionName = "--network"): string { + const resolved = resolvePreferredNetwork(network); + if (!resolved) { + throw new Error(`Unsupported network for ${optionName}: ${network}`); } + + return resolved; } function normalizeSelectorValue(value?: string): string | undefined { @@ -327,23 +340,23 @@ function parsePairSelector(pair?: string): { network?: string; asset?: string } if (pair.includes("/")) { const [network, asset] = pair.split("/", 2); return { - network: resolvePreferredNetwork(network) ?? network, + network: requireSupportedNetwork(network, "--pair"), asset, }; } const parts = pair.split(":"); if (parts.length >= 3) { + const network = parts.slice(0, -1).join(":"); return { - network: - resolvePreferredNetwork(parts.slice(0, -1).join(":")) ?? parts.slice(0, -1).join(":"), + network: requireSupportedNetwork(network, "--pair"), asset: parts.at(-1), }; } if (parts.length === 2) { return { - network: resolvePreferredNetwork(parts[0]) ?? parts[0], + network: requireSupportedNetwork(parts[0], "--pair"), asset: parts[1], }; } @@ -695,8 +708,59 @@ export async function runStatus(): Promise { process.stdout.write(JSON.stringify(result, null, 2) + "\n"); } -function getPreferredAsset(options: CliBalanceOptions): string | undefined { - return parsePairSelector(options.pair).asset ?? options.asset ?? options.token; +function isHexAddress(value: string): value is `0x${string}` { + return /^0x[a-fA-F0-9]{40}$/.test(value); +} + +function isTronAddress(value: string): boolean { + return TronWeb.isAddress(value); +} + +function resolvePreferredAssetInfo(args: { + network: SupportedNetwork; + asset?: string; + token?: string; + pairAsset?: string; +}): { asset: string; symbol?: string } | undefined { + const explicitToken = args.token?.trim(); + const explicitAsset = args.asset?.trim(); + const explicitPairAsset = args.pairAsset?.trim(); + const explicitSelector = explicitPairAsset ?? explicitAsset ?? explicitToken; + const defaultAsset = getDefaultPaymentAsset(args.network); + + if (!explicitSelector) { + return defaultAsset; + } + + if (args.network === "mainnet" || args.network === "nile" || args.network === "shasta") { + if ( + defaultAsset?.symbol && + normalizeSelectorValue(explicitSelector) === normalizeSelectorValue(defaultAsset.symbol) + ) { + return defaultAsset; + } + + if (!isTronAddress(explicitSelector)) { + throw new Error(`Invalid token address format for tron:${args.network}: ${explicitSelector}`); + } + + return { asset: explicitSelector }; + } + + if ( + defaultAsset?.symbol && + normalizeSelectorValue(explicitSelector) === normalizeSelectorValue(defaultAsset.symbol) + ) { + return defaultAsset; + } + + if (!isHexAddress(explicitSelector)) { + throw new Error( + `Invalid token address format for ${EVM_NETWORKS[args.network].chainId}: ${explicitSelector}`, + ); + } + + return { asset: explicitSelector }; } function getDefaultPaymentAsset( @@ -980,10 +1044,40 @@ export async function runBalance(options: CliBalanceOptions = {}): Promise const { tronKey, evmKey, tronGridApiKey } = await resolveKeys(); const result: Record = {}; const pairSelector = parsePairSelector(options.pair); - const preferredNetwork = resolvePreferredNetwork(options.network) ?? pairSelector.network; - const preferredAsset = getPreferredAsset(options); + const preferredNetwork = + options.network !== undefined ? requireSupportedNetwork(options.network) : pairSelector.network; + const includeTron = !preferredNetwork || preferredNetwork.startsWith("tron:"); + const includeEvm = !preferredNetwork || preferredNetwork.startsWith("eip155:"); + + if (preferredNetwork?.startsWith("tron:")) { + resolvePreferredAssetInfo({ + network: preferredNetwork.slice("tron:".length) as keyof typeof TRON_RPC_URLS, + asset: options.asset, + token: options.token, + pairAsset: pairSelector.asset, + }); + } - if (tronKey) { + if (preferredNetwork?.startsWith("eip155:")) { + const evmNetwork = + preferredNetwork === "eip155:56" + ? "bsc" + : preferredNetwork === "eip155:97" + ? "bsc-testnet" + : undefined; + if (!evmNetwork) { + throw new Error(`Unsupported network for --network: ${preferredNetwork}`); + } + + resolvePreferredAssetInfo({ + network: evmNetwork, + asset: options.asset, + token: options.token, + pairAsset: pairSelector.asset, + }); + } + + if (tronKey && includeTron) { const tronWeb = buildTronWeb(TRON_RPC_URLS.nile, tronKey, tronGridApiKey); const signer = createClientTronSigner(tronWeb, tronKey); const trxSun = await tronWeb.trx.getBalance(signer.address); @@ -991,10 +1085,12 @@ export async function runBalance(options: CliBalanceOptions = {}): Promise preferredNetwork && preferredNetwork.startsWith("tron:") ? (preferredNetwork.slice("tron:".length) as keyof typeof TRON_RPC_URLS) : "nile"; - const tokenInfo = - preferredAsset && (!preferredNetwork || preferredNetwork.startsWith("tron:")) - ? { asset: preferredAsset } - : getDefaultPaymentAsset(tronNetwork); + const tokenInfo = resolvePreferredAssetInfo({ + network: tronNetwork, + asset: options.asset, + token: options.token, + pairAsset: pairSelector.asset, + }); result.tron = { address: signer.address, network: `tron:${tronNetwork}`, @@ -1016,7 +1112,7 @@ export async function runBalance(options: CliBalanceOptions = {}): Promise } } - if (evmKey) { + if (evmKey && includeEvm) { const account = privateKeyToAccount(normalizeHexPrivateKey(evmKey)); const balances: Record = {}; @@ -1037,9 +1133,12 @@ export async function runBalance(options: CliBalanceOptions = {}): Promise nativeBalance: balance.toString(), }; - const tokenInfo = preferredAsset - ? { asset: preferredAsset } - : getDefaultPaymentAsset(networkName as keyof typeof EVM_NETWORKS); + const tokenInfo = resolvePreferredAssetInfo({ + network: networkName as keyof typeof EVM_NETWORKS, + asset: options.asset, + token: options.token, + pairAsset: pairSelector.asset, + }); if (tokenInfo?.asset) { entry.token = { diff --git a/typescript/packages/mechanisms/evm/src/constants.ts b/typescript/packages/mechanisms/evm/src/constants.ts index 98a27b1a..360fd98d 100644 --- a/typescript/packages/mechanisms/evm/src/constants.ts +++ b/typescript/packages/mechanisms/evm/src/constants.ts @@ -29,6 +29,7 @@ export const permit2WitnessTypes = { ], Witness: [ { name: "to", type: "address" }, + { name: "facilitator", type: "address" }, { name: "validAfter", type: "uint256" }, ], } as const; @@ -147,25 +148,83 @@ export const DEFAULT_MAX_FEE_PER_GAS = 1_000_000_000n; export const DEFAULT_MAX_PRIORITY_FEE_PER_GAS = 100_000_000n; /** - * Canonical Permit2 contract address. - * Same address on all EVM chains via CREATE2 deployment. + * Canonical Uniswap Permit2 contract address. + * Used as the default on EVM chains that do not override Permit2 deployment. * * @see https://github.com/Uniswap/permit2 */ export const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3" as const; /** - * x402ExactPermit2Proxy contract address. - * Current deployed address on BSC mainnet and BSC testnet. + * Chain-specific Permit2 deployments. + * BSC uses PancakeSwap's Permit2 deployment instead of the canonical Uniswap address. + */ +export const PERMIT2_ADDRESSES: Record = { + "eip155:56": "0x31c2F6fcFf4F8759b3Bd5Bf0e1084A055615c768", + "eip155:97": "0x31c2F6fcFf4F8759b3Bd5Bf0e1084A055615c768", +}; + +/** + * Default x402ExactPermit2Proxy contract address. + * Preserved for backwards compatibility in exports and tests. */ export const x402ExactPermit2ProxyAddress = "0xEe38Ec718255fe78e9D16aCC0e1183C731679b23" as const; /** - * x402UptoPermit2Proxy contract address. - * Current deployed address on BSC mainnet and BSC testnet. + * Chain-specific x402ExactPermit2Proxy deployments. + */ +export const X402_EXACT_PERMIT2_PROXY_ADDRESSES: Record = { + "eip155:56": x402ExactPermit2ProxyAddress, + "eip155:97": x402ExactPermit2ProxyAddress, +}; + +/** + * Default x402UptoPermit2Proxy contract address. + * Preserved for backwards compatibility in exports and tests. */ export const x402UptoPermit2ProxyAddress = "0x2b30Ed9F37c7C21ae8779c5753B1cCf264DfD63C" as const; +/** + * Chain-specific x402UptoPermit2Proxy deployments. + */ +export const X402_UPTO_PERMIT2_PROXY_ADDRESSES: Record = { + "eip155:56": x402UptoPermit2ProxyAddress, + "eip155:97": x402UptoPermit2ProxyAddress, +}; + +/** + * Resolve the Permit2 contract address for an EVM network. + * Falls back to the canonical Uniswap deployment when a chain-specific override is not configured. + * + * @param network - CAIP-2 EVM network identifier. + * @returns The Permit2 contract address for the requested network. + */ +export function getPermit2Address(network: string): `0x${string}` { + return PERMIT2_ADDRESSES[network] ?? PERMIT2_ADDRESS; +} + +/** + * Resolve the x402 exact Permit2 proxy address for an EVM network. + * Falls back to the default exported address when a chain-specific override is not configured. + * + * @param network - CAIP-2 EVM network identifier. + * @returns The x402 exact Permit2 proxy contract address for the requested network. + */ +export function getX402ExactPermit2ProxyAddress(network: string): `0x${string}` { + return X402_EXACT_PERMIT2_PROXY_ADDRESSES[network] ?? x402ExactPermit2ProxyAddress; +} + +/** + * Resolve the x402 upto Permit2 proxy address for an EVM network. + * Falls back to the default exported address when a chain-specific override is not configured. + * + * @param network - CAIP-2 EVM network identifier. + * @returns The x402 upto Permit2 proxy contract address for the requested network. + */ +export function getX402UptoPermit2ProxyAddress(network: string): `0x${string}` { + return X402_UPTO_PERMIT2_PROXY_ADDRESSES[network] ?? x402UptoPermit2ProxyAddress; +} + /** * Shared ABI components for the Permit2 witness tuple. * Used in both x402ExactPermit2ProxyABI and x402UptoPermit2ProxyABI to keep them in sync. @@ -173,6 +232,7 @@ export const x402UptoPermit2ProxyAddress = "0x2b30Ed9F37c7C21ae8779c5753B1cCf264 */ const permit2WitnessABIComponents = [ { name: "to", type: "address", internalType: "address" }, + { name: "facilitator", type: "address", internalType: "address" }, { name: "validAfter", type: "uint256", internalType: "uint256" }, ] as const; diff --git a/typescript/packages/mechanisms/evm/src/exact/client/eip2612.ts b/typescript/packages/mechanisms/evm/src/exact/client/eip2612.ts index 7c305cd7..3914a195 100644 --- a/typescript/packages/mechanisms/evm/src/exact/client/eip2612.ts +++ b/typescript/packages/mechanisms/evm/src/exact/client/eip2612.ts @@ -1,6 +1,6 @@ import { getAddress } from "viem"; import type { Eip2612GasSponsoringInfo } from "@bankofai/x402-extensions"; -import { eip2612PermitTypes, eip2612NoncesAbi, PERMIT2_ADDRESS } from "../../constants"; +import { eip2612PermitTypes, eip2612NoncesAbi, getPermit2Address } from "../../constants"; import { ClientEvmSigner } from "../../signer"; /** @@ -16,6 +16,7 @@ import { ClientEvmSigner } from "../../signer"; * @param tokenAddress - The ERC-20 token contract address * @param tokenName - The token name (from paymentRequirements.extra.name) * @param tokenVersion - The token version (from paymentRequirements.extra.version) + * @param network - The target EVM network used to resolve Permit2 * @param chainId - The chain ID * @param deadline - The deadline for the permit (unix timestamp as string) * @param permittedAmount - The Permit2 permitted amount (must match exactly) @@ -26,12 +27,13 @@ export async function signEip2612Permit( tokenAddress: `0x${string}`, tokenName: string, tokenVersion: string, + network: string, chainId: number, deadline: string, permittedAmount: string, ): Promise { const owner = signer.address; - const spender = getAddress(PERMIT2_ADDRESS); + const spender = getAddress(getPermit2Address(network)); // Query the current EIP-2612 nonce from the token contract const nonce = (await signer.readContract({ diff --git a/typescript/packages/mechanisms/evm/src/exact/client/erc20approval.ts b/typescript/packages/mechanisms/evm/src/exact/client/erc20approval.ts index a8044930..be72784a 100644 --- a/typescript/packages/mechanisms/evm/src/exact/client/erc20approval.ts +++ b/typescript/packages/mechanisms/evm/src/exact/client/erc20approval.ts @@ -4,7 +4,7 @@ import { type Erc20ApprovalGasSponsoringInfo, } from "@bankofai/x402-extensions"; import { - PERMIT2_ADDRESS, + getPermit2Address, erc20ApproveAbi, ERC20_APPROVE_GAS_LIMIT, DEFAULT_MAX_FEE_PER_GAS, @@ -23,16 +23,18 @@ import { ClientEvmSigner } from "../../signer"; * * @param signer - The client EVM signer (must support signTransaction, getTransactionCount) * @param tokenAddress - The ERC-20 token contract address + * @param network - The target EVM network used to resolve Permit2 * @param chainId - The chain ID * @returns The ERC-20 approval gas sponsoring info object */ export async function signErc20ApprovalTransaction( signer: ClientEvmSigner, tokenAddress: `0x${string}`, + network: string, chainId: number, ): Promise { const from = signer.address; - const spender = getAddress(PERMIT2_ADDRESS); + const spender = getAddress(getPermit2Address(network)); // Encode approve(PERMIT2_ADDRESS, MaxUint256) calldata const data = encodeFunctionData({ diff --git a/typescript/packages/mechanisms/evm/src/exact/client/permit2.ts b/typescript/packages/mechanisms/evm/src/exact/client/permit2.ts index 1c71988a..fe08d5a9 100644 --- a/typescript/packages/mechanisms/evm/src/exact/client/permit2.ts +++ b/typescript/packages/mechanisms/evm/src/exact/client/permit2.ts @@ -2,8 +2,8 @@ import { PaymentRequirements, PaymentPayloadResult } from "@bankofai/x402-core/t import { encodeFunctionData, getAddress } from "viem"; import { permit2WitnessTypes, - PERMIT2_ADDRESS, - x402ExactPermit2ProxyAddress, + getPermit2Address, + getX402ExactPermit2ProxyAddress, erc20ApproveAbi, erc20AllowanceAbi, } from "../../constants"; @@ -37,17 +37,24 @@ export async function createPermit2Payload( // Upper time bound is enforced by Permit2's deadline field const deadline = (now + paymentRequirements.maxTimeoutSeconds).toString(); + const facilitator = + paymentRequirements.extra?.permit2FacilitatorAddress as `0x${string}` | undefined; + if (!facilitator) { + throw new Error("permit2FacilitatorAddress is required for Permit2 payments"); + } + const permit2Authorization: ExactPermit2Payload["permit2Authorization"] = { from: signer.address, permitted: { token: getAddress(paymentRequirements.asset), amount: paymentRequirements.amount, }, - spender: x402ExactPermit2ProxyAddress, + spender: getX402ExactPermit2ProxyAddress(paymentRequirements.network), nonce, deadline, witness: { to: getAddress(paymentRequirements.payTo), + facilitator: getAddress(facilitator), validAfter, }, }; @@ -84,11 +91,12 @@ async function signPermit2Authorization( requirements: PaymentRequirements, ): Promise<`0x${string}`> { const chainId = getEvmChainId(requirements.network); + const permit2Address = getPermit2Address(requirements.network); const domain = { name: "Permit2", chainId, - verifyingContract: PERMIT2_ADDRESS, + verifyingContract: permit2Address, }; const message = { @@ -101,6 +109,7 @@ async function signPermit2Authorization( deadline: BigInt(permit2Authorization.deadline), witness: { to: getAddress(permit2Authorization.witness.to), + facilitator: getAddress(permit2Authorization.witness.facilitator), validAfter: BigInt(permit2Authorization.witness.validAfter), }, }; @@ -118,25 +127,30 @@ async function signPermit2Authorization( * The user sends this transaction (paying gas) before using Permit2 flow. * * @param tokenAddress - The ERC20 token contract address + * @param network - The target EVM network used to resolve the Permit2 deployment * @returns Transaction data to send for approval * * @example * ```typescript - * const tx = createPermit2ApprovalTx("0x..."); + * const tx = createPermit2ApprovalTx("0x...", "eip155:97"); * await walletClient.sendTransaction({ * to: tx.to, * data: tx.data, * }); * ``` */ -export function createPermit2ApprovalTx(tokenAddress: `0x${string}`): { +export function createPermit2ApprovalTx( + tokenAddress: `0x${string}`, + network: string, +): { to: `0x${string}`; data: `0x${string}`; } { + const permit2Address = getPermit2Address(network); const data = encodeFunctionData({ abi: erc20ApproveAbi, functionName: "approve", - args: [PERMIT2_ADDRESS, MAX_UINT256], + args: [permit2Address, MAX_UINT256], }); return { @@ -152,6 +166,7 @@ export function createPermit2ApprovalTx(tokenAddress: `0x${string}`): { export interface Permit2AllowanceParams { tokenAddress: `0x${string}`; ownerAddress: `0x${string}`; + network: string; } /** @@ -178,10 +193,11 @@ export function getPermit2AllowanceReadParams(params: Permit2AllowanceParams): { functionName: "allowance"; args: [`0x${string}`, `0x${string}`]; } { + const permit2Address = getPermit2Address(params.network); return { address: getAddress(params.tokenAddress), abi: erc20AllowanceAbi, functionName: "allowance", - args: [getAddress(params.ownerAddress), PERMIT2_ADDRESS], + args: [getAddress(params.ownerAddress), permit2Address], }; } diff --git a/typescript/packages/mechanisms/evm/src/exact/client/scheme.ts b/typescript/packages/mechanisms/evm/src/exact/client/scheme.ts index e8d673ee..9c5c2c49 100644 --- a/typescript/packages/mechanisms/evm/src/exact/client/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/exact/client/scheme.ts @@ -7,7 +7,7 @@ import { import { EIP2612_GAS_SPONSORING, ERC20_APPROVAL_GAS_SPONSORING } from "@bankofai/x402-extensions"; import { ClientEvmSigner } from "../../signer"; import { AssetTransferMethod } from "../../types"; -import { PERMIT2_ADDRESS, erc20AllowanceAbi } from "../../constants"; +import { erc20AllowanceAbi, getPermit2Address } from "../../constants"; import { getAddress } from "viem"; import { getEvmChainId } from "../../utils"; import { createEIP3009Payload } from "./eip3009"; @@ -126,6 +126,7 @@ export class ExactEvmScheme implements SchemeNetworkClient { const chainId = getEvmChainId(requirements.network); const tokenAddress = getAddress(requirements.asset) as `0x${string}`; + const permit2Address = getPermit2Address(requirements.network); // Check if user already has sufficient Permit2 allowance try { @@ -133,7 +134,7 @@ export class ExactEvmScheme implements SchemeNetworkClient { address: tokenAddress, abi: erc20AllowanceAbi, functionName: "allowance", - args: [this.signer.address, PERMIT2_ADDRESS], + args: [this.signer.address, permit2Address], })) as bigint; if (allowance >= BigInt(requirements.amount)) { @@ -156,6 +157,7 @@ export class ExactEvmScheme implements SchemeNetworkClient { tokenAddress, tokenName, tokenVersion, + requirements.network, chainId, deadline, requirements.amount, @@ -202,6 +204,7 @@ export class ExactEvmScheme implements SchemeNetworkClient { const chainId = getEvmChainId(requirements.network); const tokenAddress = getAddress(requirements.asset) as `0x${string}`; + const permit2Address = getPermit2Address(requirements.network); // Check if user already has sufficient Permit2 allowance try { @@ -209,7 +212,7 @@ export class ExactEvmScheme implements SchemeNetworkClient { address: tokenAddress, abi: erc20AllowanceAbi, functionName: "allowance", - args: [this.signer.address, PERMIT2_ADDRESS], + args: [this.signer.address, permit2Address], })) as bigint; if (allowance >= BigInt(requirements.amount)) { @@ -220,7 +223,12 @@ export class ExactEvmScheme implements SchemeNetworkClient { } // Sign the approve(Permit2, MaxUint256) transaction - const info = await signErc20ApprovalTransaction(this.signer, tokenAddress, chainId); + const info = await signErc20ApprovalTransaction( + this.signer, + tokenAddress, + requirements.network, + chainId, + ); return { [ERC20_APPROVAL_GAS_SPONSORING.key]: { info }, diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/erc20approval.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/erc20approval.ts index f5af587e..a7f21c5a 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/erc20approval.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/erc20approval.ts @@ -10,7 +10,7 @@ import { validateErc20ApprovalGasSponsoringInfo, type Erc20ApprovalGasSponsoringInfo, } from "@bankofai/x402-extensions"; -import { PERMIT2_ADDRESS, erc20ApproveAbi } from "../../constants"; +import { erc20ApproveAbi, getPermit2Address } from "../../constants"; import { ErrErc20ApprovalInvalidFormat, ErrErc20ApprovalFromMismatch, @@ -43,13 +43,16 @@ const APPROVE_SELECTOR = "0x095ea7b3"; * @param info - The ERC-20 approval gas sponsoring info * @param payer - The expected payer address * @param tokenAddress - The expected token address + * @param network - CAIP-2 EVM network identifier used to resolve Permit2. * @returns Validation result with invalidReason and invalidMessage on failure */ export async function validateErc20ApprovalForPayment( info: Erc20ApprovalGasSponsoringInfo, payer: `0x${string}`, tokenAddress: `0x${string}`, + network: string, ): Promise> { + const permit2Address = getPermit2Address(network); if (!validateErc20ApprovalGasSponsoringInfo(info)) { return { isValid: false, @@ -74,11 +77,11 @@ export async function validateErc20ApprovalForPayment( }; } - if (getAddress(info.spender) !== getAddress(PERMIT2_ADDRESS)) { + if (getAddress(info.spender) !== getAddress(permit2Address)) { return { isValid: false, invalidReason: ErrErc20ApprovalSpenderNotPermit2, - invalidMessage: `Expected spender=${PERMIT2_ADDRESS}, got ${info.spender}`, + invalidMessage: `Expected spender=${permit2Address}, got ${info.spender}`, }; } @@ -109,11 +112,11 @@ export async function validateErc20ApprovalForPayment( data: data as `0x${string}`, }); const calldataSpender = getAddress(decoded.args[0] as `0x${string}`); - if (calldataSpender !== getAddress(PERMIT2_ADDRESS)) { + if (calldataSpender !== getAddress(permit2Address)) { return { isValid: false, invalidReason: ErrErc20ApprovalTxWrongSpender, - invalidMessage: `approve() spender is ${calldataSpender}, expected Permit2 ${PERMIT2_ADDRESS}`, + invalidMessage: `approve() spender is ${calldataSpender}, expected Permit2 ${permit2Address}`, }; } } catch { diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts index 87098b27..1b3a4887 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts @@ -16,10 +16,10 @@ import type { Eip2612GasSponsoringInfo } from "@bankofai/x402-extensions"; import { getAddress } from "viem"; import { eip3009ABI, - PERMIT2_ADDRESS, + getPermit2Address, permit2WitnessTypes, x402ExactPermit2ProxyABI, - x402ExactPermit2ProxyAddress, + getX402ExactPermit2ProxyAddress, erc20AllowanceAbi, } from "../../constants"; import { @@ -78,11 +78,19 @@ export async function verifyPermit2( const chainId = getEvmChainId(requirements.network); const tokenAddress = getAddress(requirements.asset); + const permit2Address = getPermit2Address(requirements.network); + const proxyAddress = getX402ExactPermit2ProxyAddress(requirements.network); + const facilitator = resolvePermit2Facilitator(requirements, permit2Payload); + if (!facilitator) { + return { + isValid: false, + invalidReason: "invalid_permit2_facilitator_mismatch", + payer, + }; + } + const { facilitatorAddress, witnessFacilitator } = facilitator; - if ( - getAddress(permit2Payload.permit2Authorization.spender) !== - getAddress(x402ExactPermit2ProxyAddress) - ) { + if (getAddress(permit2Payload.permit2Authorization.spender) !== getAddress(proxyAddress)) { return { isValid: false, invalidReason: "invalid_permit2_spender", @@ -100,6 +108,14 @@ export async function verifyPermit2( }; } + if (getAddress(witnessFacilitator) !== getAddress(facilitatorAddress)) { + return { + isValid: false, + invalidReason: "invalid_permit2_facilitator_mismatch", + payer, + }; + } + const now = Math.floor(Date.now() / 1000); if (BigInt(permit2Payload.permit2Authorization.deadline) < BigInt(now + 6)) { return { @@ -142,7 +158,7 @@ export async function verifyPermit2( domain: { name: "Permit2", chainId, - verifyingContract: PERMIT2_ADDRESS, + verifyingContract: permit2Address, }, message: { permitted: { @@ -154,6 +170,7 @@ export async function verifyPermit2( deadline: BigInt(permit2Payload.permit2Authorization.deadline), witness: { to: getAddress(permit2Payload.permit2Authorization.witness.to), + facilitator: getAddress(witnessFacilitator), validAfter: BigInt(permit2Payload.permit2Authorization.witness.validAfter), }, }, @@ -240,12 +257,13 @@ async function _verifyPermit2Allowance( tokenAddress: `0x${string}`, context?: FacilitatorContext, ): Promise { + const permit2Address = getPermit2Address(requirements.network); try { const allowance = (await signer.readContract({ address: tokenAddress, abi: erc20AllowanceAbi, functionName: "allowance", - args: [payer, PERMIT2_ADDRESS], + args: [payer, permit2Address], })) as bigint; if (allowance >= BigInt(requirements.amount)) { @@ -255,7 +273,12 @@ async function _verifyPermit2Allowance( // Allowance insufficient — try EIP-2612 gas sponsoring first const eip2612Info = extractEip2612GasSponsoringInfo(payload); if (eip2612Info) { - const result = validateEip2612PermitForPayment(eip2612Info, payer, tokenAddress); + const result = validateEip2612PermitForPayment( + eip2612Info, + payer, + tokenAddress, + requirements.network, + ); if (!result.isValid) { return { isValid: false, invalidReason: result.invalidReason!, payer }; } @@ -270,7 +293,12 @@ async function _verifyPermit2Allowance( if (erc20GasSponsorshipExtension) { const erc20Info = extractErc20ApprovalGasSponsoringInfo(payload); if (erc20Info) { - const result = await validateErc20ApprovalForPayment(erc20Info, payer, tokenAddress); + const result = await validateErc20ApprovalForPayment( + erc20Info, + payer, + tokenAddress, + requirements.network, + ); if (!result.isValid) { return { isValid: false, invalidReason: result.invalidReason!, payer }; } @@ -283,7 +311,12 @@ async function _verifyPermit2Allowance( // If allowance check fails, validate extensions if present; otherwise proceed optimistically const eip2612Info = extractEip2612GasSponsoringInfo(payload); if (eip2612Info) { - const result = validateEip2612PermitForPayment(eip2612Info, payer, tokenAddress); + const result = validateEip2612PermitForPayment( + eip2612Info, + payer, + tokenAddress, + requirements.network, + ); if (!result.isValid) { return { isValid: false, invalidReason: result.invalidReason!, payer }; } @@ -369,11 +402,23 @@ async function _settlePermit2WithEIP2612( eip2612Info: Eip2612GasSponsoringInfo, ): Promise { const payer = permit2Payload.permit2Authorization.from; + const proxyAddress = getX402ExactPermit2ProxyAddress(payload.accepted.network); + const facilitator = resolvePermit2Facilitator(payload.accepted, permit2Payload); + if (!facilitator) { + return { + success: false, + network: payload.accepted.network, + transaction: "", + errorReason: "invalid_permit2_facilitator_mismatch", + payer, + }; + } + const { witnessFacilitator } = facilitator; try { const { v, r, s } = splitEip2612Signature(eip2612Info.signature); const tx = await signer.writeContract({ - address: x402ExactPermit2ProxyAddress, + address: proxyAddress, abi: x402ExactPermit2ProxyABI, functionName: "settleWithPermit", args: [ @@ -395,6 +440,7 @@ async function _settlePermit2WithEIP2612( getAddress(payer), { to: getAddress(permit2Payload.permit2Authorization.witness.to), + facilitator: getAddress(witnessFacilitator), validAfter: BigInt(permit2Payload.permit2Authorization.witness.validAfter), }, permit2Payload.signature, @@ -425,6 +471,18 @@ async function _settlePermit2WithERC20Approval( erc20Info: { signedTransaction: string }, ): Promise { const payer = permit2Payload.permit2Authorization.from; + const proxyAddress = getX402ExactPermit2ProxyAddress(payload.accepted.network); + const facilitator = resolvePermit2Facilitator(payload.accepted, permit2Payload); + if (!facilitator) { + return { + success: false, + network: payload.accepted.network, + transaction: "", + errorReason: "invalid_permit2_facilitator_mismatch", + payer, + }; + } + const { witnessFacilitator } = facilitator; try { const approvalTxHash = await extensionSigner.sendRawTransaction({ @@ -446,7 +504,7 @@ async function _settlePermit2WithERC20Approval( } const tx = await extensionSigner.writeContract({ - address: x402ExactPermit2ProxyAddress, + address: proxyAddress, abi: x402ExactPermit2ProxyABI, functionName: "settle", args: [ @@ -461,6 +519,7 @@ async function _settlePermit2WithERC20Approval( getAddress(payer), { to: getAddress(permit2Payload.permit2Authorization.witness.to), + facilitator: getAddress(witnessFacilitator), validAfter: BigInt(permit2Payload.permit2Authorization.witness.validAfter), }, permit2Payload.signature, @@ -487,9 +546,21 @@ async function _settlePermit2Direct( permit2Payload: ExactPermit2Payload, ): Promise { const payer = permit2Payload.permit2Authorization.from; + const proxyAddress = getX402ExactPermit2ProxyAddress(payload.accepted.network); + const facilitator = resolvePermit2Facilitator(payload.accepted, permit2Payload); + if (!facilitator) { + return { + success: false, + network: payload.accepted.network, + transaction: "", + errorReason: "invalid_permit2_facilitator_mismatch", + payer, + }; + } + const { witnessFacilitator } = facilitator; try { const tx = await signer.writeContract({ - address: x402ExactPermit2ProxyAddress, + address: proxyAddress, abi: x402ExactPermit2ProxyABI, functionName: "settle", args: [ @@ -504,6 +575,7 @@ async function _settlePermit2Direct( getAddress(payer), { to: getAddress(permit2Payload.permit2Authorization.witness.to), + facilitator: getAddress(witnessFacilitator), validAfter: BigInt(permit2Payload.permit2Authorization.witness.validAfter), }, permit2Payload.signature, @@ -600,13 +672,16 @@ function _mapSettleError( * @param info - The EIP-2612 gas sponsoring info * @param payer - The expected payer address * @param tokenAddress - The expected token address + * @param network - CAIP-2 EVM network identifier used to resolve Permit2. * @returns Validation result with optional invalidReason */ function validateEip2612PermitForPayment( info: Eip2612GasSponsoringInfo, payer: `0x${string}`, tokenAddress: `0x${string}`, + network: string, ): { isValid: boolean; invalidReason?: string } { + const permit2Address = getPermit2Address(network); if (!validateEip2612GasSponsoringInfo(info)) { return { isValid: false, invalidReason: "invalid_eip2612_extension_format" }; } @@ -619,7 +694,7 @@ function validateEip2612PermitForPayment( return { isValid: false, invalidReason: "eip2612_asset_mismatch" }; } - if (getAddress(info.spender as `0x${string}`) !== getAddress(PERMIT2_ADDRESS)) { + if (getAddress(info.spender as `0x${string}`) !== getAddress(permit2Address)) { return { isValid: false, invalidReason: "eip2612_spender_not_permit2" }; } @@ -631,6 +706,29 @@ function validateEip2612PermitForPayment( return { isValid: true }; } +/** + * Resolves the expected facilitator address for Permit2 witness validation and settlement. + * + * @param requirements - The payment requirements associated with the payment. + * @param permit2Payload - The Permit2 payload supplied by the client. + * @returns The expected facilitator address and the witness facilitator value to use. + */ +function resolvePermit2Facilitator( + requirements: PaymentRequirements, + permit2Payload: ExactPermit2Payload, +): { facilitatorAddress: `0x${string}`; witnessFacilitator: `0x${string}` } | null { + const facilitatorAddress = + requirements.extra?.permit2FacilitatorAddress as `0x${string}` | undefined; + const witnessFacilitator = + permit2Payload.permit2Authorization.witness.facilitator as `0x${string}` | undefined; + + if (!facilitatorAddress || !witnessFacilitator) { + return null; + } + + return { facilitatorAddress, witnessFacilitator }; +} + /** * Splits a 65-byte EIP-2612 signature into v, r, s components. * diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts index 52a1650d..0c7d52ac 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts @@ -48,13 +48,18 @@ export class ExactEvmScheme implements SchemeNetworkFacilitator { } /** - * Returns undefined — EVM has no mechanism-specific extra data. + * Returns mechanism-specific extra data for supported kinds. * * @param _ - The network identifier (unused) - * @returns undefined + * @returns Extra metadata for clients, including the Permit2 facilitator address */ getExtra(_: string): Record | undefined { - return undefined; + const facilitatorAddress = this.signer.getAddresses()[0]; + return facilitatorAddress + ? { + permit2FacilitatorAddress: facilitatorAddress, + } + : undefined; } /** diff --git a/typescript/packages/mechanisms/evm/src/exact/server/scheme.ts b/typescript/packages/mechanisms/evm/src/exact/server/scheme.ts index 35cd85e8..e0bfebc9 100644 --- a/typescript/packages/mechanisms/evm/src/exact/server/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/exact/server/scheme.ts @@ -99,10 +99,21 @@ export class ExactEvmScheme implements SchemeNetworkServer { }, extensionKeys: string[], ): Promise { - // Mark unused parameters to satisfy linter - void supportedKind; void extensionKeys; - return Promise.resolve(paymentRequirements); + const existingMethod = paymentRequirements.extra?.assetTransferMethod as string | undefined; + const permit2FacilitatorAddress = + (paymentRequirements.extra?.permit2FacilitatorAddress as string | undefined) ?? + (supportedKind.extra?.permit2FacilitatorAddress as string | undefined); + + return Promise.resolve({ + ...paymentRequirements, + extra: { + ...paymentRequirements.extra, + ...(existingMethod === "permit2" && permit2FacilitatorAddress + ? { permit2FacilitatorAddress } + : {}), + }, + }); } /** diff --git a/typescript/packages/mechanisms/evm/src/index.ts b/typescript/packages/mechanisms/evm/src/index.ts index 946047f3..19fcd90f 100644 --- a/typescript/packages/mechanisms/evm/src/index.ts +++ b/typescript/packages/mechanisms/evm/src/index.ts @@ -32,8 +32,14 @@ export { isPermit2Payload, isEIP3009Payload } from "./types"; // Constants export { PERMIT2_ADDRESS, + PERMIT2_ADDRESSES, + getPermit2Address, x402ExactPermit2ProxyAddress, + X402_EXACT_PERMIT2_PROXY_ADDRESSES, + getX402ExactPermit2ProxyAddress, x402UptoPermit2ProxyAddress, + X402_UPTO_PERMIT2_PROXY_ADDRESSES, + getX402UptoPermit2ProxyAddress, permit2WitnessTypes, authorizationTypes, eip3009ABI, diff --git a/typescript/packages/mechanisms/evm/src/types.ts b/typescript/packages/mechanisms/evm/src/types.ts index ca243ca9..da3f1742 100644 --- a/typescript/packages/mechanisms/evm/src/types.ts +++ b/typescript/packages/mechanisms/evm/src/types.ts @@ -27,6 +27,7 @@ export type ExactEIP3009Payload = { */ export type Permit2Witness = { to: `0x${string}`; + facilitator: `0x${string}`; validAfter: string; }; diff --git a/typescript/packages/mechanisms/evm/test/unit/constants.test.ts b/typescript/packages/mechanisms/evm/test/unit/constants.test.ts index 2bd03051..d64688b1 100644 --- a/typescript/packages/mechanisms/evm/test/unit/constants.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/constants.test.ts @@ -4,6 +4,7 @@ import { authorizationTypes, eip3009ABI, permit2WitnessTypes, + getPermit2Address, x402ExactPermit2ProxyAddress, PERMIT2_ADDRESS, } from "../../src/constants"; @@ -91,11 +92,22 @@ describe("EVM Constants", () => { expect(hasExtra).toBe(false); }); - it("Witness type must have exactly 'to' and 'validAfter' fields", () => { + it("Witness type must have exactly 'to', 'facilitator', and 'validAfter' fields", () => { const witnessFields = permit2WitnessTypes.Witness; - expect(witnessFields).toHaveLength(2); + expect(witnessFields).toHaveLength(3); expect(witnessFields[0].name).toBe("to"); - expect(witnessFields[1].name).toBe("validAfter"); + expect(witnessFields[1].name).toBe("facilitator"); + expect(witnessFields[2].name).toBe("validAfter"); + }); + }); + + describe("Permit2 address resolution", () => { + it("should use PancakeSwap Permit2 on BSC testnet", () => { + expect(getPermit2Address("eip155:97")).toBe("0x31c2F6fcFf4F8759b3Bd5Bf0e1084A055615c768"); + }); + + it("should fall back to canonical Permit2 on other EVM chains", () => { + expect(getPermit2Address("eip155:84532")).toBe(PERMIT2_ADDRESS); }); }); @@ -124,6 +136,7 @@ describe("EVM Constants", () => { deadline: 9999999999n, witness: { to: "0x9876543210987654321098765432109876543210" as `0x${string}`, + facilitator: "0x1111111111111111111111111111111111111111" as `0x${string}`, validAfter: 0n, }, } as const; @@ -170,6 +183,7 @@ describe("EVM Constants", () => { ...canonicalMessage, witness: { to: "0x0000000000000000000000000000000000000001" as `0x${string}`, + facilitator: "0x1111111111111111111111111111111111111111" as `0x${string}`, validAfter: 0n, }, }, diff --git a/typescript/packages/mechanisms/evm/test/unit/exact/client.test.ts b/typescript/packages/mechanisms/evm/test/unit/exact/client.test.ts index d4d86a1d..5f04e963 100644 --- a/typescript/packages/mechanisms/evm/test/unit/exact/client.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/exact/client.test.ts @@ -9,6 +9,8 @@ import { PaymentRequirements } from "@bankofai/x402-core/types"; import { PERMIT2_ADDRESS, x402ExactPermit2ProxyAddress } from "../../../src/constants"; import { isPermit2Payload, isEIP3009Payload } from "../../../src/types"; +const permit2FacilitatorAddress = "0x1111111111111111111111111111111111111111"; + describe("ExactEvmScheme (Client)", () => { let client: ExactEvmScheme; let mockSigner: ClientEvmSigner; @@ -266,7 +268,12 @@ describe("ExactEvmScheme (Client)", () => { asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 300, - extra: { name: "USD Coin", version: "2", assetTransferMethod: "permit2" }, + extra: { + name: "USD Coin", + version: "2", + assetTransferMethod: "permit2", + permit2FacilitatorAddress, + }, }; const result = await client.createPaymentPayload(2, requirements); @@ -287,7 +294,7 @@ describe("ExactEvmScheme (Client)", () => { asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 300, - extra: { assetTransferMethod: "permit2" }, + extra: { assetTransferMethod: "permit2", permit2FacilitatorAddress }, }; const result = await client.createPaymentPayload(2, requirements); @@ -308,7 +315,7 @@ describe("ExactEvmScheme (Client)", () => { asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 300, - extra: { assetTransferMethod: "permit2" }, + extra: { assetTransferMethod: "permit2", permit2FacilitatorAddress }, }; const result = await client.createPaymentPayload(2, requirements); @@ -326,7 +333,7 @@ describe("ExactEvmScheme (Client)", () => { asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", payTo: payToAddress, maxTimeoutSeconds: 300, - extra: { assetTransferMethod: "permit2" }, + extra: { assetTransferMethod: "permit2", permit2FacilitatorAddress }, }; const result = await client.createPaymentPayload(2, requirements); @@ -345,7 +352,7 @@ describe("ExactEvmScheme (Client)", () => { asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 300, - extra: { assetTransferMethod: "permit2" }, + extra: { assetTransferMethod: "permit2", permit2FacilitatorAddress }, }; await client.createPaymentPayload(2, requirements); @@ -362,7 +369,7 @@ describe("Permit2 Approval Helpers", () => { describe("createPermit2ApprovalTx", () => { it("should create approval transaction data", () => { const tokenAddress = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as `0x${string}`; - const tx = createPermit2ApprovalTx(tokenAddress); + const tx = createPermit2ApprovalTx(tokenAddress, "eip155:84532"); expect(tx.to.toLowerCase()).toBe(tokenAddress.toLowerCase()); expect(tx.data).toBeDefined(); @@ -371,11 +378,20 @@ describe("Permit2 Approval Helpers", () => { it("should encode approve function call", () => { const tokenAddress = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as `0x${string}`; - const tx = createPermit2ApprovalTx(tokenAddress); + const tx = createPermit2ApprovalTx(tokenAddress, "eip155:84532"); // approve(address,uint256) selector is 0x095ea7b3 expect(tx.data.startsWith("0x095ea7b3")).toBe(true); }); + + it("should target PancakeSwap Permit2 on BSC testnet", () => { + const tokenAddress = "0x55d398326f99059fF775485246999027B3197955" as `0x${string}`; + const tx = createPermit2ApprovalTx(tokenAddress, "eip155:97"); + + expect(tx.data.toLowerCase()).toContain( + "31c2f6fcff4f8759b3bd5bf0e1084a055615c768".toLowerCase(), + ); + }); }); describe("getPermit2AllowanceReadParams", () => { @@ -383,6 +399,7 @@ describe("Permit2 Approval Helpers", () => { const params = getPermit2AllowanceReadParams({ tokenAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", ownerAddress: "0x1234567890123456789012345678901234567890", + network: "eip155:84532", }); expect(params.address.toLowerCase()).toBe( @@ -399,6 +416,7 @@ describe("Permit2 Approval Helpers", () => { const params = getPermit2AllowanceReadParams({ tokenAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", ownerAddress: "0x1234567890123456789012345678901234567890", + network: "eip155:84532", }); expect(params.abi).toBeDefined(); @@ -486,6 +504,7 @@ describe("Permit2 Approval Flow", () => { const readParams = getPermit2AllowanceReadParams({ tokenAddress, ownerAddress, + network: "eip155:84532", }); expect(readParams).toBeDefined(); @@ -496,7 +515,7 @@ describe("Permit2 Approval Flow", () => { // Step 3: Check if approval needed if (checkNeedsApproval(currentAllowance, requiredAmount)) { // Step 4: Create approval transaction - const tx = createPermit2ApprovalTx(tokenAddress); + const tx = createPermit2ApprovalTx(tokenAddress, "eip155:84532"); expect(tx.to).toBeDefined(); expect(tx.data).toBeDefined(); @@ -570,13 +589,14 @@ describe("Permit2 Approval Flow", () => { asset: tokenAddress, payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 300, - extra: { assetTransferMethod: "permit2" }, + extra: { assetTransferMethod: "permit2", permit2FacilitatorAddress }, }; // Step 1: Check allowance (simulated as zero) const readParams = getPermit2AllowanceReadParams({ tokenAddress, ownerAddress: mockSigner.address, + network: requirements.network, }); expect(readParams.functionName).toBe("allowance"); @@ -585,7 +605,7 @@ describe("Permit2 Approval Flow", () => { expect(needsApproval).toBe(true); // Step 2: Create and "send" approval tx - const approvalTx = createPermit2ApprovalTx(tokenAddress); + const approvalTx = createPermit2ApprovalTx(tokenAddress, requirements.network); expect(approvalTx.to.toLowerCase()).toBe(tokenAddress.toLowerCase()); // In real app: await walletClient.sendTransaction(approvalTx) @@ -606,7 +626,7 @@ describe("Permit2 Approval Flow", () => { asset: tokenAddress, payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 300, - extra: { assetTransferMethod: "permit2" }, + extra: { assetTransferMethod: "permit2", permit2FacilitatorAddress }, }; // Step 1: Check allowance (simulated as max uint256 - already approved) @@ -636,6 +656,7 @@ describe("Permit2 Approval Flow", () => { assetTransferMethod: "permit2", name: "USDC", version: "2", + permit2FacilitatorAddress, }, }; @@ -734,6 +755,7 @@ describe("Permit2 Approval Flow", () => { maxTimeoutSeconds: 60, extra: { assetTransferMethod: "permit2", + permit2FacilitatorAddress, // No name/version - generic ERC-20 without EIP-2612 }, }; @@ -831,6 +853,7 @@ describe("Permit2 Approval Flow", () => { assetTransferMethod: "permit2", name: "TOKEN", version: "1", + permit2FacilitatorAddress, }, }; diff --git a/typescript/packages/mechanisms/evm/test/unit/exact/facilitator.test.ts b/typescript/packages/mechanisms/evm/test/unit/exact/facilitator.test.ts index 3c201ca4..dda13831 100644 --- a/typescript/packages/mechanisms/evm/test/unit/exact/facilitator.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/exact/facilitator.test.ts @@ -6,6 +6,8 @@ import { PaymentRequirements, PaymentPayload } from "@bankofai/x402-core/types"; import { x402ExactPermit2ProxyAddress, PERMIT2_ADDRESS } from "../../../src/constants"; import { ERC20_APPROVAL_GAS_SPONSORING } from "@bankofai/x402-extensions"; +const permit2FacilitatorAddress = "0x1111111111111111111111111111111111111111"; + // Mock viem's transaction parsing utilities for ERC-20 approval tests // Uses importOriginal to preserve all other viem exports (getAddress, etc.) vi.mock("viem", async importOriginal => { @@ -267,7 +269,12 @@ describe("ExactEvmScheme (Facilitator)", () => { asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 300, - extra: { name: "USDC", version: "2", assetTransferMethod: "permit2" }, + extra: { + name: "USDC", + version: "2", + assetTransferMethod: "permit2", + permit2FacilitatorAddress, + }, }; // Mock readContract to return sufficient allowance and balance @@ -288,6 +295,7 @@ describe("ExactEvmScheme (Facilitator)", () => { deadline: "999999999999", witness: { to: requirements.payTo, + facilitator: permit2FacilitatorAddress, validAfter: "0", }, }, @@ -310,7 +318,12 @@ describe("ExactEvmScheme (Facilitator)", () => { asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 300, - extra: { name: "USDC", version: "2", assetTransferMethod: "permit2" }, + extra: { + name: "USDC", + version: "2", + assetTransferMethod: "permit2", + permit2FacilitatorAddress, + }, }; // Mock readContract to return zero allowance @@ -331,6 +344,7 @@ describe("ExactEvmScheme (Facilitator)", () => { deadline: "999999999999", witness: { to: requirements.payTo, + facilitator: permit2FacilitatorAddress, validAfter: "0", }, }, @@ -354,7 +368,12 @@ describe("ExactEvmScheme (Facilitator)", () => { asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 300, - extra: { name: "USDC", version: "2", assetTransferMethod: "permit2" }, + extra: { + name: "USDC", + version: "2", + assetTransferMethod: "permit2", + permit2FacilitatorAddress, + }, }; const permit2Payload: PaymentPayload = { @@ -372,6 +391,7 @@ describe("ExactEvmScheme (Facilitator)", () => { deadline: "1", // Expired deadline witness: { to: requirements.payTo, + facilitator: permit2FacilitatorAddress, validAfter: "0", }, }, @@ -395,7 +415,12 @@ describe("ExactEvmScheme (Facilitator)", () => { asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 300, - extra: { name: "USDC", version: "2", assetTransferMethod: "permit2" }, + extra: { + name: "USDC", + version: "2", + assetTransferMethod: "permit2", + permit2FacilitatorAddress, + }, }; const permit2Payload: PaymentPayload = { @@ -413,6 +438,7 @@ describe("ExactEvmScheme (Facilitator)", () => { deadline: "999999999999", witness: { to: requirements.payTo, + facilitator: permit2FacilitatorAddress, validAfter: "0", }, }, @@ -436,7 +462,12 @@ describe("ExactEvmScheme (Facilitator)", () => { asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 300, - extra: { name: "USDC", version: "2", assetTransferMethod: "permit2" }, + extra: { + name: "USDC", + version: "2", + assetTransferMethod: "permit2", + permit2FacilitatorAddress, + }, }; const permit2Payload: PaymentPayload = { @@ -454,6 +485,7 @@ describe("ExactEvmScheme (Facilitator)", () => { deadline: "999999999999", witness: { to: "0x0000000000000000000000000000000000000001", // Wrong recipient + facilitator: permit2FacilitatorAddress, validAfter: "0", }, }, @@ -479,7 +511,12 @@ describe("ExactEvmScheme (Facilitator)", () => { asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 300, - extra: { name: "USDC", version: "2", assetTransferMethod: "permit2" }, + extra: { + name: "USDC", + version: "2", + assetTransferMethod: "permit2", + permit2FacilitatorAddress, + }, }; // Mock readContract to return sufficient allowance and balance @@ -500,6 +537,7 @@ describe("ExactEvmScheme (Facilitator)", () => { deadline: "999999999999", witness: { to: requirements.payTo, + facilitator: permit2FacilitatorAddress, validAfter: "0", }, }, @@ -524,7 +562,12 @@ describe("ExactEvmScheme (Facilitator)", () => { asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 300, - extra: { name: "USDC", version: "2", assetTransferMethod: "permit2" }, + extra: { + name: "USDC", + version: "2", + assetTransferMethod: "permit2", + permit2FacilitatorAddress, + }, }; // Mock readContract to return zero allowance @@ -545,6 +588,7 @@ describe("ExactEvmScheme (Facilitator)", () => { deadline: "999999999999", witness: { to: requirements.payTo, + facilitator: permit2FacilitatorAddress, validAfter: "0", }, }, @@ -641,7 +685,12 @@ describe("ExactEvmScheme (Facilitator)", () => { asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 60, - extra: { assetTransferMethod: "permit2", name: "USDC", version: "2" }, + extra: { + assetTransferMethod: "permit2", + name: "USDC", + version: "2", + permit2FacilitatorAddress, + }, }; // Create a Permit2 payload @@ -697,7 +746,12 @@ describe("ExactEvmScheme (Facilitator)", () => { asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 60, - extra: { assetTransferMethod: "permit2", name: "USDC", version: "2" }, + extra: { + assetTransferMethod: "permit2", + name: "USDC", + version: "2", + permit2FacilitatorAddress, + }, }; const permit2ClientSigner: ClientEvmSigner = { @@ -734,7 +788,12 @@ describe("ExactEvmScheme (Facilitator)", () => { asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 60, - extra: { assetTransferMethod: "permit2", name: "USDC", version: "2" }, + extra: { + assetTransferMethod: "permit2", + name: "USDC", + version: "2", + permit2FacilitatorAddress, + }, }; const permit2ClientSigner: ClientEvmSigner = { @@ -782,7 +841,12 @@ describe("ExactEvmScheme (Facilitator)", () => { asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 60, - extra: { assetTransferMethod: "permit2", name: "USDC", version: "2" }, + extra: { + assetTransferMethod: "permit2", + name: "USDC", + version: "2", + permit2FacilitatorAddress, + }, }; function makePermit2Payload(extensions?: Record): PaymentPayload { @@ -802,6 +866,7 @@ describe("ExactEvmScheme (Facilitator)", () => { deadline: (now + 300).toString(), witness: { to: permit2Requirements.payTo, + facilitator: permit2FacilitatorAddress, validAfter: "0", }, }, @@ -955,7 +1020,7 @@ describe("ExactEvmScheme (Facilitator)", () => { asset: TOKEN_ADDRESS, payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 60, - extra: { assetTransferMethod: "permit2" }, + extra: { assetTransferMethod: "permit2", permit2FacilitatorAddress }, }; function makeErc20Permit2Payload(extensions?: Record): PaymentPayload { @@ -975,6 +1040,7 @@ describe("ExactEvmScheme (Facilitator)", () => { deadline: (now + 300).toString(), witness: { to: erc20Requirements.payTo, + facilitator: permit2FacilitatorAddress, validAfter: "0", }, }, @@ -1177,7 +1243,7 @@ describe("ExactEvmScheme (Facilitator)", () => { asset: TOKEN_ADDRESS, payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", maxTimeoutSeconds: 60, - extra: { assetTransferMethod: "permit2" }, + extra: { assetTransferMethod: "permit2", permit2FacilitatorAddress }, }; function makeErc20Permit2Payload(extensions?: Record): PaymentPayload { @@ -1197,6 +1263,7 @@ describe("ExactEvmScheme (Facilitator)", () => { deadline: (now + 300).toString(), witness: { to: erc20Requirements.payTo, + facilitator: permit2FacilitatorAddress, validAfter: "0", }, }, diff --git a/typescript/packages/mechanisms/tron/src/exact/client/scheme.ts b/typescript/packages/mechanisms/tron/src/exact/client/scheme.ts index bca1a0e9..9dd92948 100644 --- a/typescript/packages/mechanisms/tron/src/exact/client/scheme.ts +++ b/typescript/packages/mechanisms/tron/src/exact/client/scheme.ts @@ -17,7 +17,7 @@ import { signTrc20ApprovalTransaction } from "./trc20approval"; * Supports both EIP-3009-style TransferWithAuthorization and Permit2 flows. * * Routes to the appropriate authorization method based on - * `requirements.extra.assetTransferMethod`. Defaults to `eip3009`. + * `requirements.extra.assetTransferMethod`. Defaults to `transferWithAuthorization`. */ export class ExactTronScheme implements SchemeNetworkClient { readonly scheme = "exact"; @@ -43,11 +43,9 @@ export class ExactTronScheme implements SchemeNetworkClient { paymentRequirements: PaymentRequirements, context?: PaymentPayloadContext, ): Promise { - // Mark unused parameters to satisfy linter - void context; - - const assetTransferMethod = - (paymentRequirements.extra?.assetTransferMethod as AssetTransferMethod) ?? "eip3009"; + const assetTransferMethod = normalizeAssetTransferMethod( + paymentRequirements.extra?.assetTransferMethod as string | undefined, + ); if (assetTransferMethod === "permit2") { const result = await createPermit2Payload(this.signer, x402Version, paymentRequirements); @@ -112,3 +110,13 @@ export class ExactTronScheme implements SchemeNetworkClient { }; } } + +function normalizeAssetTransferMethod(method?: string): AssetTransferMethod { + if (method === "permit2") { + return "permit2"; + } + if (method === "tip712" || method === "eip3009") { + return "transferWithAuthorization"; + } + return method === "transferWithAuthorization" ? method : "transferWithAuthorization"; +} diff --git a/typescript/packages/mechanisms/tron/src/exact/facilitator/scheme.ts b/typescript/packages/mechanisms/tron/src/exact/facilitator/scheme.ts index dda7c623..6faab007 100644 --- a/typescript/packages/mechanisms/tron/src/exact/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/tron/src/exact/facilitator/scheme.ts @@ -34,7 +34,7 @@ export class ExactTronScheme implements SchemeNetworkFacilitator { * @returns The extra configuration object */ getExtra(network: string): Record | undefined { - const supportedMethods: string[] = ["eip3009"]; + const supportedMethods: string[] = ["transferWithAuthorization"]; const signers = this.signer.getAddresses(); if (X402_PERMIT2_PROXY_ADDRESSES[network]) { supportedMethods.push("permit2"); diff --git a/typescript/packages/mechanisms/tron/src/exact/server/scheme.ts b/typescript/packages/mechanisms/tron/src/exact/server/scheme.ts index d6be7b29..40860322 100644 --- a/typescript/packages/mechanisms/tron/src/exact/server/scheme.ts +++ b/typescript/packages/mechanisms/tron/src/exact/server/scheme.ts @@ -104,13 +104,16 @@ export class ExactTronScheme implements SchemeNetworkServer { const supportedMethods = supportedKind.extra?.supportedAssetTransferMethods as | string[] | undefined; - const existingMethod = paymentRequirements.extra?.assetTransferMethod as string | undefined; + const existingMethod = normalizeAssetTransferMethod( + paymentRequirements.extra?.assetTransferMethod as string | undefined, + ); + const normalizedSupported = supportedMethods?.map(normalizeAssetTransferMethod); const method = existingMethod ?? - (supportedMethods && supportedMethods.length > 0 - ? supportedMethods.includes("eip3009") - ? "eip3009" - : supportedMethods[0] + (normalizedSupported && normalizedSupported.length > 0 + ? normalizedSupported.includes("transferWithAuthorization") + ? "transferWithAuthorization" + : normalizedSupported[0] : undefined); if (!method) { @@ -257,3 +260,11 @@ export class ExactTronScheme implements SchemeNetworkServer { return assetInfo; } } + +function normalizeAssetTransferMethod(method?: string): string | undefined { + if (!method) return undefined; + if (method === "tip712" || method === "eip3009") { + return "transferWithAuthorization"; + } + return method; +} diff --git a/typescript/packages/mechanisms/tron/src/signer.ts b/typescript/packages/mechanisms/tron/src/signer.ts index 59dd45a4..edf838bd 100644 --- a/typescript/packages/mechanisms/tron/src/signer.ts +++ b/typescript/packages/mechanisms/tron/src/signer.ts @@ -337,7 +337,7 @@ export function createFacilitatorTronSigner( return typeof txId === "string" ? txId : ((txId as { txid?: string }).txid ?? String(txId)); }, async waitForTransactionReceipt(args) { - const maxAttempts = 30; + const maxAttempts = 120; const delayMs = 1000; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { diff --git a/typescript/packages/mechanisms/tron/src/types.ts b/typescript/packages/mechanisms/tron/src/types.ts index 2e5886bf..75f41154 100644 --- a/typescript/packages/mechanisms/tron/src/types.ts +++ b/typescript/packages/mechanisms/tron/src/types.ts @@ -1,9 +1,9 @@ /** * Asset transfer methods for the exact TRON scheme. - * - eip3009: Uses TransferWithAuthorization via TIP-712 (TRON equivalent of EIP-3009) + * - transferWithAuthorization: Uses TransferWithAuthorization via TIP-712 * - permit2: Uses Permit2 + x402Permit2Proxy — universal fallback for any TRC-20 */ -export type AssetTransferMethod = "eip3009" | "permit2"; +export type AssetTransferMethod = "transferWithAuthorization" | "permit2"; // --- TIP-712 (TransferWithAuthorization) types --- diff --git a/typescript/packages/mechanisms/tron/test/unit/exact/client.test.ts b/typescript/packages/mechanisms/tron/test/unit/exact/client.test.ts index f3bd0e1a..2e031353 100644 --- a/typescript/packages/mechanisms/tron/test/unit/exact/client.test.ts +++ b/typescript/packages/mechanisms/tron/test/unit/exact/client.test.ts @@ -65,10 +65,10 @@ describe("ExactTronScheme (Client)", () => { expect(result.payload).not.toHaveProperty("permit2Authorization"); }); - it("should create TIP-712 payload with eip3009 method", async () => { + it("should create TIP-712 payload with transferWithAuthorization method", async () => { const reqs = { ...tip712Requirements, - extra: { ...tip712Requirements.extra, assetTransferMethod: "eip3009" }, + extra: { ...tip712Requirements.extra, assetTransferMethod: "transferWithAuthorization" }, }; const client = new ExactTronScheme(mockSigner); const result = await client.createPaymentPayload(2, reqs); diff --git a/typescript/packages/mechanisms/tron/test/unit/exact/facilitator.test.ts b/typescript/packages/mechanisms/tron/test/unit/exact/facilitator.test.ts index 46c913ac..6978ed6b 100644 --- a/typescript/packages/mechanisms/tron/test/unit/exact/facilitator.test.ts +++ b/typescript/packages/mechanisms/tron/test/unit/exact/facilitator.test.ts @@ -177,10 +177,10 @@ describe("ExactTronScheme (Facilitator)", () => { }); describe("getExtra", () => { - it("should return supportedAssetTransferMethods including eip3009", () => { + it("should return supportedAssetTransferMethods including transferWithAuthorization", () => { const extra = facilitator.getExtra("tron:nile"); expect(extra).toBeDefined(); - expect(extra!.supportedAssetTransferMethods).toContain("eip3009"); + expect(extra!.supportedAssetTransferMethods).toContain("transferWithAuthorization"); }); it("should include permit2 when proxy address exists for network", () => { @@ -192,7 +192,7 @@ describe("ExactTronScheme (Facilitator)", () => { it("should not include permit2 for unknown network without proxy", () => { const extra = facilitator.getExtra("tron:unknown"); const methods = extra!.supportedAssetTransferMethods as string[]; - expect(methods).toContain("eip3009"); + expect(methods).toContain("transferWithAuthorization"); expect(methods).not.toContain("permit2"); }); }); diff --git a/typescript/packages/mechanisms/tron/test/unit/exact/server.test.ts b/typescript/packages/mechanisms/tron/test/unit/exact/server.test.ts index 09c69205..cd1aca30 100644 --- a/typescript/packages/mechanisms/tron/test/unit/exact/server.test.ts +++ b/typescript/packages/mechanisms/tron/test/unit/exact/server.test.ts @@ -128,11 +128,11 @@ describe("ExactTronScheme (Server)", () => { x402Version: 2, scheme: "exact", network: "tron:nile", - extra: { supportedAssetTransferMethods: ["eip3009", "permit2"] }, + extra: { supportedAssetTransferMethods: ["transferWithAuthorization", "permit2"] }, }, [], ); - expect(result.extra?.assetTransferMethod).toBe("eip3009"); + expect(result.extra?.assetTransferMethod).toBe("transferWithAuthorization"); }); it("should not override existing assetTransferMethod", async () => { @@ -142,7 +142,7 @@ describe("ExactTronScheme (Server)", () => { x402Version: 2, scheme: "exact", network: "tron:nile", - extra: { supportedAssetTransferMethods: ["eip3009", "permit2"] }, + extra: { supportedAssetTransferMethods: ["transferWithAuthorization", "permit2"] }, }, [], ); @@ -157,7 +157,7 @@ describe("ExactTronScheme (Server)", () => { scheme: "exact", network: "tron:nile", extra: { - supportedAssetTransferMethods: ["eip3009", "permit2"], + supportedAssetTransferMethods: ["transferWithAuthorization", "permit2"], permit2FacilitatorAddress: "TSForFRqxmZdJ6Yfx2rNaFykhuQLc9cTMR", }, }, @@ -168,7 +168,7 @@ describe("ExactTronScheme (Server)", () => { expect(result.extra?.permit2FacilitatorAddress).toBe("TSForFRqxmZdJ6Yfx2rNaFykhuQLc9cTMR"); }); - it("should use first method if eip3009 not in supported list", async () => { + it("should use first method if transferWithAuthorization not in supported list", async () => { const result = await server.enhancePaymentRequirements( baseRequirements, {