feat: add TVM exact-payment mechanism to Python SDK#1944
feat: add TVM exact-payment mechanism to Python SDK#1944phdargen merged 10 commits intox402-foundation:mainfrom
Conversation
ArkadiyStena
commented
Apr 6, 2026
- add TVM exact client, server, and facilitator implementations
- add TVM signers, codecs, provider, and settlement helpers
- add unit and integration coverage for TON testnet/mainnet flows
- add Python examples and e2e wiring for TVM
| @@ -0,0 +1,84 @@ | |||
| """Registration helpers for TVM exact payment schemes.""" | |||
There was a problem hiding this comment.
please remove the register helper functions in favor of builder pattern, see #1266
| from ..signer import ClientTvmSigner | ||
| from ..trace_utils import ( | ||
| parse_trace_transactions, | ||
| trace_transaction_balance_before, |
There was a problem hiding this comment.
trace_transaction_balance_before seems unused
| settlement=settlement, | ||
| return_transaction=True, | ||
| ) | ||
| actual_inner = settlement.transfer.attached_ton_amount |
There was a problem hiding this comment.
It is my understanding the client sets the gas fee for the inner message. Could we add a reasonable cap here such that a malicious client can't arbitrarily inflate it, similar to SVM?
|
Thanks @ArkadiyStena, made a first pass through the code and this looks great! Could you please add the remaining e2e tests for:
|
|
Got some testnet TON for the facilitator from @testgiver_ton_bot For USDT, I tried @testgiver_ton_usdt_bot but its not responsive. Could you please point me to a USDT faucet, so I can run the e2e tests and examples? Please also put the faucet info for TON + USDT in the README |
|
@ArkadiyStena is attempting to deploy a commit to the Coinbase Team on Vercel. A member of the Team first needs to authorize it. |
|
@phdargen Thank you for the comments — addressed everything in the latest commit. You can get testnet USDT by following the link: ton://transfer/kQDNUDJC0iQvJoZp0ml-YteL1NtTXKphU03CTI5v4VtBhGYs?amount=49000000&bin=te6cckEBAQEAFgAAKClXdJkAAAAAAAAAAAAAAAAAmJaAhDUekg I’ve added this link to the docs. |
|
|
||
| ## Testnet funding | ||
|
|
||
| To fund a TVM payer wallet, request testnet TON from [@testgiver_ton_bot](https://t.me/testgiver_ton_bot) for fees. Then open the [testnet USDT transfer link](ton://transfer/kQDNUDJC0iQvJoZp0ml-YteL1NtTXKphU03CTI5v4VtBhGYs?amount=49000000&bin=te6cckEBAQEAFgAAKClXdJkAAAAAAAAAAAAAAAAAmJaAhDUekg) or scan the QR code below to obtain testnet USDT: |
There was a problem hiding this comment.
| To fund a TVM payer wallet, request testnet TON from [@testgiver_ton_bot](https://t.me/testgiver_ton_bot) for fees. Then open the [testnet USDT transfer link](ton://transfer/kQDNUDJC0iQvJoZp0ml-YteL1NtTXKphU03CTI5v4VtBhGYs?amount=49000000&bin=te6cckEBAQEAFgAAKClXdJkAAAAAAAAAAAAAAAAAmJaAhDUekg) or scan the QR code below to obtain testnet USDT: | |
| To fund a TVM payer wallet, request testnet TON from [@testgiver_ton_bot](https://t.me/testgiver_ton_bot) for fees. Then open the [testnet USDT transfer link](https://app.tonkeeper.com/transfer/kQDNUDJC0iQvJoZp0ml-YteL1NtTXKphU03CTI5v4VtBhGYs?amount=49000000&bin=te6cckEBAQEAFgAAKClXdJkAAAAAAAAAAAAAAAAAmJaAhDUekg) or scan the QR code below with the Tonkeeper App to obtain testnet USDT: |
|
Thanks @ArkadiyStena, have funded test accounts now but still cant get the examples/e2e tests running. |
|
OK actually the USDT claim didn't work initially. I confirmed the claim tx in the Tonkeeper wallet and it it showed success but then the USDT never arrived. Seems I had to make a random TON transfer first, only then the USDT claim actually works? Maybe the wallet wasnt deployed or sth? |
|
Now the facilitator fails with: Anything else missing I need to setup? |
|
@phdargen Thanks for the feedback! I've updated the README to clarify that the facilitator contract needs a minimum balance of 1.1 TON before running the tests. I've also sent 2 TON to the facilitator address you mentioned in your previous message. As for the USDT claim — it should work correctly. I just re-tested it on my end and the claim went through as expected. Maybe you've faced a temporary bug in the Tonkeeper. Please let me know if the issue persists. |
There was a problem hiding this comment.
Had to increase timeout here:
| httpx_timeout = float(os.getenv("HTTPX_TIMEOUT_SECONDS", "60")) | |
| async with x402HttpxClient(client, timeout=httpx_timeout) as http: |
or client fails with:
uv run python all_networks.py
Initialized TVM account: 0:defbf71d826420f1a9120ad7ca7f8205a2c1d034d9dd47fe57b3b439a8d6dac2
Making request to: http://localhost:4021/weather
Toncenter request failed: method=POST path=/api/emulate/v1/emulateTrace url=https://testnet.toncenter.com/api/emulate/v1/emulateTrace status=429 attempt=1/5 retryable=True body='{"ok":false,"result":"Ratelimit exceed","code":429}'
Toncenter request failed: method=POST path=/api/emulate/v1/emulateTrace url=https://testnet.toncenter.com/api/emulate/v1/emulateTrace status=429 attempt=2/5 retryable=True body='{"ok":false,"result":"Ratelimit exceed","code":429}'
Traceback (most recent call last):
File "//examples/python/clients/advanced/.venv/lib/python3.13/site-packages/httpx/_transports/default.py", line 101, in map_httpcore_exceptions
yield
File "//examples/python/clients/advanced/.venv/lib/python3.13/site-packages/httpx/_transports/default.py", line 394, in handle_async_request
resp = await self._pool.handle_async_request(req)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "//examples/python/clients/advanced/.venv/lib/python3.13/site-packages/httpcore/_async/connection_pool.py", line 256, in handle_async_request
raise exc from None
File "//examples/python/clients/advanced/.venv/lib/python3.13/site-packages/httpcore/_async/connection_pool.py", line 236, in handle_async_request
response = await connection.handle_async_request(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pool_request.request
^^^^^^^^^^^^^^^^^^^^
)
^
File "//examples/python/clients/advanced/.venv/lib/python3.13/site-packages/httpcore/_async/connection.py", line 103, in handle_async_request
return await self._connection.handle_async_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "//examples/python/clients/advanced/.venv/lib/python3.13/site-packages/httpcore/_async/http11.py", line 136, in handle_async_request
raise exc
File "//examples/python/clients/advanced/.venv/lib/python3.13/site-packages/httpcore/_async/http11.py", line 106, in handle_async_request
) = await self._receive_response_headers(**kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "//examples/python/clients/advanced/.venv/lib/python3.13/site-packages/httpcore/_async/http11.py", line 177, in _receive_response_headers
event = await self._receive_event(timeout=timeout)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "//examples/python/clients/advanced/.venv/lib/python3.13/site-packages/httpcore/_async/http11.py", line 217, in _receive_event
data = await self._network_stream.read(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
self.READ_NUM_BYTES, timeout=timeout
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "//examples/python/clients/advanced/.venv/lib/python3.13/site-packages/httpcore/_backends/anyio.py", line 32, in read
with map_exceptions(exc_map):
~~~~~~~~~~~~~~^^^^^^^^^
File "/opt/homebrew/Cellar/[email protected]/3.13.1/Frameworks/Python.framework/Versions/3.13/lib/python3.13/contextlib.py", line 162, in __exit__
self.gen.throw(value)
~~~~~~~~~~~~~~^^^^^^^
File "//examples/python/clients/advanced/.venv/lib/python3.13/site-packages/httpcore/_exceptions.py", line 14, in map_exceptions
raise to_exc(exc) from exc
httpcore.ReadTimeout
while tx was actually successful
There was a problem hiding this comment.
Happened both with and without using TONCENTER API key
There was a problem hiding this comment.
e2e tests likely need similar fix
There was a problem hiding this comment.
Still having the issue that I can't run the examples without increasing the httpx timeout @ArkadiyStena, which seems to be 5s by default
Running the example without TONCENTER API key takes about 26s
Running the example with TONCENTER API key (for both. facilitator and client) still takes about 18s
In contrast, doing an evm payment with the same example takes only ~3s, any ideas where this latency difference comes from? There is the settlement queue but this should only add ~1s
There was a problem hiding this comment.
Do I understand correctly that you’re encountering a 429 error even when using a Toncenter API key for testnet? Probably you’re running many tests in parallel with the same Toncenter API key?
There was a problem hiding this comment.
I am running a single test with the advanced/all_networks examples.
I only get the 429 when running without an API key, but thats happens before the paid request when the client prepares the payload and is retried and eventually succeeds. So thats not the problem.
The issue is the http timeout because the paid request takes too long. With or without API key
| if self._settlement_cache.is_duplicate( | ||
| settlement.settlement_hash, requirements.max_timeout_seconds | ||
| ): |
There was a problem hiding this comment.
Was running some tests with concurrent request using the same payload and not sure if and for what the settlement cache is actually needed.
Seems when I remove this check, duplicate tx are still rejected with invalid_exact_tvm_payload_seqno_mismatch
There was a problem hiding this comment.
This is the scripts I used, similar to what I used to test: https://github.com/coinbase/x402/blob/main/specs/schemes/exact/scheme_exact_svm.md#duplicate-settlement-mitigation-recommended.
from __future__ import annotations
import asyncio
import json
import os
import sys
import time
from typing import Any
import httpx
from dotenv import load_dotenv
from x402 import prefer_network, x402Client
from x402.http import x402HTTPClient
from x402.mechanisms.tvm import (
TVM_MAINNET,
TVM_TESTNET,
WalletV5R1Config,
WalletV5R1MnemonicSigner,
)
from x402.mechanisms.tvm.exact import ExactTvmClientScheme
load_dotenv()
def _validate_environment() -> tuple[str, str, str, int]:
"""Return (tvm_private_key, base_url, endpoint_path, race_count) or exit."""
tvm_private_key = os.getenv("TVM_PRIVATE_KEY")
base_url = os.getenv("RESOURCE_SERVER_URL", "http://localhost:4021")
endpoint_path = os.getenv("ENDPOINT_PATH", "/weather")
race_raw = os.getenv("RACE_COUNT", "4")
if not tvm_private_key:
print("TVM_PRIVATE_KEY is required")
sys.exit(1)
try:
race_count = int(race_raw)
except ValueError:
print("RACE_COUNT must be a positive integer")
sys.exit(1)
if race_count <= 0:
print("RACE_COUNT must be a positive number")
sys.exit(1)
return tvm_private_key, base_url, endpoint_path, race_count
async def main() -> None:
"""Run the TVM race-condition demo: one payment, many parallel requests."""
tvm_private_key, base_url, endpoint_path, race_count = _validate_environment()
url = f"{base_url}{endpoint_path}"
tvm_network = os.getenv("TVM_NETWORK", TVM_TESTNET)
if tvm_network not in {TVM_TESTNET, TVM_MAINNET}:
print(f"Unsupported TVM network: {tvm_network}")
sys.exit(1)
tvm_config = WalletV5R1Config.from_private_key(tvm_network, tvm_private_key)
tvm_config.api_key = os.getenv("TONCENTER_API_KEY")
tvm_config.base_url = os.getenv("TONCENTER_BASE_URL")
tvm_signer = WalletV5R1MnemonicSigner(tvm_config)
tvm_scheme = ExactTvmClientScheme(tvm_signer)
print("TVM Race Condition Vulnerability Demo")
print("=====================================")
print(f"Server: {url}")
print(f"Payer: {tvm_signer.address}")
print(f"Race count: {race_count}")
print("")
client = x402Client()
client.register_policy(prefer_network(tvm_network))
client.register(tvm_network, tvm_scheme)
http_client = x402HTTPClient(client)
httpx_timeout = float(os.getenv("HTTPX_TIMEOUT_SECONDS", "60"))
try:
async with httpx.AsyncClient(timeout=httpx_timeout) as http:
print("Getting 402 response...")
initial = await http.get(url)
if initial.status_code != 402:
msg = f"Expected 402, got {initial.status_code}. Is the server running?"
raise RuntimeError(msg)
print("Got 402 Payment Required")
print("Parsing payment requirements...")
try:
body: Any = initial.json()
except json.JSONDecodeError as e:
msg = "Failed to parse 402 response body as JSON"
raise RuntimeError(msg) from e
payment_required = http_client.get_payment_required_response(
lambda name: initial.headers.get(name),
body,
)
tvm_option = next(
(
a
for a in payment_required.accepts
if str(a.network).startswith("tvm:") and a.scheme == "exact"
),
None,
)
if tvm_option is None:
msg = "No TVM exact payment option found in 402 response"
raise RuntimeError(msg)
print(f"Found TVM exact option on network: {tvm_option.network}")
print(f" Pay to: {tvm_option.pay_to}")
print(f" Amount: {tvm_option.amount}")
print(f" Asset: {tvm_option.asset}")
print("")
print("Building and signing payment transaction once...")
payment_payload = await client.create_payment_payload(payment_required)
payment_headers = http_client.encode_payment_signature_header(payment_payload)
print(f"Firing {race_count} parallel requests with the same payment header...")
async def one_request(index: int) -> dict[str, Any]:
r = await http.get(url, headers=payment_headers)
try:
res_body: Any = r.json()
except json.JSONDecodeError:
res_body = r.text
payment_response: Any
try:
payment_response = http_client.get_payment_settle_response(
r.headers.get
)
except ValueError:
payment_response = None
return {
"index": index,
"status": r.status_code,
"body": res_body,
"payment_response": payment_response,
}
start = time.perf_counter()
results = await asyncio.gather(
*(one_request(i) for i in range(race_count))
)
elapsed_ms = int((time.perf_counter() - start) * 1000)
print("Results:")
print("--------")
succeeded = [r for r in results if r["status"] == 200]
failed = [r for r in results if r["status"] != 200]
for r in results:
pr = r.get("payment_response")
if pr is not None:
pay_str = (
pr.model_dump_json()
if hasattr(pr, "model_dump_json")
else json.dumps(pr, default=str)
)
extra = f" | payment: {pay_str}"
else:
extra = ""
print(
f" Request {r['index']}: HTTP {r['status']} - "
f"{json.dumps(r['body'], default=str)}{extra}"
)
print("")
print(f"Time: {elapsed_ms}ms")
print("")
if len(succeeded) > 1:
print(
f"VULNERABLE: {len(succeeded)}/{race_count} requests succeeded with the same payment."
)
print("The server accepted the same payment multiple times.")
elif len(succeeded) == 1:
print(
f"PROTECTED: Only 1/{race_count} requests succeeded. "
f"{len(failed)} were correctly rejected."
)
else:
codes = ", ".join(str(r["status"]) for r in results)
print(
f"All {race_count} requests failed (status codes: {codes})."
)
print("Check that the server is running and accepting payments.")
finally:
tvm_scheme.close()
if __name__ == "__main__":
try:
asyncio.run(main())
except Exception as ex:
print(str(ex), file=sys.stderr)
sys.exit(1)
There was a problem hiding this comment.
Hey! Are you sure that the key you are using is valid and env variable is correctly set?
There was a problem hiding this comment.
Was running some tests with concurrent request using the same payload and not sure if and for what the settlement cache is actually needed.
Seems when I remove this check, duplicate tx are still rejected with
invalid_exact_tvm_payload_seqno_mismatch
The seqno changes only after the transaction is processed. If a new request with a duplicate settlement hash arrives before the previous one is processed on the blockchain, it will pass the seqno check. Now the risk of this is quite low, since transactions in the blockchain are processed in an average of 0.4 seconds, but nevertheless such a situation can happen.
There was a problem hiding this comment.
Ok thanks for the clarification!
Would it be sufficient to cache for 0.4 sec (or any reasonable fixed amount of a few secs to mins)?
MaxTimeout is a server defined field covering both api execution time + settlement and can be in principle arbitrarily large
There was a problem hiding this comment.
We've reworked it so the entry lifetime no longer depends on MaxTimeout in practice:
- On any failure path (relay build/send, trace-confirmation timeout, per-item verifier rejection) the entry is released right away.
- On success the entry is now also released as soon as the confirmation worker records the on-chain trace, because the W5 wallet's seqno has already advanced.
There was a problem hiding this comment.
OK thanks, thats sounds like a good solution
|
Downstream client compatibility note from the paid MCP side: PR #1944 is a useful signal for TVM/TON exact-payment adoption, especially because it exposes facilitator, account funding, jetton, gas, and settlement-cache behavior that EVM-only clients do not have to handle the same way. I updated AgentPay MCP to fail closed for TVM requirements until that support is implemented deliberately:
Proof:
This is not a request to change PR #1944. It is downstream proof that paid MCP clients should treat TVM as explicit support, not as another string in a generic network allowlist. |
|
|
||
| # Make request using async context manager | ||
| async with x402HttpxClient(client) as http: | ||
| async with x402HttpxClient(client, timeout=10.0) as http: |
There was a problem hiding this comment.
Thanks for the update @ArkadiyStena, lets increase to 30s as requests without API key take ~20s
| async with x402HttpxClient(client, timeout=10.0) as http: | |
| async with x402HttpxClient(client, timeout=30.0) as http: |
|
Please increase then timeout in the client example to 30s and rebase against main to resolve merge conflicts @ArkadiyStena, then we can get this in |
Done |
- add TVM exact client, server, and facilitator implementations - add TVM signers, codecs, provider, and settlement helpers - add unit and integration coverage for TON testnet/mainnet flows - add Python examples and e2e wiring for TVM
Add TVM support to the Python e2e clients and servers, switch TVM usage to direct `ExactTvm*Scheme` registration after removing the old register helpers, and refresh docs/examples for testnet setup. Also reject exact TVM settlements with excessively large attached TON amounts and cover the new validation with tests.
On-chain seqno advances after a successful relay, so any retry of the same settlement BOC will fail at the W5 contract's seqno check regardless of the dedup reservation. Drop the entry as soon as the confirmation worker records success, so the invariant becomes "every is_duplicate=False is paired with a release" and TTL pruning is left as a crash safety-net only.
|
Seems sth went wrong with the rebase @ArkadiyStena, there are still merge conflicts (probably due to changes in e2e tests in #2230 and #2109) and the PR shows changes in over 1000 files now |
| } | ||
|
|
||
| // Load environment variables | ||
| config({ path: '.env-local' }); |
|
Thanks @ArkadiyStena, could you please fix the failing unit tests? |
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
phdargen
left a comment
There was a problem hiding this comment.
LGTM, thanks @ArkadiyStena 🚀
