t402 1.9.0__py3-none-any.whl → 1.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. t402/__init__.py +2 -1
  2. t402/a2a/__init__.py +73 -0
  3. t402/a2a/helpers.py +158 -0
  4. t402/a2a/types.py +145 -0
  5. t402/bridge/client.py +13 -5
  6. t402/bridge/constants.py +4 -2
  7. t402/bridge/router.py +1 -1
  8. t402/bridge/scan.py +3 -1
  9. t402/chains.py +268 -1
  10. t402/cli.py +31 -9
  11. t402/common.py +2 -0
  12. t402/cosmos_paywall_template.py +2 -0
  13. t402/django/__init__.py +42 -0
  14. t402/django/middleware.py +596 -0
  15. t402/encoding.py +9 -3
  16. t402/erc4337/accounts.py +56 -51
  17. t402/erc4337/bundlers.py +105 -99
  18. t402/erc4337/paymasters.py +100 -109
  19. t402/erc4337/types.py +39 -26
  20. t402/errors.py +213 -0
  21. t402/evm_paywall_template.py +1 -1
  22. t402/facilitator.py +125 -0
  23. t402/fastapi/middleware.py +1 -3
  24. t402/mcp/constants.py +3 -6
  25. t402/mcp/server.py +501 -84
  26. t402/mcp/web3_utils.py +493 -0
  27. t402/multisig/__init__.py +120 -0
  28. t402/multisig/constants.py +54 -0
  29. t402/multisig/safe.py +441 -0
  30. t402/multisig/signature.py +228 -0
  31. t402/multisig/transaction.py +238 -0
  32. t402/multisig/types.py +108 -0
  33. t402/multisig/utils.py +77 -0
  34. t402/near_paywall_template.py +2 -0
  35. t402/networks.py +34 -1
  36. t402/paywall.py +1 -3
  37. t402/schemes/__init__.py +143 -0
  38. t402/schemes/aptos/__init__.py +70 -0
  39. t402/schemes/aptos/constants.py +349 -0
  40. t402/schemes/aptos/exact_direct/__init__.py +44 -0
  41. t402/schemes/aptos/exact_direct/client.py +202 -0
  42. t402/schemes/aptos/exact_direct/facilitator.py +426 -0
  43. t402/schemes/aptos/exact_direct/server.py +272 -0
  44. t402/schemes/aptos/types.py +237 -0
  45. t402/schemes/cosmos/__init__.py +114 -0
  46. t402/schemes/cosmos/constants.py +211 -0
  47. t402/schemes/cosmos/exact_direct/__init__.py +21 -0
  48. t402/schemes/cosmos/exact_direct/client.py +198 -0
  49. t402/schemes/cosmos/exact_direct/facilitator.py +493 -0
  50. t402/schemes/cosmos/exact_direct/server.py +315 -0
  51. t402/schemes/cosmos/types.py +501 -0
  52. t402/schemes/evm/__init__.py +46 -1
  53. t402/schemes/evm/exact/__init__.py +11 -0
  54. t402/schemes/evm/exact/client.py +3 -1
  55. t402/schemes/evm/exact/facilitator.py +894 -0
  56. t402/schemes/evm/exact/server.py +1 -1
  57. t402/schemes/evm/exact_legacy/__init__.py +38 -0
  58. t402/schemes/evm/exact_legacy/client.py +291 -0
  59. t402/schemes/evm/exact_legacy/facilitator.py +777 -0
  60. t402/schemes/evm/exact_legacy/server.py +231 -0
  61. t402/schemes/evm/upto/__init__.py +12 -0
  62. t402/schemes/evm/upto/client.py +6 -2
  63. t402/schemes/evm/upto/facilitator.py +625 -0
  64. t402/schemes/evm/upto/server.py +243 -0
  65. t402/schemes/evm/upto/types.py +3 -1
  66. t402/schemes/interfaces.py +6 -2
  67. t402/schemes/near/__init__.py +137 -0
  68. t402/schemes/near/constants.py +189 -0
  69. t402/schemes/near/exact_direct/__init__.py +21 -0
  70. t402/schemes/near/exact_direct/client.py +204 -0
  71. t402/schemes/near/exact_direct/facilitator.py +455 -0
  72. t402/schemes/near/exact_direct/server.py +303 -0
  73. t402/schemes/near/types.py +419 -0
  74. t402/schemes/near/upto/__init__.py +54 -0
  75. t402/schemes/near/upto/types.py +272 -0
  76. t402/schemes/polkadot/__init__.py +72 -0
  77. t402/schemes/polkadot/constants.py +155 -0
  78. t402/schemes/polkadot/exact_direct/__init__.py +43 -0
  79. t402/schemes/polkadot/exact_direct/client.py +235 -0
  80. t402/schemes/polkadot/exact_direct/facilitator.py +428 -0
  81. t402/schemes/polkadot/exact_direct/server.py +292 -0
  82. t402/schemes/polkadot/types.py +385 -0
  83. t402/schemes/registry.py +6 -2
  84. t402/schemes/stacks/__init__.py +68 -0
  85. t402/schemes/stacks/constants.py +122 -0
  86. t402/schemes/stacks/exact_direct/__init__.py +43 -0
  87. t402/schemes/stacks/exact_direct/client.py +222 -0
  88. t402/schemes/stacks/exact_direct/facilitator.py +424 -0
  89. t402/schemes/stacks/exact_direct/server.py +292 -0
  90. t402/schemes/stacks/types.py +380 -0
  91. t402/schemes/svm/__init__.py +44 -0
  92. t402/schemes/svm/exact/__init__.py +35 -0
  93. t402/schemes/svm/exact/client.py +23 -0
  94. t402/schemes/svm/exact/facilitator.py +24 -0
  95. t402/schemes/svm/exact/server.py +20 -0
  96. t402/schemes/svm/upto/__init__.py +23 -0
  97. t402/schemes/svm/upto/types.py +193 -0
  98. t402/schemes/tezos/__init__.py +84 -0
  99. t402/schemes/tezos/constants.py +372 -0
  100. t402/schemes/tezos/exact_direct/__init__.py +22 -0
  101. t402/schemes/tezos/exact_direct/client.py +226 -0
  102. t402/schemes/tezos/exact_direct/facilitator.py +491 -0
  103. t402/schemes/tezos/exact_direct/server.py +277 -0
  104. t402/schemes/tezos/types.py +220 -0
  105. t402/schemes/ton/__init__.py +24 -2
  106. t402/schemes/ton/exact/__init__.py +7 -0
  107. t402/schemes/ton/exact/facilitator.py +730 -0
  108. t402/schemes/ton/exact/server.py +1 -1
  109. t402/schemes/ton/upto/__init__.py +31 -0
  110. t402/schemes/ton/upto/types.py +215 -0
  111. t402/schemes/tron/__init__.py +28 -2
  112. t402/schemes/tron/exact/__init__.py +9 -0
  113. t402/schemes/tron/exact/facilitator.py +673 -0
  114. t402/schemes/tron/exact/server.py +1 -1
  115. t402/schemes/tron/upto/__init__.py +30 -0
  116. t402/schemes/tron/upto/types.py +213 -0
  117. t402/stacks_paywall_template.py +2 -0
  118. t402/starlette/__init__.py +38 -0
  119. t402/starlette/middleware.py +522 -0
  120. t402/svm.py +45 -11
  121. t402/svm_paywall_template.py +1 -1
  122. t402/ton.py +6 -2
  123. t402/ton_paywall_template.py +1 -192
  124. t402/tron.py +2 -0
  125. t402/tron_paywall_template.py +2 -0
  126. t402/types.py +103 -3
  127. t402/wdk/chains.py +1 -1
  128. t402/wdk/errors.py +15 -5
  129. t402/wdk/signer.py +11 -2
  130. {t402-1.9.0.dist-info → t402-1.10.0.dist-info}/METADATA +42 -1
  131. t402-1.10.0.dist-info/RECORD +156 -0
  132. t402-1.9.0.dist-info/RECORD +0 -72
  133. {t402-1.9.0.dist-info → t402-1.10.0.dist-info}/WHEEL +0 -0
  134. {t402-1.9.0.dist-info → t402-1.10.0.dist-info}/entry_points.txt +0 -0
