t402 1.5.3__py3-none-any.whl → 1.6.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/mcp/server.py ADDED
@@ -0,0 +1,527 @@
1
+ """T402 MCP Server implementation."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import sys
7
+ from dataclasses import asdict
8
+ from typing import Any, Optional, TextIO
9
+
10
+ from .constants import (
11
+ ALL_NETWORKS,
12
+ LAYERZERO_SCAN_URL,
13
+ NATIVE_SYMBOLS,
14
+ get_explorer_tx_url,
15
+ get_token_address,
16
+ is_bridgeable_chain,
17
+ is_gasless_network,
18
+ is_valid_network,
19
+ )
20
+ from .tools import get_tool_definitions
21
+ from .types import (
22
+ BalanceInfo,
23
+ BridgeFeeResult,
24
+ BridgeResultData,
25
+ ContentBlock,
26
+ JSONRPCError,
27
+ JSONRPCResponse,
28
+ NetworkBalance,
29
+ PaymentResult,
30
+ ServerConfig,
31
+ ToolResult,
32
+ )
33
+
34
+
35
+ class T402McpServer:
36
+ """T402 MCP Server.
37
+
38
+ Provides blockchain payment tools for AI agents via MCP protocol.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ config: Optional[ServerConfig] = None,
44
+ stdin: Optional[TextIO] = None,
45
+ stdout: Optional[TextIO] = None,
46
+ ) -> None:
47
+ """Create a new MCP server.
48
+
49
+ Args:
50
+ config: Server configuration
51
+ stdin: Input stream (default: sys.stdin)
52
+ stdout: Output stream (default: sys.stdout)
53
+ """
54
+ self.config = config or ServerConfig()
55
+ self._stdin = stdin or sys.stdin
56
+ self._stdout = stdout or sys.stdout
57
+
58
+ async def run(self) -> None:
59
+ """Run the MCP server, processing requests until EOF."""
60
+ print("T402 MCP Server starting...", file=sys.stderr)
61
+ print(f"Demo mode: {self.config.demo_mode}", file=sys.stderr)
62
+
63
+ loop = asyncio.get_event_loop()
64
+
65
+ while True:
66
+ try:
67
+ # Read line from stdin
68
+ line = await loop.run_in_executor(None, self._stdin.readline)
69
+ if not line:
70
+ break
71
+
72
+ line = line.strip()
73
+ if not line:
74
+ continue
75
+
76
+ # Process request
77
+ response = await self._handle_request(line)
78
+
79
+ # Write response
80
+ response_json = self._serialize_response(response)
81
+ self._stdout.write(response_json + "\n")
82
+ self._stdout.flush()
83
+
84
+ except Exception as e:
85
+ print(f"Error: {e}", file=sys.stderr)
86
+ continue
87
+
88
+ async def _handle_request(self, data: str) -> JSONRPCResponse:
89
+ """Handle a single JSON-RPC request."""
90
+ try:
91
+ req = json.loads(data)
92
+ except json.JSONDecodeError as e:
93
+ return JSONRPCResponse(
94
+ jsonrpc="2.0",
95
+ id=None,
96
+ error=JSONRPCError(code=-32700, message="Parse error", data=str(e)),
97
+ )
98
+
99
+ method = req.get("method", "")
100
+ req_id = req.get("id")
101
+ params = req.get("params", {})
102
+
103
+ response = JSONRPCResponse(jsonrpc="2.0", id=req_id)
104
+
105
+ if method == "initialize":
106
+ response.result = self._handle_initialize()
107
+ elif method == "tools/list":
108
+ response.result = self._handle_list_tools()
109
+ elif method == "tools/call":
110
+ response.result = await self._handle_call_tool(params)
111
+ elif method == "notifications/initialized":
112
+ response.result = {}
113
+ else:
114
+ response.error = JSONRPCError(
115
+ code=-32601, message="Method not found", data=method
116
+ )
117
+
118
+ return response
119
+
120
+ def _handle_initialize(self) -> dict[str, Any]:
121
+ """Handle the initialize request."""
122
+ return {
123
+ "protocolVersion": "2024-11-05",
124
+ "serverInfo": {"name": "t402", "version": "1.0.0"},
125
+ "capabilities": {"tools": {}},
126
+ }
127
+
128
+ def _handle_list_tools(self) -> dict[str, Any]:
129
+ """Handle the tools/list request."""
130
+ tools = get_tool_definitions()
131
+ return {"tools": [self._tool_to_dict(t) for t in tools]}
132
+
133
+ def _tool_to_dict(self, tool) -> dict[str, Any]:
134
+ """Convert Tool to dictionary."""
135
+ return {
136
+ "name": tool.name,
137
+ "description": tool.description,
138
+ "inputSchema": {
139
+ "type": tool.inputSchema.type,
140
+ "properties": {
141
+ k: {
142
+ "type": v.type,
143
+ **({"description": v.description} if v.description else {}),
144
+ **({"enum": v.enum} if v.enum else {}),
145
+ **({"pattern": v.pattern} if v.pattern else {}),
146
+ }
147
+ for k, v in tool.inputSchema.properties.items()
148
+ },
149
+ "required": tool.inputSchema.required,
150
+ },
151
+ }
152
+
153
+ async def _handle_call_tool(self, params: dict[str, Any]) -> dict[str, Any]:
154
+ """Handle the tools/call request."""
155
+ tool_name = params.get("name", "")
156
+ arguments = params.get("arguments", {})
157
+
158
+ if tool_name == "t402/getBalance":
159
+ result = await self._handle_get_balance(arguments)
160
+ elif tool_name == "t402/getAllBalances":
161
+ result = await self._handle_get_all_balances(arguments)
162
+ elif tool_name == "t402/pay":
163
+ result = await self._handle_pay(arguments)
164
+ elif tool_name == "t402/payGasless":
165
+ result = await self._handle_pay_gasless(arguments)
166
+ elif tool_name == "t402/getBridgeFee":
167
+ result = await self._handle_get_bridge_fee(arguments)
168
+ elif tool_name == "t402/bridge":
169
+ result = await self._handle_bridge(arguments)
170
+ else:
171
+ result = self._error_result(f"Unknown tool: {tool_name}")
172
+
173
+ return {"content": [asdict(c) for c in result.content], "isError": result.isError}
174
+
175
+ async def _handle_get_balance(self, args: dict[str, Any]) -> ToolResult:
176
+ """Handle t402/getBalance tool."""
177
+ try:
178
+ _address = args.get("address", "") # noqa: F841 - reserved for future use
179
+ network = args.get("network", "")
180
+
181
+ if not is_valid_network(network):
182
+ return self._error_result(f"Invalid network: {network}")
183
+
184
+ # In demo mode or without web3, return placeholder data
185
+ result = NetworkBalance(
186
+ network=network,
187
+ native=BalanceInfo(
188
+ token=NATIVE_SYMBOLS.get(network, "ETH"),
189
+ balance="0.0",
190
+ raw="0",
191
+ ),
192
+ tokens=[],
193
+ )
194
+
195
+ return self._text_result(self._format_balance_result(result))
196
+
197
+ except Exception as e:
198
+ return self._error_result(str(e))
199
+
200
+ async def _handle_get_all_balances(self, args: dict[str, Any]) -> ToolResult:
201
+ """Handle t402/getAllBalances tool."""
202
+ 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=[],
216
+ )
217
+ )
218
+
219
+ return self._text_result(self._format_all_balances_result(results))
220
+
221
+ except Exception as e:
222
+ return self._error_result(str(e))
223
+
224
+ async def _handle_pay(self, args: dict[str, Any]) -> ToolResult:
225
+ """Handle t402/pay tool."""
226
+ try:
227
+ to = args.get("to", "")
228
+ amount = args.get("amount", "")
229
+ token = args.get("token", "")
230
+ network = args.get("network", "")
231
+
232
+ if not is_valid_network(network):
233
+ return self._error_result(f"Invalid network: {network}")
234
+
235
+ token_addr = get_token_address(network, token)
236
+ if not token_addr:
237
+ return self._error_result(f"Token {token} not supported on {network}")
238
+
239
+ if not self.config.private_key and not self.config.demo_mode:
240
+ return self._error_result(
241
+ "Private key not configured. Set T402_PRIVATE_KEY or enable T402_DEMO_MODE"
242
+ )
243
+
244
+ # Demo mode
245
+ if self.config.demo_mode:
246
+ result = PaymentResult(
247
+ tx_hash="0x" + "0" * 64 + "_demo",
248
+ from_address="0x" + "0" * 40,
249
+ to=to,
250
+ amount=amount,
251
+ token=token,
252
+ network=network,
253
+ explorer_url=get_explorer_tx_url(network, "0x_demo"),
254
+ demo_mode=True,
255
+ )
256
+ return self._text_result(self._format_payment_result(result))
257
+
258
+ return self._error_result("Real transactions require private key configuration")
259
+
260
+ except Exception as e:
261
+ return self._error_result(str(e))
262
+
263
+ async def _handle_pay_gasless(self, args: dict[str, Any]) -> ToolResult:
264
+ """Handle t402/payGasless tool."""
265
+ try:
266
+ network = args.get("network", "")
267
+
268
+ if not is_gasless_network(network):
269
+ return self._error_result(f"Network {network} does not support gasless payments")
270
+
271
+ if not self.config.bundler_url and not self.config.demo_mode:
272
+ return self._error_result(
273
+ "Bundler URL not configured. Set T402_BUNDLER_URL or enable T402_DEMO_MODE"
274
+ )
275
+
276
+ # Demo mode
277
+ if self.config.demo_mode:
278
+ result = PaymentResult(
279
+ tx_hash="0x" + "0" * 64 + "_gasless_demo",
280
+ from_address="0x" + "0" * 40,
281
+ to=args.get("to", ""),
282
+ amount=args.get("amount", ""),
283
+ token=args.get("token", ""),
284
+ network=network,
285
+ explorer_url=get_explorer_tx_url(network, "0x_demo"),
286
+ demo_mode=True,
287
+ )
288
+ return self._text_result(self._format_payment_result(result))
289
+
290
+ return self._error_result("Gasless payments require bundler configuration")
291
+
292
+ except Exception as e:
293
+ return self._error_result(str(e))
294
+
295
+ async def _handle_get_bridge_fee(self, args: dict[str, Any]) -> ToolResult:
296
+ """Handle t402/getBridgeFee tool."""
297
+ try:
298
+ from_chain = args.get("fromChain", "")
299
+ to_chain = args.get("toChain", "")
300
+ amount = args.get("amount", "")
301
+
302
+ if not is_bridgeable_chain(from_chain):
303
+ return self._error_result(f"Chain {from_chain} does not support USDT0 bridging")
304
+ if not is_bridgeable_chain(to_chain):
305
+ return self._error_result(f"Chain {to_chain} does not support USDT0 bridging")
306
+ if from_chain == to_chain:
307
+ return self._error_result("Source and destination chains must be different")
308
+
309
+ result = BridgeFeeResult(
310
+ native_fee="0.001",
311
+ native_symbol=NATIVE_SYMBOLS.get(from_chain, "ETH"),
312
+ from_chain=from_chain,
313
+ to_chain=to_chain,
314
+ amount=amount,
315
+ estimated_time=300,
316
+ )
317
+ return self._text_result(self._format_bridge_fee_result(result))
318
+
319
+ except Exception as e:
320
+ return self._error_result(str(e))
321
+
322
+ async def _handle_bridge(self, args: dict[str, Any]) -> ToolResult:
323
+ """Handle t402/bridge tool."""
324
+ try:
325
+ from_chain = args.get("fromChain", "")
326
+ to_chain = args.get("toChain", "")
327
+ amount = args.get("amount", "")
328
+
329
+ if not is_bridgeable_chain(from_chain):
330
+ return self._error_result(f"Chain {from_chain} does not support USDT0 bridging")
331
+ if not is_bridgeable_chain(to_chain):
332
+ return self._error_result(f"Chain {to_chain} does not support USDT0 bridging")
333
+ if from_chain == to_chain:
334
+ return self._error_result("Source and destination chains must be different")
335
+
336
+ if not self.config.private_key and not self.config.demo_mode:
337
+ return self._error_result(
338
+ "Private key not configured. Set T402_PRIVATE_KEY or enable T402_DEMO_MODE"
339
+ )
340
+
341
+ # Demo mode
342
+ if self.config.demo_mode:
343
+ demo_guid = "0x" + "a" * 64
344
+ result = BridgeResultData(
345
+ tx_hash="0x" + "0" * 64 + "_bridge_demo",
346
+ message_guid=demo_guid,
347
+ from_chain=from_chain,
348
+ to_chain=to_chain,
349
+ amount=amount,
350
+ explorer_url=get_explorer_tx_url(from_chain, "0x_demo"),
351
+ tracking_url=LAYERZERO_SCAN_URL + demo_guid,
352
+ estimated_time=300,
353
+ demo_mode=True,
354
+ )
355
+ return self._text_result(self._format_bridge_result(result))
356
+
357
+ return self._error_result("Bridge functionality requires private key configuration")
358
+
359
+ except Exception as e:
360
+ return self._error_result(str(e))
361
+
362
+ # Result helpers
363
+
364
+ def _text_result(self, text: str) -> ToolResult:
365
+ """Create a text result."""
366
+ return ToolResult(content=[ContentBlock(type="text", text=text)])
367
+
368
+ def _error_result(self, message: str) -> ToolResult:
369
+ """Create an error result."""
370
+ return ToolResult(
371
+ content=[ContentBlock(type="text", text=f"Error: {message}")],
372
+ isError=True,
373
+ )
374
+
375
+ # Formatting helpers
376
+
377
+ def _format_balance_result(self, result: NetworkBalance) -> str:
378
+ """Format balance result as markdown."""
379
+ lines = [f"## Balance on {result.network}", ""]
380
+
381
+ if result.error:
382
+ lines.append(f"Error: {result.error}")
383
+ return "\n".join(lines)
384
+
385
+ if result.native:
386
+ lines.append(f"**Native ({result.native.token}):** {result.native.balance}")
387
+ lines.append("")
388
+
389
+ if result.tokens:
390
+ lines.append("**Tokens:**")
391
+ for token in result.tokens:
392
+ lines.append(f"- {token.token}: {token.balance}")
393
+ else:
394
+ lines.append("No token balances found.")
395
+
396
+ return "\n".join(lines)
397
+
398
+ def _format_all_balances_result(self, results: list[NetworkBalance]) -> str:
399
+ """Format all balances result as markdown."""
400
+ lines = ["## Balances Across All Networks", ""]
401
+
402
+ for result in results:
403
+ if result.error:
404
+ lines.append(f"### {result.network}")
405
+ lines.append(f"❌ {result.error}")
406
+ lines.append("")
407
+ continue
408
+
409
+ lines.append(f"### {result.network}")
410
+ if result.native:
411
+ lines.append(f"- Native ({result.native.token}): {result.native.balance}")
412
+ for token in result.tokens:
413
+ lines.append(f"- {token.token}: {token.balance}")
414
+ lines.append("")
415
+
416
+ return "\n".join(lines)
417
+
418
+ def _format_payment_result(self, result: PaymentResult) -> str:
419
+ """Format payment result as markdown."""
420
+ lines = []
421
+
422
+ 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
+ ])
429
+ else:
430
+ lines.extend(["## Payment Successful", ""])
431
+
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
+ ])
438
+
439
+ return "\n".join(lines)
440
+
441
+ def _format_bridge_fee_result(self, result: BridgeFeeResult) -> str:
442
+ """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
+ ])
452
+
453
+ def _format_bridge_result(self, result: BridgeResultData) -> str:
454
+ """Format bridge result as markdown."""
455
+ lines = []
456
+
457
+ 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
+ ])
464
+ else:
465
+ lines.extend(["## Bridge Initiated", ""])
466
+
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
+ ])
475
+
476
+ return "\n".join(lines)
477
+
478
+ def _truncate_hash(self, hash_str: str) -> str:
479
+ """Truncate a hash for display."""
480
+ if len(hash_str) <= 16:
481
+ return hash_str
482
+ return f"{hash_str[:8]}...{hash_str[-6:]}"
483
+
484
+ def _serialize_response(self, response: JSONRPCResponse) -> str:
485
+ """Serialize response to JSON."""
486
+ data = {"jsonrpc": response.jsonrpc, "id": response.id}
487
+
488
+ if response.error:
489
+ data["error"] = {
490
+ "code": response.error.code,
491
+ "message": response.error.message,
492
+ }
493
+ if response.error.data:
494
+ data["error"]["data"] = response.error.data
495
+ else:
496
+ data["result"] = response.result
497
+
498
+ return json.dumps(data)
499
+
500
+
501
+ def load_config_from_env() -> ServerConfig:
502
+ """Load server configuration from environment variables."""
503
+ config = ServerConfig(
504
+ private_key=os.environ.get("T402_PRIVATE_KEY"),
505
+ demo_mode=os.environ.get("T402_DEMO_MODE", "").lower() == "true",
506
+ bundler_url=os.environ.get("T402_BUNDLER_URL"),
507
+ paymaster_url=os.environ.get("T402_PAYMASTER_URL"),
508
+ )
509
+
510
+ # Load network-specific RPC URLs
511
+ for network in ALL_NETWORKS:
512
+ env_key = f"T402_RPC_{network.upper()}"
513
+ if url := os.environ.get(env_key):
514
+ config.rpc_urls[network] = url
515
+
516
+ return config
517
+
518
+
519
+ def run_server() -> None:
520
+ """Run the MCP server (entry point for CLI)."""
521
+ config = load_config_from_env()
522
+ server = T402McpServer(config)
523
+ asyncio.run(server.run())
524
+
525
+
526
+ if __name__ == "__main__":
527
+ run_server()
t402/mcp/tools.py ADDED
@@ -0,0 +1,169 @@
1
+ """Tool definitions for T402 MCP Server."""
2
+
3
+ from .constants import ALL_NETWORKS, BRIDGEABLE_CHAINS, GASLESS_NETWORKS
4
+ from .types import InputSchema, Property, Tool
5
+
6
+
7
+ def get_tool_definitions() -> list[Tool]:
8
+ """Get all available tool definitions."""
9
+ networks = list(ALL_NETWORKS)
10
+ bridgeable_chains = list(BRIDGEABLE_CHAINS)
11
+ gasless_networks = list(GASLESS_NETWORKS)
12
+
13
+ return [
14
+ Tool(
15
+ name="t402/getBalance",
16
+ description="Get token balances (native + stablecoins) for a wallet address on a specific network",
17
+ inputSchema=InputSchema(
18
+ type="object",
19
+ properties={
20
+ "address": Property(
21
+ type="string",
22
+ description="Ethereum address (0x...)",
23
+ pattern="^0x[a-fA-F0-9]{40}$",
24
+ ),
25
+ "network": Property(
26
+ type="string",
27
+ description="Network to query",
28
+ enum=networks,
29
+ ),
30
+ },
31
+ required=["address", "network"],
32
+ ),
33
+ ),
34
+ Tool(
35
+ name="t402/getAllBalances",
36
+ description="Get token balances across all supported networks for a wallet address",
37
+ inputSchema=InputSchema(
38
+ type="object",
39
+ properties={
40
+ "address": Property(
41
+ type="string",
42
+ description="Ethereum address (0x...)",
43
+ pattern="^0x[a-fA-F0-9]{40}$",
44
+ ),
45
+ },
46
+ required=["address"],
47
+ ),
48
+ ),
49
+ Tool(
50
+ name="t402/pay",
51
+ description="Execute a stablecoin payment (USDC, USDT, or USDT0)",
52
+ inputSchema=InputSchema(
53
+ type="object",
54
+ properties={
55
+ "to": Property(
56
+ type="string",
57
+ description="Recipient address (0x...)",
58
+ pattern="^0x[a-fA-F0-9]{40}$",
59
+ ),
60
+ "amount": Property(
61
+ type="string",
62
+ description="Amount to send (e.g., '10.5')",
63
+ pattern=r"^\d+(\.\d+)?$",
64
+ ),
65
+ "token": Property(
66
+ type="string",
67
+ description="Token to send",
68
+ enum=["USDC", "USDT", "USDT0"],
69
+ ),
70
+ "network": Property(
71
+ type="string",
72
+ description="Network to use",
73
+ enum=networks,
74
+ ),
75
+ },
76
+ required=["to", "amount", "token", "network"],
77
+ ),
78
+ ),
79
+ Tool(
80
+ name="t402/payGasless",
81
+ description="Execute a gasless payment using ERC-4337 account abstraction (user pays no gas)",
82
+ inputSchema=InputSchema(
83
+ type="object",
84
+ properties={
85
+ "to": Property(
86
+ type="string",
87
+ description="Recipient address (0x...)",
88
+ pattern="^0x[a-fA-F0-9]{40}$",
89
+ ),
90
+ "amount": Property(
91
+ type="string",
92
+ description="Amount to send (e.g., '10.5')",
93
+ pattern=r"^\d+(\.\d+)?$",
94
+ ),
95
+ "token": Property(
96
+ type="string",
97
+ description="Token to send",
98
+ enum=["USDC", "USDT", "USDT0"],
99
+ ),
100
+ "network": Property(
101
+ type="string",
102
+ description="Network to use (must support ERC-4337)",
103
+ enum=gasless_networks,
104
+ ),
105
+ },
106
+ required=["to", "amount", "token", "network"],
107
+ ),
108
+ ),
109
+ Tool(
110
+ name="t402/getBridgeFee",
111
+ description="Get the fee quote for bridging USDT0 between chains via LayerZero",
112
+ inputSchema=InputSchema(
113
+ type="object",
114
+ properties={
115
+ "fromChain": Property(
116
+ type="string",
117
+ description="Source chain",
118
+ enum=bridgeable_chains,
119
+ ),
120
+ "toChain": Property(
121
+ type="string",
122
+ description="Destination chain",
123
+ enum=bridgeable_chains,
124
+ ),
125
+ "amount": Property(
126
+ type="string",
127
+ description="Amount to bridge (e.g., '100')",
128
+ pattern=r"^\d+(\.\d+)?$",
129
+ ),
130
+ "recipient": Property(
131
+ type="string",
132
+ description="Recipient address on destination chain (0x...)",
133
+ pattern="^0x[a-fA-F0-9]{40}$",
134
+ ),
135
+ },
136
+ required=["fromChain", "toChain", "amount", "recipient"],
137
+ ),
138
+ ),
139
+ Tool(
140
+ name="t402/bridge",
141
+ description="Bridge USDT0 between chains using LayerZero OFT",
142
+ inputSchema=InputSchema(
143
+ type="object",
144
+ properties={
145
+ "fromChain": Property(
146
+ type="string",
147
+ description="Source chain",
148
+ enum=bridgeable_chains,
149
+ ),
150
+ "toChain": Property(
151
+ type="string",
152
+ description="Destination chain",
153
+ enum=bridgeable_chains,
154
+ ),
155
+ "amount": Property(
156
+ type="string",
157
+ description="Amount to bridge (e.g., '100')",
158
+ pattern=r"^\d+(\.\d+)?$",
159
+ ),
160
+ "recipient": Property(
161
+ type="string",
162
+ description="Recipient address on destination chain (0x...)",
163
+ pattern="^0x[a-fA-F0-9]{40}$",
164
+ ),
165
+ },
166
+ required=["fromChain", "toChain", "amount", "recipient"],
167
+ ),
168
+ ),
169
+ ]