t402 1.9.1__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.
- t402/__init__.py +1 -1
- t402/a2a/__init__.py +73 -0
- t402/a2a/helpers.py +158 -0
- t402/a2a/types.py +145 -0
- t402/bridge/constants.py +1 -1
- t402/django/__init__.py +42 -0
- t402/django/middleware.py +596 -0
- t402/errors.py +213 -0
- t402/facilitator.py +125 -0
- t402/mcp/constants.py +3 -6
- t402/mcp/server.py +428 -44
- t402/mcp/web3_utils.py +493 -0
- t402/multisig/__init__.py +120 -0
- t402/multisig/constants.py +54 -0
- t402/multisig/safe.py +441 -0
- t402/multisig/signature.py +228 -0
- t402/multisig/transaction.py +238 -0
- t402/multisig/types.py +108 -0
- t402/multisig/utils.py +77 -0
- t402/schemes/__init__.py +19 -0
- t402/schemes/cosmos/__init__.py +114 -0
- t402/schemes/cosmos/constants.py +211 -0
- t402/schemes/cosmos/exact_direct/__init__.py +21 -0
- t402/schemes/cosmos/exact_direct/client.py +198 -0
- t402/schemes/cosmos/exact_direct/facilitator.py +493 -0
- t402/schemes/cosmos/exact_direct/server.py +315 -0
- t402/schemes/cosmos/types.py +501 -0
- t402/schemes/evm/__init__.py +1 -1
- t402/schemes/evm/exact_legacy/server.py +1 -1
- t402/schemes/near/__init__.py +25 -0
- t402/schemes/near/upto/__init__.py +54 -0
- t402/schemes/near/upto/types.py +272 -0
- t402/schemes/svm/__init__.py +15 -0
- t402/schemes/svm/upto/__init__.py +23 -0
- t402/schemes/svm/upto/types.py +193 -0
- t402/schemes/ton/__init__.py +15 -0
- t402/schemes/ton/upto/__init__.py +31 -0
- t402/schemes/ton/upto/types.py +215 -0
- t402/schemes/tron/__init__.py +21 -4
- t402/schemes/tron/upto/__init__.py +30 -0
- t402/schemes/tron/upto/types.py +213 -0
- t402/starlette/__init__.py +38 -0
- t402/starlette/middleware.py +522 -0
- t402/ton.py +1 -1
- t402/ton_paywall_template.py +1 -1
- t402/types.py +100 -2
- t402/wdk/chains.py +1 -1
- {t402-1.9.1.dist-info → t402-1.10.0.dist-info}/METADATA +3 -3
- {t402-1.9.1.dist-info → t402-1.10.0.dist-info}/RECORD +51 -20
- {t402-1.9.1.dist-info → t402-1.10.0.dist-info}/WHEEL +0 -0
- {t402-1.9.1.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:
|
|
@@ -175,57 +204,162 @@ class T402McpServer:
|
|
|
175
204
|
"isError": result.isError,
|
|
176
205
|
}
|
|
177
206
|
|
|
178
|
-
|
|
179
|
-
"""
|
|
207
|
+
def _get_web3(self, network: str) -> Any:
|
|
208
|
+
"""Get a Web3 provider for the given network.
|
|
209
|
+
|
|
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
|
+
"""
|
|
180
233
|
try:
|
|
181
|
-
|
|
182
|
-
network = args.get("network", "")
|
|
234
|
+
w3 = self._get_web3(network)
|
|
183
235
|
|
|
184
|
-
|
|
185
|
-
|
|
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)
|
|
186
241
|
|
|
187
|
-
# In demo mode or without web3, return placeholder data
|
|
188
242
|
result = NetworkBalance(
|
|
189
243
|
network=network,
|
|
190
244
|
native=BalanceInfo(
|
|
191
245
|
token=NATIVE_SYMBOLS.get(network, "ETH"),
|
|
192
|
-
balance=
|
|
193
|
-
raw=
|
|
246
|
+
balance=native_formatted,
|
|
247
|
+
raw=str(native_raw),
|
|
194
248
|
),
|
|
195
249
|
tokens=[],
|
|
196
250
|
)
|
|
197
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)
|
|
198
312
|
return self._text_result(self._format_balance_result(result))
|
|
199
313
|
|
|
200
314
|
except Exception as e:
|
|
201
315
|
return self._error_result(str(e))
|
|
202
316
|
|
|
203
317
|
async def _handle_get_all_balances(self, args: dict[str, Any]) -> ToolResult:
|
|
204
|
-
"""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
|
+
"""
|
|
205
324
|
try:
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
results
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
)
|
|
219
341
|
)
|
|
220
|
-
)
|
|
342
|
+
return self._text_result(self._format_all_balances_result(results))
|
|
221
343
|
|
|
222
|
-
|
|
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)))
|
|
223
351
|
|
|
224
352
|
except Exception as e:
|
|
225
353
|
return self._error_result(str(e))
|
|
226
354
|
|
|
227
355
|
async def _handle_pay(self, args: dict[str, Any]) -> ToolResult:
|
|
228
|
-
"""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
|
+
"""
|
|
229
363
|
try:
|
|
230
364
|
to = args.get("to", "")
|
|
231
365
|
amount = args.get("amount", "")
|
|
@@ -258,17 +392,55 @@ class T402McpServer:
|
|
|
258
392
|
)
|
|
259
393
|
return self._text_result(self._format_payment_result(result))
|
|
260
394
|
|
|
261
|
-
|
|
262
|
-
|
|
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,
|
|
263
406
|
)
|
|
264
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))
|
|
427
|
+
|
|
265
428
|
except Exception as e:
|
|
266
429
|
return self._error_result(str(e))
|
|
267
430
|
|
|
268
431
|
async def _handle_pay_gasless(self, args: dict[str, Any]) -> ToolResult:
|
|
269
|
-
"""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
|
+
"""
|
|
270
439
|
try:
|
|
271
440
|
network = args.get("network", "")
|
|
441
|
+
to = args.get("to", "")
|
|
442
|
+
amount = args.get("amount", "")
|
|
443
|
+
token = args.get("token", "")
|
|
272
444
|
|
|
273
445
|
if not is_gasless_network(network):
|
|
274
446
|
return self._error_result(
|
|
@@ -285,26 +457,114 @@ class T402McpServer:
|
|
|
285
457
|
result = PaymentResult(
|
|
286
458
|
tx_hash="0x" + "0" * 64 + "_gasless_demo",
|
|
287
459
|
from_address="0x" + "0" * 40,
|
|
288
|
-
to=
|
|
289
|
-
amount=
|
|
290
|
-
token=
|
|
460
|
+
to=to,
|
|
461
|
+
amount=amount,
|
|
462
|
+
token=token,
|
|
291
463
|
network=network,
|
|
292
464
|
explorer_url=get_explorer_tx_url(network, "0x_demo"),
|
|
293
465
|
demo_mode=True,
|
|
294
466
|
)
|
|
295
467
|
return self._text_result(self._format_payment_result(result))
|
|
296
468
|
|
|
297
|
-
|
|
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))
|
|
298
552
|
|
|
299
553
|
except Exception as e:
|
|
300
554
|
return self._error_result(str(e))
|
|
301
555
|
|
|
302
556
|
async def _handle_get_bridge_fee(self, args: dict[str, Any]) -> ToolResult:
|
|
303
|
-
"""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
|
+
"""
|
|
304
563
|
try:
|
|
305
564
|
from_chain = args.get("fromChain", "")
|
|
306
565
|
to_chain = args.get("toChain", "")
|
|
307
566
|
amount = args.get("amount", "")
|
|
567
|
+
recipient = args.get("recipient", "")
|
|
308
568
|
|
|
309
569
|
if not is_bridgeable_chain(from_chain):
|
|
310
570
|
return self._error_result(
|
|
@@ -319,13 +579,52 @@ class T402McpServer:
|
|
|
319
579
|
"Source and destination chains must be different"
|
|
320
580
|
)
|
|
321
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)
|
|
620
|
+
|
|
322
621
|
result = BridgeFeeResult(
|
|
323
|
-
native_fee="
|
|
324
|
-
native_symbol=
|
|
622
|
+
native_fee=f"{fee_formatted} {native_symbol}",
|
|
623
|
+
native_symbol=native_symbol,
|
|
325
624
|
from_chain=from_chain,
|
|
326
625
|
to_chain=to_chain,
|
|
327
626
|
amount=amount,
|
|
328
|
-
estimated_time=300,
|
|
627
|
+
estimated_time=ESTIMATED_BRIDGE_TIMES.get(to_chain, 300),
|
|
329
628
|
)
|
|
330
629
|
return self._text_result(self._format_bridge_fee_result(result))
|
|
331
630
|
|
|
@@ -333,11 +632,19 @@ class T402McpServer:
|
|
|
333
632
|
return self._error_result(str(e))
|
|
334
633
|
|
|
335
634
|
async def _handle_bridge(self, args: dict[str, Any]) -> ToolResult:
|
|
336
|
-
"""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
|
+
"""
|
|
337
643
|
try:
|
|
338
644
|
from_chain = args.get("fromChain", "")
|
|
339
645
|
to_chain = args.get("toChain", "")
|
|
340
646
|
amount = args.get("amount", "")
|
|
647
|
+
recipient = args.get("recipient", "")
|
|
341
648
|
|
|
342
649
|
if not is_bridgeable_chain(from_chain):
|
|
343
650
|
return self._error_result(
|
|
@@ -368,14 +675,91 @@ class T402McpServer:
|
|
|
368
675
|
amount=amount,
|
|
369
676
|
explorer_url=get_explorer_tx_url(from_chain, "0x_demo"),
|
|
370
677
|
tracking_url=LAYERZERO_SCAN_URL + demo_guid,
|
|
371
|
-
estimated_time=300,
|
|
678
|
+
estimated_time=ESTIMATED_BRIDGE_TIMES.get(to_chain, 300),
|
|
372
679
|
demo_mode=True,
|
|
373
680
|
)
|
|
374
681
|
return self._text_result(self._format_bridge_result(result))
|
|
375
682
|
|
|
376
|
-
|
|
377
|
-
|
|
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,
|
|
378
761
|
)
|
|
762
|
+
return self._text_result(self._format_bridge_result(bridge_result))
|
|
379
763
|
|
|
380
764
|
except Exception as e:
|
|
381
765
|
return self._error_result(str(e))
|
|
@@ -423,7 +807,7 @@ class T402McpServer:
|
|
|
423
807
|
for result in results:
|
|
424
808
|
if result.error:
|
|
425
809
|
lines.append(f"### {result.network}")
|
|
426
|
-
lines.append(f"
|
|
810
|
+
lines.append(f"Error: {result.error}")
|
|
427
811
|
lines.append("")
|
|
428
812
|
continue
|
|
429
813
|
|
|
@@ -447,7 +831,7 @@ class T402McpServer:
|
|
|
447
831
|
[
|
|
448
832
|
"## Payment (Demo Mode)",
|
|
449
833
|
"",
|
|
450
|
-
"
|
|
834
|
+
"This is a simulated transaction. No actual tokens were transferred.",
|
|
451
835
|
"",
|
|
452
836
|
]
|
|
453
837
|
)
|
|
@@ -488,7 +872,7 @@ class T402McpServer:
|
|
|
488
872
|
[
|
|
489
873
|
"## Bridge (Demo Mode)",
|
|
490
874
|
"",
|
|
491
|
-
"
|
|
875
|
+
"This is a simulated bridge. No actual tokens were transferred.",
|
|
492
876
|
"",
|
|
493
877
|
]
|
|
494
878
|
)
|