t402/mcp/server.py CHANGED
@@ -9,13 +9,21 @@ from typing import Any, Optional, TextIO
9
9
 
10
10
  from .constants import (
11
11
  ALL_NETWORKS,
12
+ CHAIN_IDS,
13
+ LAYERZERO_ENDPOINT_IDS,
12
14
  LAYERZERO_SCAN_URL,
15
+ NATIVE_DECIMALS,
13
16
  NATIVE_SYMBOLS,
17
+ TOKEN_DECIMALS,
18
+ USDT0_ADDRESSES,
19
+ format_token_amount,
14
20
  get_explorer_tx_url,
21
+ get_rpc_url,
15
22
  get_token_address,
16
23
  is_bridgeable_chain,
17
24
  is_gasless_network,
18
25
  is_valid_network,
26
+ parse_token_amount,
19
27
  )
20
28
  from .tools import get_tool_definitions
21
29
  from .types import (
@@ -30,6 +38,27 @@ from .types import (
30
38
  ServerConfig,
31
39
  ToolResult,
32
40
  )
41
+ from .web3_utils import (
42
+ execute_bridge_send,
43
+ extract_message_guid_from_receipt,
44
+ format_wei_to_ether,
45
+ get_erc20_balance,
46
+ get_native_balance,
47
+ get_web3_provider,
48
+ quote_bridge_fee,
49
+ run_sync_in_executor,
50
+ transfer_erc20,
51
+ )
52
+
53
+
54
+ # Estimated bridge times in seconds per destination chain
55
+ ESTIMATED_BRIDGE_TIMES: dict[str, int] = {
56
+ "ethereum": 900, # 15 minutes
57
+ "arbitrum": 300, # 5 minutes
58
+ "ink": 300,
59
+ "berachain": 300,
60
+ "unichain": 300,
61
+ }
33
62
 
34
63
 
35
64
  class T402McpServer:
@@ -170,59 +199,167 @@ class T402McpServer:
170
199
  else:
171
200
  result = self._error_result(f"Unknown tool: {tool_name}")
172
201
 
173
- return {"content": [asdict(c) for c in result.content], "isError": result.isError}
202
+ return {
203
+ "content": [asdict(c) for c in result.content],
204
+ "isError": result.isError,
205
+ }
206
+
207
+ def _get_web3(self, network: str) -> Any:
208
+ """Get a Web3 provider for the given network.
174
209
 
175
- async def _handle_get_balance(self, args: dict[str, Any]) -> ToolResult:
176
- """Handle t402/getBalance tool."""
210
+ Args:
211
+ network: Network name
212
+
213
+ Returns:
214
+ Web3 instance
215
+ """
216
+ rpc_url = get_rpc_url(self.config, network)
217
+ if not rpc_url:
218
+ raise ValueError(f"No RPC URL configured for {network}")
219
+ return get_web3_provider(rpc_url)
220
+
221
+ async def _fetch_single_balance(
222
+ self, address: str, network: str
223
+ ) -> NetworkBalance:
224
+ """Fetch balance for a single network, returning a NetworkBalance.
225
+
226
+ Args:
227
+ address: Wallet address to check
228
+ network: Network name
229
+
230
+ Returns:
231
+ NetworkBalance with native and token balances
232
+ """
177
233
  try:
178
- _address = args.get("address", "") # noqa: F841 - reserved for future use
179
- network = args.get("network", "")
234
+ w3 = self._get_web3(network)
180
235
 
181
- if not is_valid_network(network):
182
- return self._error_result(f"Invalid network: {network}")
236
+ # Get native balance
237
+ native_raw = await run_sync_in_executor(
238
+ get_native_balance, w3, address
239
+ )
240
+ native_formatted = format_token_amount(native_raw, NATIVE_DECIMALS)
183
241
 
184
- # In demo mode or without web3, return placeholder data
185
242
  result = NetworkBalance(
186
243
  network=network,
187
244
  native=BalanceInfo(
188
245
  token=NATIVE_SYMBOLS.get(network, "ETH"),
189
- balance="0.0",
190
- raw="0",
246
+ balance=native_formatted,
247
+ raw=str(native_raw),
191
248
  ),
192
249
  tokens=[],
193
250
  )
194
251
 
252
+ # Get token balances for USDC, USDT, USDT0
253
+ tokens_to_check = ["USDC", "USDT", "USDT0"]
254
+ for token_name in tokens_to_check:
255
+ token_addr = get_token_address(network, token_name)
256
+ if not token_addr:
257
+ continue
258
+
259
+ try:
260
+ balance = await run_sync_in_executor(
261
+ get_erc20_balance, w3, token_addr, address
262
+ )
263
+ if balance > 0:
264
+ result.tokens.append(
265
+ BalanceInfo(
266
+ token=token_name,
267
+ balance=format_token_amount(balance, TOKEN_DECIMALS),
268
+ raw=str(balance),
269
+ )
270
+ )
271
+ except Exception:
272
+ # Skip token if query fails
273
+ continue
274
+
275
+ return result
276
+
277
+ except Exception as e:
278
+ return NetworkBalance(
279
+ network=network,
280
+ error=str(e),
281
+ )
282
+
283
+ async def _handle_get_balance(self, args: dict[str, Any]) -> ToolResult:
284
+ """Handle t402/getBalance tool.
285
+
286
+ Connects to the network via RPC, queries native and ERC-20 token
287
+ balances, and returns formatted results. Falls back to demo mode
288
+ when no RPC is available.
289
+ """
290
+ try:
291
+ address = args.get("address", "")
292
+ network = args.get("network", "")
293
+
294
+ if not is_valid_network(network):
295
+ return self._error_result(f"Invalid network: {network}")
296
+
297
+ # Demo mode returns placeholder data
298
+ if self.config.demo_mode:
299
+ result = NetworkBalance(
300
+ network=network,
301
+ native=BalanceInfo(
302
+ token=NATIVE_SYMBOLS.get(network, "ETH"),
303
+ balance="0.0",
304
+ raw="0",
305
+ ),
306
+ tokens=[],
307
+ )
308
+ return self._text_result(self._format_balance_result(result))
309
+
310
+ # Real mode: query blockchain
311
+ result = await self._fetch_single_balance(address, network)
195
312
  return self._text_result(self._format_balance_result(result))
196
313
 
197
314
  except Exception as e:
198
315
  return self._error_result(str(e))
199
316
 
200
317
  async def _handle_get_all_balances(self, args: dict[str, Any]) -> ToolResult:
201
- """Handle t402/getAllBalances tool."""
318
+ """Handle t402/getAllBalances tool.
319
+
320
+ Queries all supported networks in parallel using asyncio.gather,
321
+ reusing _fetch_single_balance per network. Handles per-network
322
+ errors gracefully.
323
+ """
202
324
  try:
203
- _address = args.get("address", "") # noqa: F841 - reserved for future use
204
-
205
- results = []
206
- for network in ALL_NETWORKS:
207
- results.append(
208
- NetworkBalance(
209
- network=network,
210
- native=BalanceInfo(
211
- token=NATIVE_SYMBOLS.get(network, "ETH"),
212
- balance="0.0",
213
- raw="0",
214
- ),
215
- tokens=[],
325
+ address = args.get("address", "")
326
+
327
+ # Demo mode returns placeholder data
328
+ if self.config.demo_mode:
329
+ results = []
330
+ for network in ALL_NETWORKS:
331
+ results.append(
332
+ NetworkBalance(
333
+ network=network,
334
+ native=BalanceInfo(
335
+ token=NATIVE_SYMBOLS.get(network, "ETH"),
336
+ balance="0.0",
337
+ raw="0",
338
+ ),
339
+ tokens=[],
340
+ )
216
341
  )
217
- )
342
+ return self._text_result(self._format_all_balances_result(results))
218
343
 
219
- return self._text_result(self._format_all_balances_result(results))
344
+ # Real mode: query all networks in parallel
345
+ tasks = [
346
+ self._fetch_single_balance(address, network)
347
+ for network in ALL_NETWORKS
348
+ ]
349
+ results = await asyncio.gather(*tasks)
350
+ return self._text_result(self._format_all_balances_result(list(results)))
220
351
 
221
352
  except Exception as e:
222
353
  return self._error_result(str(e))
223
354
 
224
355
  async def _handle_pay(self, args: dict[str, Any]) -> ToolResult:
225
- """Handle t402/pay tool."""
356
+ """Handle t402/pay tool.
357
+
358
+ Validates token support on network, parses amount with correct
359
+ decimals, builds and signs an ERC-20 transfer transaction via web3,
360
+ sends and waits for receipt, and returns the tx hash and explorer URL.
361
+ Falls back to demo mode when no private key is configured.
362
+ """
226
363
  try:
227
364
  to = args.get("to", "")
228
365
  amount = args.get("amount", "")
@@ -255,18 +392,60 @@ class T402McpServer:
255
392
  )
256
393
  return self._text_result(self._format_payment_result(result))
257
394
 
258
- return self._error_result("Real transactions require private key configuration")
395
+ # Real mode: execute ERC-20 transfer
396
+ raw_amount = parse_token_amount(amount, TOKEN_DECIMALS)
397
+ w3 = self._get_web3(network)
398
+
399
+ receipt = await run_sync_in_executor(
400
+ transfer_erc20,
401
+ w3,
402
+ self.config.private_key,
403
+ token_addr,
404
+ to,
405
+ raw_amount,
406
+ )
407
+
408
+ tx_hash = receipt["transactionHash"].hex()
409
+ if not tx_hash.startswith("0x"):
410
+ tx_hash = "0x" + tx_hash
411
+
412
+ # Derive from_address from private key
413
+ from_address = w3.eth.account.from_key(
414
+ self.config.private_key
415
+ ).address
416
+
417
+ result = PaymentResult(
418
+ tx_hash=tx_hash,
419
+ from_address=from_address,
420
+ to=to,
421
+ amount=amount,
422
+ token=token,
423
+ network=network,
424
+ explorer_url=get_explorer_tx_url(network, tx_hash),
425
+ )
426
+ return self._text_result(self._format_payment_result(result))
259
427
 
260
428
  except Exception as e:
261
429
  return self._error_result(str(e))
262
430
 
263
431
  async def _handle_pay_gasless(self, args: dict[str, Any]) -> ToolResult:
264
- """Handle t402/payGasless tool."""
432
+ """Handle t402/payGasless tool.
433
+
434
+ Builds an ERC-4337 UserOperation using the existing t402.erc4337
435
+ module, submits it to the bundler, polls for receipt, and returns
436
+ the transaction hash. Falls back to demo mode when bundler is
437
+ not configured.
438
+ """
265
439
  try:
266
440
  network = args.get("network", "")
441
+ to = args.get("to", "")
442
+ amount = args.get("amount", "")
443
+ token = args.get("token", "")
267
444
 
268
445
  if not is_gasless_network(network):
269
- return self._error_result(f"Network {network} does not support gasless payments")
446
+ return self._error_result(
447
+ f"Network {network} does not support gasless payments"
448
+ )
270
449
 
271
450
  if not self.config.bundler_url and not self.config.demo_mode:
272
451
  return self._error_result(
@@ -278,41 +457,174 @@ class T402McpServer:
278
457
  result = PaymentResult(
279
458
  tx_hash="0x" + "0" * 64 + "_gasless_demo",
280
459
  from_address="0x" + "0" * 40,
281
- to=args.get("to", ""),
282
- amount=args.get("amount", ""),
283
- token=args.get("token", ""),
460
+ to=to,
461
+ amount=amount,
462
+ token=token,
284
463
  network=network,
285
464
  explorer_url=get_explorer_tx_url(network, "0x_demo"),
286
465
  demo_mode=True,
287
466
  )
288
467
  return self._text_result(self._format_payment_result(result))
289
468
 
290
- return self._error_result("Gasless payments require bundler configuration")
469
+ # Real mode: build and submit ERC-4337 UserOperation
470
+ token_addr = get_token_address(network, token)
471
+ if not token_addr:
472
+ return self._error_result(f"Token {token} not supported on {network}")
473
+
474
+ if not self.config.private_key:
475
+ return self._error_result(
476
+ "Private key not configured for gasless payments"
477
+ )
478
+
479
+ raw_amount = parse_token_amount(amount, TOKEN_DECIMALS)
480
+ chain_id = CHAIN_IDS.get(network)
481
+ if not chain_id:
482
+ return self._error_result(f"Chain ID not found for {network}")
483
+
484
+ # Use ERC-4337 module to build and submit UserOperation
485
+ from t402.erc4337 import (
486
+ GenericBundlerClient,
487
+ BundlerConfig,
488
+ ENTRYPOINT_V07_ADDRESS,
489
+ UserOperation,
490
+ )
491
+ from web3 import Web3
492
+
493
+ w3 = self._get_web3(network)
494
+
495
+ # Encode the ERC-20 transfer call data
496
+ transfer_selector = Web3.keccak(text="transfer(address,uint256)")[:4]
497
+ to_padded = bytes.fromhex(to[2:]).rjust(32, b"\x00")
498
+ amount_padded = raw_amount.to_bytes(32, "big")
499
+ call_data = transfer_selector + to_padded + amount_padded
500
+
501
+ account = w3.eth.account.from_key(self.config.private_key)
502
+ from_address = account.address
503
+
504
+ nonce = await run_sync_in_executor(
505
+ w3.eth.get_transaction_count, from_address
506
+ )
507
+ gas_price = await run_sync_in_executor(
508
+ lambda: w3.eth.gas_price
509
+ )
510
+
511
+ user_op = UserOperation(
512
+ sender=from_address,
513
+ nonce=nonce,
514
+ call_data=call_data,
515
+ verification_gas_limit=150000,
516
+ call_gas_limit=100000,
517
+ pre_verification_gas=50000,
518
+ max_fee_per_gas=gas_price,
519
+ max_priority_fee_per_gas=gas_price // 10,
520
+ )
521
+
522
+ bundler = GenericBundlerClient(
523
+ BundlerConfig(
524
+ bundler_url=self.config.bundler_url,
525
+ chain_id=chain_id,
526
+ entry_point=ENTRYPOINT_V07_ADDRESS,
527
+ )
528
+ )
529
+
530
+ # Submit UserOperation
531
+ user_op_hash = await run_sync_in_executor(
532
+ bundler.send_user_operation, user_op
533
+ )
534
+
535
+ # Poll for receipt
536
+ receipt = await run_sync_in_executor(
537
+ bundler.wait_for_receipt, user_op_hash, 60.0, 2.0
538
+ )
539
+
540
+ tx_hash = receipt.transaction_hash or user_op_hash
541
+
542
+ pay_result = PaymentResult(
543
+ tx_hash=tx_hash,
544
+ from_address=from_address,
545
+ to=to,
546
+ amount=amount,
547
+ token=token,
548
+ network=network,
549
+ explorer_url=get_explorer_tx_url(network, tx_hash),
550
+ )
551
+ return self._text_result(self._format_payment_result(pay_result))
291
552
 
292
553
  except Exception as e:
293
554
  return self._error_result(str(e))
294
555
 
295
556
  async def _handle_get_bridge_fee(self, args: dict[str, Any]) -> ToolResult:
296
- """Handle t402/getBridgeFee tool."""
557
+ """Handle t402/getBridgeFee tool.
558
+
559
+ Queries the LayerZero OFT contract's quoteSend function to get
560
+ the actual bridge fee estimate. Falls back to demo mode when
561
+ no RPC is available.
562
+ """
297
563
  try:
298
564
  from_chain = args.get("fromChain", "")
299
565
  to_chain = args.get("toChain", "")
300
566
  amount = args.get("amount", "")
567
+ recipient = args.get("recipient", "")
301
568
 
302
569
  if not is_bridgeable_chain(from_chain):
303
- return self._error_result(f"Chain {from_chain} does not support USDT0 bridging")
570
+ return self._error_result(
571
+ f"Chain {from_chain} does not support USDT0 bridging"
572
+ )
304
573
  if not is_bridgeable_chain(to_chain):
305
- return self._error_result(f"Chain {to_chain} does not support USDT0 bridging")
574
+ return self._error_result(
575
+ f"Chain {to_chain} does not support USDT0 bridging"
576
+ )
306
577
  if from_chain == to_chain:
307
- return self._error_result("Source and destination chains must be different")
578
+ return self._error_result(
579
+ "Source and destination chains must be different"
580
+ )
581
+
582
+ # Demo mode returns estimated fee
583
+ if self.config.demo_mode:
584
+ result = BridgeFeeResult(
585
+ native_fee="0.001",
586
+ native_symbol=NATIVE_SYMBOLS.get(from_chain, "ETH"),
587
+ from_chain=from_chain,
588
+ to_chain=to_chain,
589
+ amount=amount,
590
+ estimated_time=ESTIMATED_BRIDGE_TIMES.get(to_chain, 300),
591
+ )
592
+ return self._text_result(self._format_bridge_fee_result(result))
593
+
594
+ # Real mode: query OFT contract
595
+ oft_address = USDT0_ADDRESSES.get(from_chain)
596
+ if not oft_address:
597
+ return self._error_result(f"USDT0 not found on {from_chain}")
598
+
599
+ dst_eid = LAYERZERO_ENDPOINT_IDS.get(to_chain)
600
+ if not dst_eid:
601
+ return self._error_result(
602
+ f"LayerZero endpoint ID not found for {to_chain}"
603
+ )
604
+
605
+ raw_amount = parse_token_amount(amount, TOKEN_DECIMALS)
606
+ w3 = self._get_web3(from_chain)
607
+
608
+ native_fee, _lz_fee = await run_sync_in_executor(
609
+ quote_bridge_fee,
610
+ w3,
611
+ oft_address,
612
+ dst_eid,
613
+ recipient,
614
+ raw_amount,
615
+ raw_amount, # minAmount = amount for quote (no slippage)
616
+ )
617
+
618
+ native_symbol = NATIVE_SYMBOLS.get(from_chain, "ETH")
619
+ fee_formatted = format_wei_to_ether(native_fee)
308
620
 
309
621
  result = BridgeFeeResult(
310
- native_fee="0.001",
311
- native_symbol=NATIVE_SYMBOLS.get(from_chain, "ETH"),
622
+ native_fee=f"{fee_formatted} {native_symbol}",
623
+ native_symbol=native_symbol,
312
624
  from_chain=from_chain,
313
625
  to_chain=to_chain,
314
626
  amount=amount,
315
- estimated_time=300,
627
+ estimated_time=ESTIMATED_BRIDGE_TIMES.get(to_chain, 300),
316
628
  )
317
629
  return self._text_result(self._format_bridge_fee_result(result))
318
630
 
@@ -320,18 +632,32 @@ class T402McpServer:
320
632
  return self._error_result(str(e))
321
633
 
322
634
  async def _handle_bridge(self, args: dict[str, Any]) -> ToolResult:
323
- """Handle t402/bridge tool."""
635
+ """Handle t402/bridge tool.
636
+
637
+ Executes a LayerZero OFT send transaction to bridge USDT0
638
+ between chains. Gets a fee quote, executes the send with
639
+ a 10% fee buffer, and extracts the message GUID from the
640
+ OFTSent event logs. Falls back to demo mode when no private
641
+ key is configured.
642
+ """
324
643
  try:
325
644
  from_chain = args.get("fromChain", "")
326
645
  to_chain = args.get("toChain", "")
327
646
  amount = args.get("amount", "")
647
+ recipient = args.get("recipient", "")
328
648
 
329
649
  if not is_bridgeable_chain(from_chain):
330
- return self._error_result(f"Chain {from_chain} does not support USDT0 bridging")
650
+ return self._error_result(
651
+ f"Chain {from_chain} does not support USDT0 bridging"
652
+ )
331
653
  if not is_bridgeable_chain(to_chain):
332
- return self._error_result(f"Chain {to_chain} does not support USDT0 bridging")
654
+ return self._error_result(
655
+ f"Chain {to_chain} does not support USDT0 bridging"
656
+ )
333
657
  if from_chain == to_chain:
334
- return self._error_result("Source and destination chains must be different")
658
+ return self._error_result(
659
+ "Source and destination chains must be different"
660
+ )
335
661
 
336
662
  if not self.config.private_key and not self.config.demo_mode:
337
663
  return self._error_result(
@@ -349,12 +675,91 @@ class T402McpServer:
349
675
  amount=amount,
350
676
  explorer_url=get_explorer_tx_url(from_chain, "0x_demo"),
351
677
  tracking_url=LAYERZERO_SCAN_URL + demo_guid,
352
- estimated_time=300,
678
+ estimated_time=ESTIMATED_BRIDGE_TIMES.get(to_chain, 300),
353
679
  demo_mode=True,
354
680
  )
355
681
  return self._text_result(self._format_bridge_result(result))
356
682
 
357
- return self._error_result("Bridge functionality requires private key configuration")
683
+ # Real mode: execute LayerZero OFT bridge
684
+ oft_address = USDT0_ADDRESSES.get(from_chain)
685
+ if not oft_address:
686
+ return self._error_result(f"USDT0 not found on {from_chain}")
687
+
688
+ dst_eid = LAYERZERO_ENDPOINT_IDS.get(to_chain)
689
+ if not dst_eid:
690
+ return self._error_result(
691
+ f"LayerZero endpoint ID not found for {to_chain}"
692
+ )
693
+
694
+ raw_amount = parse_token_amount(amount, TOKEN_DECIMALS)
695
+
696
+ # Calculate min amount with 0.5% slippage
697
+ min_amount = raw_amount - (raw_amount * 50) // 10000
698
+
699
+ w3 = self._get_web3(from_chain)
700
+
701
+ # Get fee quote
702
+ native_fee, _lz_fee = await run_sync_in_executor(
703
+ quote_bridge_fee,
704
+ w3,
705
+ oft_address,
706
+ dst_eid,
707
+ recipient,
708
+ raw_amount,
709
+ min_amount,
710
+ )
711
+
712
+ # Add 10% buffer to fee
713
+ native_fee_with_buffer = (native_fee * 110) // 100
714
+
715
+ # Check USDT0 balance
716
+ balance = await run_sync_in_executor(
717
+ get_erc20_balance, w3, oft_address,
718
+ w3.eth.account.from_key(self.config.private_key).address,
719
+ )
720
+ if balance < raw_amount:
721
+ return self._error_result(
722
+ f"Insufficient USDT0 balance: have {format_token_amount(balance, TOKEN_DECIMALS)}, "
723
+ f"need {amount}"
724
+ )
725
+
726
+ # Execute bridge send
727
+ receipt = await run_sync_in_executor(
728
+ execute_bridge_send,
729
+ w3,
730
+ self.config.private_key,
731
+ oft_address,
732
+ dst_eid,
733
+ recipient,
734
+ raw_amount,
735
+ min_amount,
736
+ native_fee_with_buffer,
737
+ )
738
+
739
+ tx_hash = receipt["transactionHash"].hex()
740
+ if not tx_hash.startswith("0x"):
741
+ tx_hash = "0x" + tx_hash
742
+
743
+ # Extract message GUID from logs
744
+ message_guid = extract_message_guid_from_receipt(receipt)
745
+ if not message_guid:
746
+ return self._error_result(
747
+ "Bridge transaction succeeded but failed to extract message GUID from logs"
748
+ )
749
+
750
+ estimated_time = ESTIMATED_BRIDGE_TIMES.get(to_chain, 300)
751
+
752
+ bridge_result = BridgeResultData(
753
+ tx_hash=tx_hash,
754
+ message_guid=message_guid,
755
+ from_chain=from_chain,
756
+ to_chain=to_chain,
757
+ amount=amount,
758
+ explorer_url=get_explorer_tx_url(from_chain, tx_hash),
759
+ tracking_url=LAYERZERO_SCAN_URL + message_guid,
760
+ estimated_time=estimated_time,
761
+ )
762
+ return self._text_result(self._format_bridge_result(bridge_result))
358
763
 
359
764
  except Exception as e:
360
765
  return self._error_result(str(e))
@@ -402,13 +807,15 @@ class T402McpServer:
402
807
  for result in results:
403
808
  if result.error:
404
809
  lines.append(f"### {result.network}")
405
- lines.append(f" {result.error}")
810
+ lines.append(f"Error: {result.error}")
406
811
  lines.append("")
407
812
  continue
408
813
 
409
814
  lines.append(f"### {result.network}")
410
815
  if result.native:
411
- lines.append(f"- Native ({result.native.token}): {result.native.balance}")
816
+ lines.append(
817
+ f"- Native ({result.native.token}): {result.native.balance}"
818
+ )
412
819
  for token in result.tokens:
413
820
  lines.append(f"- {token.token}: {token.balance}")
414
821
  lines.append("")
@@ -420,58 +827,68 @@ class T402McpServer:
420
827
  lines = []
421
828
 
422
829
  if result.demo_mode:
423
- lines.extend([
424
- "## Payment (Demo Mode)",
425
- "",
426
- "⚠️ This is a simulated transaction. No actual tokens were transferred.",
427
- "",
428
- ])
830
+ lines.extend(
831
+ [
832
+ "## Payment (Demo Mode)",
833
+ "",
834
+ "This is a simulated transaction. No actual tokens were transferred.",
835
+ "",
836
+ ]
837
+ )
429
838
  else:
430
839
  lines.extend(["## Payment Successful", ""])
431
840
 
432
- lines.extend([
433
- f"- **Amount:** {result.amount} {result.token}",
434
- f"- **To:** {result.to}",
435
- f"- **Network:** {result.network}",
436
- f"- **Transaction:** [{self._truncate_hash(result.tx_hash)}]({result.explorer_url})",
437
- ])
841
+ lines.extend(
842
+ [
843
+ f"- **Amount:** {result.amount} {result.token}",
844
+ f"- **To:** {result.to}",
845
+ f"- **Network:** {result.network}",
846
+ f"- **Transaction:** [{self._truncate_hash(result.tx_hash)}]({result.explorer_url})",
847
+ ]
848
+ )
438
849
 
439
850
  return "\n".join(lines)
440
851
 
441
852
  def _format_bridge_fee_result(self, result: BridgeFeeResult) -> str:
442
853
  """Format bridge fee result as markdown."""
443
- return "\n".join([
444
- "## Bridge Fee Quote",
445
- "",
446
- f"- **From:** {result.from_chain}",
447
- f"- **To:** {result.to_chain}",
448
- f"- **Amount:** {result.amount} USDT0",
449
- f"- **Fee:** {result.native_fee} {result.native_symbol}",
450
- f"- **Estimated Time:** ~{result.estimated_time} seconds",
451
- ])
854
+ return "\n".join(
855
+ [
856
+ "## Bridge Fee Quote",
857
+ "",
858
+ f"- **From:** {result.from_chain}",
859
+ f"- **To:** {result.to_chain}",
860
+ f"- **Amount:** {result.amount} USDT0",
861
+ f"- **Fee:** {result.native_fee} {result.native_symbol}",
862
+ f"- **Estimated Time:** ~{result.estimated_time} seconds",
863
+ ]
864
+ )
452
865
 
453
866
  def _format_bridge_result(self, result: BridgeResultData) -> str:
454
867
  """Format bridge result as markdown."""
455
868
  lines = []
456
869
 
457
870
  if result.demo_mode:
458
- lines.extend([
459
- "## Bridge (Demo Mode)",
460
- "",
461
- "⚠️ This is a simulated bridge. No actual tokens were transferred.",
462
- "",
463
- ])
871
+ lines.extend(
872
+ [
873
+ "## Bridge (Demo Mode)",
874
+ "",
875
+ "This is a simulated bridge. No actual tokens were transferred.",
876
+ "",
877
+ ]
878
+ )
464
879
  else:
465
880
  lines.extend(["## Bridge Initiated", ""])
466
881
 
467
- lines.extend([
468
- f"- **Amount:** {result.amount} USDT0",
469
- f"- **From:** {result.from_chain}",
470
- f"- **To:** {result.to_chain}",
471
- f"- **Transaction:** [{self._truncate_hash(result.tx_hash)}]({result.explorer_url})",
472
- f"- **Track:** [LayerZero Scan]({result.tracking_url})",
473
- f"- **Estimated Delivery:** ~{result.estimated_time} seconds",
474
- ])
882
+ lines.extend(
883
+ [
884
+ f"- **Amount:** {result.amount} USDT0",
885
+ f"- **From:** {result.from_chain}",
886
+ f"- **To:** {result.to_chain}",
887
+ f"- **Transaction:** [{self._truncate_hash(result.tx_hash)}]({result.explorer_url})",
888
+ f"- **Track:** [LayerZero Scan]({result.tracking_url})",
889
+ f"- **Estimated Delivery:** ~{result.estimated_time} seconds",
890
+ ]
891
+ )
475
892
 
476
893
  return "\n".join(lines)
477
894