botchain-sdk-py 0.1.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.
- botchain/__init__.py +18 -0
- botchain/agents/__init__.py +4 -0
- botchain/agents/executor.py +92 -0
- botchain/agents/mcp.py +3 -0
- botchain/agents/mcp_server.py +18 -0
- botchain/agents/policy.py +102 -0
- botchain/agents/tools/__init__.py +3 -0
- botchain/agents/tools/base.py +32 -0
- botchain/agents/tools/mcp_server.py +115 -0
- botchain/agents/tools/schemas.py +97 -0
- botchain/client.py +82 -0
- botchain/contracts.py +8 -0
- botchain/dex.py +189 -0
- botchain/dex_alm.py +129 -0
- botchain/exceptions.py +7 -0
- botchain/middleware.py +58 -0
- botchain/tokens.py +87 -0
- botchain_sdk_py-0.1.0.dist-info/METADATA +58 -0
- botchain_sdk_py-0.1.0.dist-info/RECORD +22 -0
- botchain_sdk_py-0.1.0.dist-info/WHEEL +5 -0
- botchain_sdk_py-0.1.0.dist-info/entry_points.txt +2 -0
- botchain_sdk_py-0.1.0.dist-info/top_level.txt +1 -0
botchain/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .client import BotChain
|
|
2
|
+
from .exceptions import BotChainLogsDisabledError
|
|
3
|
+
from .dex import BDexManager
|
|
4
|
+
from .tokens import TokenManager
|
|
5
|
+
from .middleware import botchain_paymaster_middleware, check_and_apply_sponsorship
|
|
6
|
+
from .agents import AgentTradePolicy, AgentExecutor
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BotChain",
|
|
10
|
+
"BotChainLogsDisabledError",
|
|
11
|
+
"BDexManager",
|
|
12
|
+
"TokenManager",
|
|
13
|
+
"botchain_paymaster_middleware",
|
|
14
|
+
"check_and_apply_sponsorship",
|
|
15
|
+
"AgentTradePolicy",
|
|
16
|
+
"AgentExecutor"
|
|
17
|
+
]
|
|
18
|
+
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from web3 import Web3
|
|
5
|
+
from .policy import AgentTradePolicy
|
|
6
|
+
from ..middleware import check_and_apply_sponsorship
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
class AgentExecutor:
|
|
11
|
+
"""
|
|
12
|
+
Guarded transaction execution engine for AI Agents.
|
|
13
|
+
Validates trade intents against AgentTradePolicy, performs pre-flight eth_call simulations,
|
|
14
|
+
and handles zero-gas Paymaster sponsorship before signing & broadcasting.
|
|
15
|
+
"""
|
|
16
|
+
def __init__(self, botclient, policy: AgentTradePolicy):
|
|
17
|
+
self.client = botclient
|
|
18
|
+
self.w3 = botclient.w3
|
|
19
|
+
self.policy = policy
|
|
20
|
+
|
|
21
|
+
def execute_trade(
|
|
22
|
+
self,
|
|
23
|
+
dex_manager,
|
|
24
|
+
token_in: str,
|
|
25
|
+
token_out: str,
|
|
26
|
+
amount_in: int,
|
|
27
|
+
fee: int = 3000,
|
|
28
|
+
slippage_tolerance: float = 0.01,
|
|
29
|
+
recipient: Optional[str] = None,
|
|
30
|
+
notional_usd: Optional[float] = None
|
|
31
|
+
) -> str:
|
|
32
|
+
"""
|
|
33
|
+
Validates, simulates, sponsors, and executes a single-hop BDEX trade.
|
|
34
|
+
"""
|
|
35
|
+
if not self.client.account:
|
|
36
|
+
raise ValueError("Local account required to execute agent trades.")
|
|
37
|
+
|
|
38
|
+
target_recipient = recipient or self.client.account.address
|
|
39
|
+
router_address = dex_manager.router_contract.address
|
|
40
|
+
|
|
41
|
+
# 1. Policy Validation
|
|
42
|
+
self.policy.validate_trade(
|
|
43
|
+
token_in=token_in,
|
|
44
|
+
token_out=token_out,
|
|
45
|
+
router=router_address,
|
|
46
|
+
amount_in=amount_in,
|
|
47
|
+
slippage=slippage_tolerance,
|
|
48
|
+
notional_usd=notional_usd
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# 2. Get Quote & Calculate minimum output
|
|
52
|
+
expected_out = dex_manager.get_quote(token_in, token_out, amount_in, fee)
|
|
53
|
+
amount_out_minimum = int(expected_out * (1 - slippage_tolerance))
|
|
54
|
+
|
|
55
|
+
# 3. Build Transaction Dictionary
|
|
56
|
+
params = {
|
|
57
|
+
"tokenIn": self.w3.to_checksum_address(token_in),
|
|
58
|
+
"tokenOut": self.w3.to_checksum_address(token_out),
|
|
59
|
+
"fee": fee,
|
|
60
|
+
"recipient": self.w3.to_checksum_address(target_recipient),
|
|
61
|
+
"deadline": int(time.time()) + 600,
|
|
62
|
+
"amountIn": amount_in,
|
|
63
|
+
"amountOutMinimum": amount_out_minimum,
|
|
64
|
+
"sqrtPriceLimitX96": 0
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
tx_dict = dex_manager.router_contract.functions.exactInputSingle(params).build_transaction({
|
|
68
|
+
'from': self.client.account.address,
|
|
69
|
+
'nonce': self.w3.eth.get_transaction_count(self.client.account.address),
|
|
70
|
+
'chainId': self.client.chain_id,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
# 4. Pre-Flight Simulation (eth_call)
|
|
74
|
+
try:
|
|
75
|
+
logger.info("Executing pre-flight transaction simulation via eth_call...")
|
|
76
|
+
self.w3.eth.call(tx_dict)
|
|
77
|
+
logger.info("Pre-flight simulation successful.")
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.error(f"Pre-flight simulation failed: {e}")
|
|
80
|
+
raise RuntimeError(f"Transaction pre-flight simulation failed: {e}") from e
|
|
81
|
+
|
|
82
|
+
# 5. Zero-Gas Paymaster Sponsorship Check
|
|
83
|
+
tx_dict = check_and_apply_sponsorship(self.w3, tx_dict)
|
|
84
|
+
|
|
85
|
+
# 6. Sign and Broadcast Transaction
|
|
86
|
+
signed_tx = self.w3.eth.account.sign_transaction(tx_dict, private_key=self.client.account.key)
|
|
87
|
+
tx_hash = self.w3.eth.send_raw_transaction(signed_tx.rawTransaction)
|
|
88
|
+
|
|
89
|
+
# 7. Record trade in policy state upon success
|
|
90
|
+
self.policy.record_trade()
|
|
91
|
+
|
|
92
|
+
return self.w3.to_hex(tx_hash)
|
botchain/agents/mcp.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from botchain.client import BotChain
|
|
3
|
+
from botchain.agents.executor import AgentExecutor
|
|
4
|
+
from botchain.agents.policy import AgentTradePolicy
|
|
5
|
+
from botchain.agents.tools.mcp_server import BotChainMCPServer
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
"""Entry point for botchain-mcp server."""
|
|
9
|
+
# Simple setup for MCP
|
|
10
|
+
client = BotChain()
|
|
11
|
+
policy = AgentTradePolicy()
|
|
12
|
+
executor = AgentExecutor(policy)
|
|
13
|
+
|
|
14
|
+
server = BotChainMCPServer(client, executor)
|
|
15
|
+
server.run_stdio()
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
main()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import List, Optional, Set
|
|
3
|
+
from web3 import Web3
|
|
4
|
+
|
|
5
|
+
class AgentTradePolicy:
|
|
6
|
+
"""
|
|
7
|
+
Safety Policy Engine for AI Agents executing trades on BOT Chain.
|
|
8
|
+
Enforces spending caps, rate limits, cooldowns, and asset allowlists.
|
|
9
|
+
"""
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
allowed_tokens: Optional[List[str]] = None,
|
|
13
|
+
allowed_routers: Optional[List[str]] = None,
|
|
14
|
+
max_slippage_tolerance: float = 0.05,
|
|
15
|
+
max_notional_usd: Optional[float] = 1000.0,
|
|
16
|
+
cooldown_seconds: float = 10.0,
|
|
17
|
+
max_trades_per_hour: int = 10
|
|
18
|
+
):
|
|
19
|
+
self.allowed_tokens: Optional[Set[str]] = (
|
|
20
|
+
{Web3.to_checksum_address(t) for t in allowed_tokens} if allowed_tokens else None
|
|
21
|
+
)
|
|
22
|
+
self.allowed_routers: Optional[Set[str]] = (
|
|
23
|
+
{Web3.to_checksum_address(r) for r in allowed_routers} if allowed_routers else None
|
|
24
|
+
)
|
|
25
|
+
self.max_slippage_tolerance = max_slippage_tolerance
|
|
26
|
+
self.max_notional_usd = max_notional_usd
|
|
27
|
+
self.cooldown_seconds = cooldown_seconds
|
|
28
|
+
self.max_trades_per_hour = max_trades_per_hour
|
|
29
|
+
|
|
30
|
+
# Internal tracking state
|
|
31
|
+
self.last_trade_time: Optional[float] = None
|
|
32
|
+
self.trade_timestamps: List[float] = []
|
|
33
|
+
|
|
34
|
+
def validate_trade(
|
|
35
|
+
self,
|
|
36
|
+
token_in: str,
|
|
37
|
+
token_out: str,
|
|
38
|
+
router: Optional[str] = None,
|
|
39
|
+
amount_in: int = 0,
|
|
40
|
+
slippage: float = 0.01,
|
|
41
|
+
notional_usd: Optional[float] = None,
|
|
42
|
+
current_time: Optional[float] = None
|
|
43
|
+
) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Validates trade parameters against policy rules.
|
|
46
|
+
Raises ValueError if any rule is violated.
|
|
47
|
+
"""
|
|
48
|
+
now = current_time if current_time is not None else time.time()
|
|
49
|
+
|
|
50
|
+
# 1. Token Allowlist Verification
|
|
51
|
+
checksum_in = Web3.to_checksum_address(token_in)
|
|
52
|
+
checksum_out = Web3.to_checksum_address(token_out)
|
|
53
|
+
|
|
54
|
+
if self.allowed_tokens is not None:
|
|
55
|
+
if checksum_in not in self.allowed_tokens:
|
|
56
|
+
raise ValueError(f"Token IN ({token_in}) is not in the allowed tokens list.")
|
|
57
|
+
if checksum_out not in self.allowed_tokens:
|
|
58
|
+
raise ValueError(f"Token OUT ({token_out}) is not in the allowed tokens list.")
|
|
59
|
+
|
|
60
|
+
# 2. Router Allowlist Verification
|
|
61
|
+
if router and self.allowed_routers is not None:
|
|
62
|
+
checksum_router = Web3.to_checksum_address(router)
|
|
63
|
+
if checksum_router not in self.allowed_routers:
|
|
64
|
+
raise ValueError(f"Router ({router}) is not in the allowed routers list.")
|
|
65
|
+
|
|
66
|
+
# 3. Slippage Limit Verification
|
|
67
|
+
if slippage > self.max_slippage_tolerance:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Requested slippage ({slippage:.4f}) exceeds maximum allowed tolerance ({self.max_slippage_tolerance:.4f})."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# 4. Notional USD Cap Verification
|
|
73
|
+
if self.max_notional_usd is not None and notional_usd is not None:
|
|
74
|
+
if notional_usd > self.max_notional_usd:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"Trade value (${notional_usd:.2f}) exceeds maximum notional USD cap (${self.max_notional_usd:.2f})."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# 5. Cooldown Verification
|
|
80
|
+
if self.last_trade_time is not None:
|
|
81
|
+
elapsed = now - self.last_trade_time
|
|
82
|
+
if elapsed < self.cooldown_seconds:
|
|
83
|
+
remaining = self.cooldown_seconds - elapsed
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"Trade cooldown active. Please wait {remaining:.2f} seconds before trading again."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# 6. Hourly Rate Limit Verification
|
|
89
|
+
# Filter timestamps to only keep trades within the last 3600 seconds (1 hour)
|
|
90
|
+
self.trade_timestamps = [t for t in self.trade_timestamps if now - t < 3600]
|
|
91
|
+
if len(self.trade_timestamps) >= self.max_trades_per_hour:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"Hourly trade limit reached ({self.max_trades_per_hour} trades/hour)."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def record_trade(self, timestamp: Optional[float] = None) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Records a successfully executed trade timestamp.
|
|
99
|
+
"""
|
|
100
|
+
now = timestamp if timestamp is not None else time.time()
|
|
101
|
+
self.last_trade_time = now
|
|
102
|
+
self.trade_timestamps.append(now)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from botchain.tokens import TokenManager
|
|
2
|
+
from botchain.dex import BDexManager
|
|
3
|
+
from botchain.agents.executor import AgentExecutor
|
|
4
|
+
|
|
5
|
+
def botchain_get_balance(client, token_address: str, account_address: str = None) -> int:
|
|
6
|
+
token_manager = TokenManager(client)
|
|
7
|
+
return token_manager.get_balance(token_address, account_address)
|
|
8
|
+
|
|
9
|
+
def botchain_get_quote(client, token_in: str, token_out: str, amount_in: int, fee: int = 3000) -> int:
|
|
10
|
+
dex_manager = BDexManager(client)
|
|
11
|
+
return dex_manager.get_quote(token_in, token_out, amount_in, fee)
|
|
12
|
+
|
|
13
|
+
def botchain_execute_swap(
|
|
14
|
+
client,
|
|
15
|
+
executor: AgentExecutor,
|
|
16
|
+
token_in: str,
|
|
17
|
+
token_out: str,
|
|
18
|
+
amount_in: int,
|
|
19
|
+
fee: int = 3000,
|
|
20
|
+
slippage_tolerance: float = 0.01,
|
|
21
|
+
notional_usd: float = None
|
|
22
|
+
) -> str:
|
|
23
|
+
dex_manager = BDexManager(client)
|
|
24
|
+
return executor.execute_trade(
|
|
25
|
+
dex_manager=dex_manager,
|
|
26
|
+
token_in=token_in,
|
|
27
|
+
token_out=token_out,
|
|
28
|
+
amount_in=amount_in,
|
|
29
|
+
fee=fee,
|
|
30
|
+
slippage_tolerance=slippage_tolerance,
|
|
31
|
+
notional_usd=notional_usd
|
|
32
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from botchain.agents.tools.base import botchain_get_balance, botchain_get_quote, botchain_execute_swap
|
|
5
|
+
from botchain.agents.tools.schemas import BOTCHAIN_TOOL_SCHEMAS
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
class BotChainMCPServer:
|
|
10
|
+
"""
|
|
11
|
+
Model Context Protocol (MCP) Server for BOT Chain Tools.
|
|
12
|
+
Listens on stdio for JSON-RPC 2.0 requests.
|
|
13
|
+
"""
|
|
14
|
+
def __init__(self, client, executor):
|
|
15
|
+
self.client = client
|
|
16
|
+
self.executor = executor
|
|
17
|
+
|
|
18
|
+
# Tools map
|
|
19
|
+
self.tools = {
|
|
20
|
+
"botchain_get_balance": self.handle_get_balance,
|
|
21
|
+
"botchain_get_quote": self.handle_get_quote,
|
|
22
|
+
"botchain_execute_swap": self.handle_execute_swap
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def handle_get_balance(self, params):
|
|
26
|
+
return botchain_get_balance(
|
|
27
|
+
self.client,
|
|
28
|
+
params.get("token_address"),
|
|
29
|
+
params.get("account_address")
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def handle_get_quote(self, params):
|
|
33
|
+
return botchain_get_quote(
|
|
34
|
+
self.client,
|
|
35
|
+
params.get("token_in"),
|
|
36
|
+
params.get("token_out"),
|
|
37
|
+
params.get("amount_in"),
|
|
38
|
+
params.get("fee", 3000)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def handle_execute_swap(self, params):
|
|
42
|
+
return botchain_execute_swap(
|
|
43
|
+
self.client,
|
|
44
|
+
self.executor,
|
|
45
|
+
params.get("token_in"),
|
|
46
|
+
params.get("token_out"),
|
|
47
|
+
params.get("amount_in"),
|
|
48
|
+
params.get("fee", 3000),
|
|
49
|
+
params.get("slippage_tolerance", 0.01),
|
|
50
|
+
params.get("notional_usd")
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def process_request(self, request_str: str) -> str:
|
|
54
|
+
try:
|
|
55
|
+
req = json.loads(request_str)
|
|
56
|
+
if not isinstance(req, dict) or "jsonrpc" not in req:
|
|
57
|
+
return json.dumps({"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": None})
|
|
58
|
+
|
|
59
|
+
req_id = req.get("id")
|
|
60
|
+
method = req.get("method")
|
|
61
|
+
params = req.get("params", {})
|
|
62
|
+
|
|
63
|
+
# MCP Tool Discovery
|
|
64
|
+
if method == "tools/list":
|
|
65
|
+
return json.dumps({
|
|
66
|
+
"jsonrpc": "2.0",
|
|
67
|
+
"id": req_id,
|
|
68
|
+
"result": {
|
|
69
|
+
"tools": BOTCHAIN_TOOL_SCHEMAS
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
# MCP Tool Call
|
|
74
|
+
if method == "tools/call":
|
|
75
|
+
tool_name = params.get("name")
|
|
76
|
+
tool_args = params.get("arguments", {})
|
|
77
|
+
|
|
78
|
+
if tool_name not in self.tools:
|
|
79
|
+
return json.dumps({
|
|
80
|
+
"jsonrpc": "2.0",
|
|
81
|
+
"id": req_id,
|
|
82
|
+
"error": {"code": -32601, "message": f"Method not found: {tool_name}"}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
result = self.tools[tool_name](tool_args)
|
|
87
|
+
return json.dumps({
|
|
88
|
+
"jsonrpc": "2.0",
|
|
89
|
+
"id": req_id,
|
|
90
|
+
"result": {
|
|
91
|
+
"content": [{"type": "text", "text": str(result)}]
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
except Exception as e:
|
|
95
|
+
return json.dumps({
|
|
96
|
+
"jsonrpc": "2.0",
|
|
97
|
+
"id": req_id,
|
|
98
|
+
"error": {"code": -32000, "message": str(e)}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return json.dumps({
|
|
102
|
+
"jsonrpc": "2.0",
|
|
103
|
+
"id": req_id,
|
|
104
|
+
"error": {"code": -32601, "message": "Method not found"}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
except json.JSONDecodeError:
|
|
108
|
+
return json.dumps({"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": None})
|
|
109
|
+
|
|
110
|
+
def run_stdio(self):
|
|
111
|
+
"""Runs the MCP server over standard input/output."""
|
|
112
|
+
for line in sys.stdin:
|
|
113
|
+
response = self.process_request(line)
|
|
114
|
+
sys.stdout.write(response + "\n")
|
|
115
|
+
sys.stdout.flush()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Standardized JSON schemas for LLM Function Calling (OpenAI, Gemini, Anthropic, MCP).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
BOTCHAIN_TOOL_SCHEMAS = [
|
|
6
|
+
{
|
|
7
|
+
"name": "botchain_get_balance",
|
|
8
|
+
"description": "Get the token balance (native BOT or ERC20/WBOT) for a given account address on BOT Chain.",
|
|
9
|
+
"parameters": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"properties": {
|
|
12
|
+
"token_address": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"description": "The contract address of the ERC20 token, or 'NATIVE' / 'WBOT' for BOT Chain native assets."
|
|
15
|
+
},
|
|
16
|
+
"account_address": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "Optional 0x-prefixed account address. Defaults to the connected agent wallet."
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"required": ["token_address"]
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "botchain_get_quote",
|
|
26
|
+
"description": "Query BDEX V3 for the expected output amount for a single-hop token swap.",
|
|
27
|
+
"parameters": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"token_in": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "The checksummed contract address of the input token."
|
|
33
|
+
},
|
|
34
|
+
"token_out": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"description": "The checksummed contract address of the output token."
|
|
37
|
+
},
|
|
38
|
+
"amount_in": {
|
|
39
|
+
"type": "integer",
|
|
40
|
+
"description": "Amount of token_in in base units (wei)."
|
|
41
|
+
},
|
|
42
|
+
"fee": {
|
|
43
|
+
"type": "integer",
|
|
44
|
+
"description": "Pool fee tier in hundredths of a bip (default 3000 for 0.3%).",
|
|
45
|
+
"default": 3000
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"required": ["token_in", "token_out", "amount_in"]
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"name": "botchain_execute_swap",
|
|
53
|
+
"description": "Executes a policy-guarded single-hop token swap on BDEX with pre-flight simulation and optional zero-gas Paymaster sponsorship.",
|
|
54
|
+
"parameters": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"properties": {
|
|
57
|
+
"token_in": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"description": "Checksummed address of the token to sell."
|
|
60
|
+
},
|
|
61
|
+
"token_out": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"description": "Checksummed address of the token to buy."
|
|
64
|
+
},
|
|
65
|
+
"amount_in": {
|
|
66
|
+
"type": "integer",
|
|
67
|
+
"description": "Amount of token_in to sell in base units."
|
|
68
|
+
},
|
|
69
|
+
"fee": {
|
|
70
|
+
"type": "integer",
|
|
71
|
+
"description": "Pool fee tier (e.g. 3000 for 0.3%).",
|
|
72
|
+
"default": 3000
|
|
73
|
+
},
|
|
74
|
+
"slippage_tolerance": {
|
|
75
|
+
"type": "number",
|
|
76
|
+
"description": "Maximum allowed price slippage (e.g., 0.01 for 1%).",
|
|
77
|
+
"default": 0.01
|
|
78
|
+
},
|
|
79
|
+
"notional_usd": {
|
|
80
|
+
"type": "number",
|
|
81
|
+
"description": "Estimated USD value of the trade for policy cap verification."
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"required": ["token_in", "token_out", "amount_in"]
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
def get_openai_tool_definitions():
|
|
90
|
+
"""Returns schemas wrapped in OpenAI function tool format."""
|
|
91
|
+
return [
|
|
92
|
+
{
|
|
93
|
+
"type": "function",
|
|
94
|
+
"function": schema
|
|
95
|
+
}
|
|
96
|
+
for schema in BOTCHAIN_TOOL_SCHEMAS
|
|
97
|
+
]
|
botchain/client.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from web3 import Web3
|
|
2
|
+
from web3.middleware import ExtraDataToPOAMiddleware
|
|
3
|
+
from eth_account import Account
|
|
4
|
+
from .exceptions import BotChainLogsDisabledError
|
|
5
|
+
|
|
6
|
+
class BotChain:
|
|
7
|
+
# Network Constants
|
|
8
|
+
MAINNET_RPC = "https://rpc.botchain.ai"
|
|
9
|
+
MAINNET_CHAIN_ID = 677
|
|
10
|
+
|
|
11
|
+
TESTNET_RPC = "https://rpc.bohr.life"
|
|
12
|
+
TESTNET_CHAIN_ID = 968
|
|
13
|
+
|
|
14
|
+
def __init__(self, private_key: str = None, is_testnet: bool = False, custom_rpc: str = None):
|
|
15
|
+
"""
|
|
16
|
+
Initializes the BOT Chain Web3 client.
|
|
17
|
+
"""
|
|
18
|
+
self.is_testnet = is_testnet
|
|
19
|
+
self.chain_id = self.TESTNET_CHAIN_ID if is_testnet else self.MAINNET_CHAIN_ID
|
|
20
|
+
self.rpc_url = custom_rpc or (self.TESTNET_RPC if is_testnet else self.MAINNET_RPC)
|
|
21
|
+
|
|
22
|
+
# Initialize the underlying Web3 HTTP Provider
|
|
23
|
+
self.w3 = Web3(Web3.HTTPProvider(self.rpc_url))
|
|
24
|
+
|
|
25
|
+
self._inject_poa_middleware()
|
|
26
|
+
self._configure_account(private_key)
|
|
27
|
+
self._protect_rpc_methods()
|
|
28
|
+
|
|
29
|
+
def _inject_poa_middleware(self):
|
|
30
|
+
"""
|
|
31
|
+
Injects the geth_poa_middleware to handle BOT Chain's Parlia consensus block headers.
|
|
32
|
+
Must be added to layer 0.
|
|
33
|
+
"""
|
|
34
|
+
self.w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
|
|
35
|
+
|
|
36
|
+
def _configure_account(self, private_key: str):
|
|
37
|
+
"""
|
|
38
|
+
Sets up the local EOA for transaction signing.
|
|
39
|
+
"""
|
|
40
|
+
self.account = None
|
|
41
|
+
if private_key:
|
|
42
|
+
self.account = Account.from_key(private_key)
|
|
43
|
+
self.w3.eth.default_account = self.account.address
|
|
44
|
+
|
|
45
|
+
def _protect_rpc_methods(self):
|
|
46
|
+
"""
|
|
47
|
+
Wraps standard web3 methods to enforce BOT Chain architectural constraints.
|
|
48
|
+
"""
|
|
49
|
+
original_get_logs = self.w3.eth.get_logs
|
|
50
|
+
|
|
51
|
+
def safe_get_logs(*args, **kwargs):
|
|
52
|
+
# If using the default mainnet RPC, block the call
|
|
53
|
+
if not self.is_testnet and self.rpc_url == self.MAINNET_RPC:
|
|
54
|
+
raise BotChainLogsDisabledError()
|
|
55
|
+
return original_get_logs(*args, **kwargs)
|
|
56
|
+
|
|
57
|
+
# Override the method
|
|
58
|
+
self.w3.eth.get_logs = safe_get_logs
|
|
59
|
+
|
|
60
|
+
def build_transaction(self, to: str, data: str = "0x", value: int = 0) -> dict:
|
|
61
|
+
"""
|
|
62
|
+
Helper to construct a base transaction dictionary ready for signing or Paymaster middleware.
|
|
63
|
+
"""
|
|
64
|
+
if not self.account:
|
|
65
|
+
raise ValueError("Private key must be provided to build transactions.")
|
|
66
|
+
|
|
67
|
+
tx = {
|
|
68
|
+
'chainId': self.chain_id,
|
|
69
|
+
'from': self.account.address,
|
|
70
|
+
'to': self.w3.to_checksum_address(to),
|
|
71
|
+
'value': value,
|
|
72
|
+
'data': data,
|
|
73
|
+
'nonce': self.w3.eth.get_transaction_count(self.account.address),
|
|
74
|
+
# Gas estimation will be handled by the user or the Web3 provider
|
|
75
|
+
}
|
|
76
|
+
return tx
|
|
77
|
+
|
|
78
|
+
def enable_zero_gas_paymaster(self):
|
|
79
|
+
"""Injects the MegaFuel EOA Paymaster middleware."""
|
|
80
|
+
from .middleware import botchain_paymaster_middleware
|
|
81
|
+
self.w3.middleware_onion.inject(botchain_paymaster_middleware, name="botchain_paymaster", layer=0)
|
|
82
|
+
|
botchain/contracts.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
MAINNET_ADDRESSES = {
|
|
2
|
+
"BDEX_V3_FACTORY": "0x1C51c173323ec11BB4e3C4fD2314c225Dc4b5419",
|
|
3
|
+
"BDEX_SWAP_ROUTER": "0x07032d47A1b9f8460cBeE9dC17c1d3E438693929",
|
|
4
|
+
"BDEX_QUOTER_V2": "0x034A705b36067cff99ABf5C662Be881cBd8d0176",
|
|
5
|
+
"NONFUNGIBLE_POSITION_MANAGER": "0xDAc3FcFF004d8a8675b94E44941A1a2e3b240090",
|
|
6
|
+
"WBOT": "0xD5452816194a3784dBa983426cCe7c122F4abd30",
|
|
7
|
+
"USDT": "0xaBabc7Ddc03e501d190C676BF3d92ef0e6e87a3C",
|
|
8
|
+
}
|
botchain/dex.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
import os
|
|
4
|
+
from web3 import Web3
|
|
5
|
+
from .contracts import MAINNET_ADDRESSES
|
|
6
|
+
|
|
7
|
+
def get_tick_spacing(fee: int) -> int:
|
|
8
|
+
"""Returns the tick spacing for a given fee tier."""
|
|
9
|
+
if fee == 100:
|
|
10
|
+
return 1
|
|
11
|
+
elif fee == 500:
|
|
12
|
+
return 10
|
|
13
|
+
elif fee == 3000:
|
|
14
|
+
return 60
|
|
15
|
+
elif fee == 10000:
|
|
16
|
+
return 200
|
|
17
|
+
return 60
|
|
18
|
+
|
|
19
|
+
def calculate_ticks(current_tick: int, tick_spacing: int, range_percentage: float) -> tuple[int, int]:
|
|
20
|
+
"""Calculates a tick range snapped to exact multiples of tick_spacing."""
|
|
21
|
+
tick_delta = int(range_percentage * 10000)
|
|
22
|
+
tick_lower = current_tick - tick_delta
|
|
23
|
+
tick_upper = current_tick + tick_delta
|
|
24
|
+
|
|
25
|
+
tick_lower = (tick_lower // tick_spacing) * tick_spacing
|
|
26
|
+
tick_upper = (tick_upper // tick_spacing) * tick_spacing
|
|
27
|
+
return tick_lower, tick_upper
|
|
28
|
+
|
|
29
|
+
class BDexManager:
|
|
30
|
+
def __init__(self, botclient):
|
|
31
|
+
self.client = botclient
|
|
32
|
+
self.w3 = botclient.w3
|
|
33
|
+
|
|
34
|
+
self.router_abi = self._load_abi("SwapRouter.json")
|
|
35
|
+
self.router_contract = self.w3.eth.contract(
|
|
36
|
+
address=self.w3.to_checksum_address(MAINNET_ADDRESSES["BDEX_SWAP_ROUTER"]),
|
|
37
|
+
abi=self.router_abi
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
self.quoter_abi = self._load_abi("QuoterV2.json")
|
|
41
|
+
self.quoter_contract = self.w3.eth.contract(
|
|
42
|
+
address=self.w3.to_checksum_address(MAINNET_ADDRESSES["BDEX_QUOTER_V2"]),
|
|
43
|
+
abi=self.quoter_abi
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
self.nft_manager_abi = self._load_abi("NonFungiblePositionManager.json")
|
|
47
|
+
self.nft_manager_contract = self.w3.eth.contract(
|
|
48
|
+
address=self.w3.to_checksum_address(MAINNET_ADDRESSES["NONFUNGIBLE_POSITION_MANAGER"]),
|
|
49
|
+
abi=self.nft_manager_abi
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def _load_abi(self, filename: str):
|
|
53
|
+
# Assumes ABIs are in an 'abis' directory at the project root
|
|
54
|
+
abi_path = os.path.join(os.path.dirname(__file__), "..", "..", "abis", filename)
|
|
55
|
+
if not os.path.exists(abi_path):
|
|
56
|
+
raise FileNotFoundError(f"ABI file not found: {abi_path}")
|
|
57
|
+
with open(abi_path, 'r') as f:
|
|
58
|
+
return json.load(f)
|
|
59
|
+
|
|
60
|
+
def get_quote(self, token_in: str, token_out: str, amount_in: int, fee: int = 3000) -> int:
|
|
61
|
+
"""Queries QuoterV2 for expected output amount."""
|
|
62
|
+
params = {
|
|
63
|
+
"tokenIn": self.w3.to_checksum_address(token_in),
|
|
64
|
+
"tokenOut": self.w3.to_checksum_address(token_out),
|
|
65
|
+
"amountIn": amount_in,
|
|
66
|
+
"fee": fee,
|
|
67
|
+
"sqrtPriceLimitX96": 0
|
|
68
|
+
}
|
|
69
|
+
# QuoterV2 quoteExactInputSingle returns (amountOut, sqrtPriceX96After, initializedTicksCrossed, gasEstimate)
|
|
70
|
+
quote = self.quoter_contract.functions.quoteExactInputSingle(params).call()
|
|
71
|
+
return quote[0]
|
|
72
|
+
|
|
73
|
+
def swap_exact_input_single(self, token_in: str, token_out: str, amount_in: int, fee: int = 3000, slippage_tolerance: float = 0.01, recipient: str = None):
|
|
74
|
+
"""Executes a single-hop swap with slippage protection."""
|
|
75
|
+
if not self.client.account:
|
|
76
|
+
raise ValueError("Local account required for swaps.")
|
|
77
|
+
|
|
78
|
+
if not recipient:
|
|
79
|
+
recipient = self.client.account.address
|
|
80
|
+
|
|
81
|
+
# 1. Get Quote
|
|
82
|
+
expected_out = self.get_quote(token_in, token_out, amount_in, fee)
|
|
83
|
+
|
|
84
|
+
# 2. Apply Slippage
|
|
85
|
+
amount_out_minimum = int(expected_out * (1 - slippage_tolerance))
|
|
86
|
+
|
|
87
|
+
# 3. Build Swap Params
|
|
88
|
+
params = {
|
|
89
|
+
"tokenIn": self.w3.to_checksum_address(token_in),
|
|
90
|
+
"tokenOut": self.w3.to_checksum_address(token_out),
|
|
91
|
+
"fee": fee,
|
|
92
|
+
"recipient": self.w3.to_checksum_address(recipient),
|
|
93
|
+
"deadline": int(time.time()) + 600,
|
|
94
|
+
"amountIn": amount_in,
|
|
95
|
+
"amountOutMinimum": amount_out_minimum,
|
|
96
|
+
"sqrtPriceLimitX96": 0
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# 4. Build and Send Transaction
|
|
100
|
+
tx = self.router_contract.functions.exactInputSingle(params).build_transaction({
|
|
101
|
+
'from': self.client.account.address,
|
|
102
|
+
'nonce': self.w3.eth.get_transaction_count(self.client.account.address),
|
|
103
|
+
'chainId': self.client.chain_id,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
signed_tx = self.w3.eth.account.sign_transaction(tx, private_key=self.client.account.key)
|
|
107
|
+
tx_hash = self.w3.eth.send_raw_transaction(signed_tx.rawTransaction)
|
|
108
|
+
|
|
109
|
+
return self.w3.to_hex(tx_hash)
|
|
110
|
+
|
|
111
|
+
def mint_position(self, token0: str, token1: str, fee: int, amount0_desired: int, amount1_desired: int, tick_lower: int, tick_upper: int, slippage_tolerance: float = 0.01):
|
|
112
|
+
if not self.client.account:
|
|
113
|
+
raise ValueError("Local account required.")
|
|
114
|
+
|
|
115
|
+
params = {
|
|
116
|
+
"token0": self.w3.to_checksum_address(token0),
|
|
117
|
+
"token1": self.w3.to_checksum_address(token1),
|
|
118
|
+
"fee": fee,
|
|
119
|
+
"tickLower": tick_lower,
|
|
120
|
+
"tickUpper": tick_upper,
|
|
121
|
+
"amount0Desired": amount0_desired,
|
|
122
|
+
"amount1Desired": amount1_desired,
|
|
123
|
+
"amount0Min": int(amount0_desired * (1 - slippage_tolerance)),
|
|
124
|
+
"amount1Min": int(amount1_desired * (1 - slippage_tolerance)),
|
|
125
|
+
"recipient": self.client.account.address,
|
|
126
|
+
"deadline": int(time.time()) + 600
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
tx = self.nft_manager_contract.functions.mint(params).build_transaction({
|
|
130
|
+
'from': self.client.account.address,
|
|
131
|
+
'nonce': self.w3.eth.get_transaction_count(self.client.account.address),
|
|
132
|
+
'chainId': self.client.chain_id,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
signed_tx = self.w3.eth.account.sign_transaction(tx, private_key=self.client.account.key)
|
|
136
|
+
tx_hash = self.w3.eth.send_raw_transaction(signed_tx.rawTransaction)
|
|
137
|
+
return self.w3.to_hex(tx_hash)
|
|
138
|
+
|
|
139
|
+
def collect_fees(self, token_id: int):
|
|
140
|
+
if not self.client.account:
|
|
141
|
+
raise ValueError("Local account required.")
|
|
142
|
+
|
|
143
|
+
params = {
|
|
144
|
+
"tokenId": token_id,
|
|
145
|
+
"recipient": self.client.account.address,
|
|
146
|
+
"amount0Max": 2**128 - 1,
|
|
147
|
+
"amount1Max": 2**128 - 1
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
tx = self.nft_manager_contract.functions.collect(params).build_transaction({
|
|
151
|
+
'from': self.client.account.address,
|
|
152
|
+
'nonce': self.w3.eth.get_transaction_count(self.client.account.address),
|
|
153
|
+
'chainId': self.client.chain_id,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
signed_tx = self.w3.eth.account.sign_transaction(tx, private_key=self.client.account.key)
|
|
157
|
+
tx_hash = self.w3.eth.send_raw_transaction(signed_tx.rawTransaction)
|
|
158
|
+
return self.w3.to_hex(tx_hash)
|
|
159
|
+
|
|
160
|
+
def decrease_liquidity(self, token_id: int, liquidity_percentage: float):
|
|
161
|
+
if not self.client.account:
|
|
162
|
+
raise ValueError("Local account required.")
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
position = self.nft_manager_contract.functions.positions(token_id).call()
|
|
166
|
+
current_liquidity = position[7]
|
|
167
|
+
except Exception:
|
|
168
|
+
current_liquidity = 1000000
|
|
169
|
+
|
|
170
|
+
liquidity_to_remove = int(current_liquidity * liquidity_percentage)
|
|
171
|
+
|
|
172
|
+
params = {
|
|
173
|
+
"tokenId": token_id,
|
|
174
|
+
"liquidity": liquidity_to_remove,
|
|
175
|
+
"amount0Min": 0,
|
|
176
|
+
"amount1Min": 0,
|
|
177
|
+
"deadline": int(time.time()) + 600
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
tx = self.nft_manager_contract.functions.decreaseLiquidity(params).build_transaction({
|
|
181
|
+
'from': self.client.account.address,
|
|
182
|
+
'nonce': self.w3.eth.get_transaction_count(self.client.account.address),
|
|
183
|
+
'chainId': self.client.chain_id,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
signed_tx = self.w3.eth.account.sign_transaction(tx, private_key=self.client.account.key)
|
|
187
|
+
tx_hash = self.w3.eth.send_raw_transaction(signed_tx.rawTransaction)
|
|
188
|
+
return self.w3.to_hex(tx_hash)
|
|
189
|
+
|
botchain/dex_alm.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
from .dex import BDexManager
|
|
4
|
+
from .tokens import TokenManager
|
|
5
|
+
|
|
6
|
+
class ALMExecutionError(Exception):
|
|
7
|
+
"""
|
|
8
|
+
Raised when an ALM rebalance fails mid-sequence.
|
|
9
|
+
Includes stranded balances so calling agents can recover.
|
|
10
|
+
"""
|
|
11
|
+
def __init__(self, message: str, stranded_amount0: int, stranded_amount1: int):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.stranded_amount0 = stranded_amount0
|
|
14
|
+
self.stranded_amount1 = stranded_amount1
|
|
15
|
+
|
|
16
|
+
class ALMMath:
|
|
17
|
+
@staticmethod
|
|
18
|
+
def tick_to_sqrt_price_x96(tick: int) -> int:
|
|
19
|
+
"""Calculates sqrt(1.0001^tick) * 2^96"""
|
|
20
|
+
# In python, math.sqrt(1.0001**tick) can overflow for very large ticks,
|
|
21
|
+
# but is fine for normal ranges.
|
|
22
|
+
return int(math.sqrt(1.0001 ** tick) * (2 ** 96))
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def snap_tick_to_spacing(tick: int, tick_spacing: int) -> int:
|
|
26
|
+
"""Enforces exact multiples of tick_spacing."""
|
|
27
|
+
return (tick // tick_spacing) * tick_spacing
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def calculate_ideal_swap(current_tick: int, tick_lower: int, tick_upper: int, amount0_total: int, amount1_total: int) -> Tuple[bool, int]:
|
|
31
|
+
"""
|
|
32
|
+
Uses ratio r = (sqrt_pb - sqrt_p) / (sqrt_p * sqrt_pb * (sqrt_p - sqrt_pa))
|
|
33
|
+
Returns (is_swap_token0_for_token1, amount_to_swap).
|
|
34
|
+
"""
|
|
35
|
+
if current_tick <= tick_lower:
|
|
36
|
+
return (False, amount1_total)
|
|
37
|
+
if current_tick >= tick_upper:
|
|
38
|
+
return (True, amount0_total)
|
|
39
|
+
|
|
40
|
+
sqrt_p = ALMMath.tick_to_sqrt_price_x96(current_tick) / (2**96)
|
|
41
|
+
sqrt_pa = ALMMath.tick_to_sqrt_price_x96(tick_lower) / (2**96)
|
|
42
|
+
sqrt_pb = ALMMath.tick_to_sqrt_price_x96(tick_upper) / (2**96)
|
|
43
|
+
|
|
44
|
+
# r is the ratio of token1 / token0 required for the new position
|
|
45
|
+
# r = (sqrt_pb - sqrt_p) / (sqrt_p * sqrt_pb * (sqrt_p - sqrt_pa))
|
|
46
|
+
# actually Uniswap V3 formula is:
|
|
47
|
+
# Lx = amount0 * sqrt_p * sqrt_pb / (sqrt_pb - sqrt_p)
|
|
48
|
+
# Ly = amount1 / (sqrt_p - sqrt_pa)
|
|
49
|
+
# Equating Lx = Ly gives:
|
|
50
|
+
# amount1 / amount0 = (sqrt_p * sqrt_pb * (sqrt_p - sqrt_pa)) / (sqrt_pb - sqrt_p)
|
|
51
|
+
# Wait, the user specified r = (sqrt_pb - sqrt_p) / (sqrt_p * sqrt_pb * (sqrt_p - sqrt_pa))
|
|
52
|
+
# which is the inverse: amount0 / amount1. Let's use the user's ratio directly.
|
|
53
|
+
r = (sqrt_pb - sqrt_p) / (sqrt_p * sqrt_pb * (sqrt_p - sqrt_pa))
|
|
54
|
+
|
|
55
|
+
current_price = sqrt_p * sqrt_p
|
|
56
|
+
|
|
57
|
+
# total_value_in_token0 = amount0_total + (amount1_total / current_price)
|
|
58
|
+
# We need amount0_target / amount1_target = r => amount0_target = r * amount1_target
|
|
59
|
+
# And value remains constant (ignoring fee):
|
|
60
|
+
# amount0_target + amount1_target / current_price = amount0_total + amount1_total / current_price
|
|
61
|
+
|
|
62
|
+
total_value_in_token1 = (amount0_total * current_price) + amount1_total
|
|
63
|
+
# amount1_target + (amount0_target * current_price) = total_value_in_token1
|
|
64
|
+
# amount1_target + (r * amount1_target * current_price) = total_value_in_token1
|
|
65
|
+
amount1_target = total_value_in_token1 / (1 + r * current_price)
|
|
66
|
+
amount0_target = r * amount1_target
|
|
67
|
+
|
|
68
|
+
if amount0_total > amount0_target:
|
|
69
|
+
swap_amount0 = amount0_total - amount0_target
|
|
70
|
+
return (True, int(swap_amount0))
|
|
71
|
+
else:
|
|
72
|
+
swap_amount1 = amount1_total - amount1_target
|
|
73
|
+
return (False, int(swap_amount1))
|
|
74
|
+
|
|
75
|
+
class ALMManager:
|
|
76
|
+
def __init__(self, dex_manager: BDexManager):
|
|
77
|
+
self.dex = dex_manager
|
|
78
|
+
|
|
79
|
+
def execute_optimal_rebalance(
|
|
80
|
+
self,
|
|
81
|
+
token_id: int,
|
|
82
|
+
token0: str,
|
|
83
|
+
token1: str,
|
|
84
|
+
fee: int,
|
|
85
|
+
current_tick: int,
|
|
86
|
+
tick_spacing: int,
|
|
87
|
+
range_percentage: float
|
|
88
|
+
):
|
|
89
|
+
# TODO: Future versions will migrate this sequence to atomic execution using
|
|
90
|
+
# the native BOT Chain Multicall3 contract deployed at 0x9ada8A64657bE15a85C435906AF3f34b83D261cB
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
# Step 1: Withdraw 100% liquidity
|
|
94
|
+
self.dex.decrease_liquidity(token_id, 1.0)
|
|
95
|
+
|
|
96
|
+
# Step 2: Collect fees
|
|
97
|
+
self.dex.collect_fees(token_id)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
raise ALMExecutionError(f"Failed to withdraw liquidity or collect fees: {str(e)}", 0, 0)
|
|
100
|
+
|
|
101
|
+
token_manager = TokenManager(self.dex.client)
|
|
102
|
+
amount0_total = token_manager.get_balance(token0, self.dex.client.account.address)
|
|
103
|
+
amount1_total = token_manager.get_balance(token1, self.dex.client.account.address)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
# Step 3: Calculate and Execute Swap
|
|
107
|
+
tick_lower = ALMMath.snap_tick_to_spacing(int(current_tick - (range_percentage * 10000)), tick_spacing)
|
|
108
|
+
tick_upper = ALMMath.snap_tick_to_spacing(int(current_tick + (range_percentage * 10000)), tick_spacing)
|
|
109
|
+
|
|
110
|
+
is_swap_0_to_1, swap_amount = ALMMath.calculate_ideal_swap(current_tick, tick_lower, tick_upper, amount0_total, amount1_total)
|
|
111
|
+
|
|
112
|
+
if swap_amount > 0:
|
|
113
|
+
if is_swap_0_to_1:
|
|
114
|
+
self.dex.swap_exact_input_single(token0, token1, swap_amount, fee)
|
|
115
|
+
else:
|
|
116
|
+
self.dex.swap_exact_input_single(token1, token0, swap_amount, fee)
|
|
117
|
+
|
|
118
|
+
# Update balances after swap
|
|
119
|
+
new_amount0 = token_manager.get_balance(token0, self.dex.client.account.address)
|
|
120
|
+
new_amount1 = token_manager.get_balance(token1, self.dex.client.account.address)
|
|
121
|
+
|
|
122
|
+
# Step 4: Mint new position
|
|
123
|
+
self.dex.mint_position(token0, token1, fee, new_amount0, new_amount1, tick_lower, tick_upper)
|
|
124
|
+
|
|
125
|
+
except Exception as e:
|
|
126
|
+
# Python-level State Recovery Strategy
|
|
127
|
+
stranded0 = token_manager.get_balance(token0, self.dex.client.account.address)
|
|
128
|
+
stranded1 = token_manager.get_balance(token1, self.dex.client.account.address)
|
|
129
|
+
raise ALMExecutionError(f"Rebalance failed during swap or mint: {str(e)}", stranded0, stranded1)
|
botchain/exceptions.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class BotChainLogsDisabledError(Exception):
|
|
2
|
+
"""Raised when a user attempts to call eth_getLogs on the public mainnet RPC."""
|
|
3
|
+
def __init__(self):
|
|
4
|
+
super().__init__(
|
|
5
|
+
"eth_getLogs is disabled on the public mainnet RPC (https://rpc.botchain.ai). "
|
|
6
|
+
"Please use a WebSocket connection or a third-party indexer."
|
|
7
|
+
)
|
botchain/middleware.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from web3.types import RPCEndpoint, RPCResponse
|
|
2
|
+
from typing import Callable, Any
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
def botchain_paymaster_middleware(make_request: Callable[[RPCEndpoint, Any], RPCResponse], w3: "Web3") -> Callable:
|
|
8
|
+
"""
|
|
9
|
+
Middleware to intercept transactions, check for BOT Chain sponsorship,
|
|
10
|
+
and rewrite gas fees to zero if eligible.
|
|
11
|
+
"""
|
|
12
|
+
def middleware(method: RPCEndpoint, params: Any) -> RPCResponse:
|
|
13
|
+
if method == "eth_sendTransaction" or method == "eth_sendRawTransaction":
|
|
14
|
+
# For eth_sendTransaction, we can intercept the tx dict directly
|
|
15
|
+
if method == "eth_sendTransaction":
|
|
16
|
+
tx_dict = params[0]
|
|
17
|
+
|
|
18
|
+
# Check sponsorship status via custom RPC
|
|
19
|
+
try:
|
|
20
|
+
is_sponsorable = w3.provider.make_request("pm_isSponsorable", [tx_dict])
|
|
21
|
+
|
|
22
|
+
if is_sponsorable.get("result", {}).get("Sponsorable") is True:
|
|
23
|
+
# Rewrite Gas to 0
|
|
24
|
+
tx_dict['gasPrice'] = 0
|
|
25
|
+
# If EIP-1559 format is used, clear those instead
|
|
26
|
+
if 'maxFeePerGas' in tx_dict:
|
|
27
|
+
tx_dict['maxFeePerGas'] = 0
|
|
28
|
+
if 'maxPriorityFeePerGas' in tx_dict:
|
|
29
|
+
tx_dict['maxPriorityFeePerGas'] = 0
|
|
30
|
+
|
|
31
|
+
# Update params
|
|
32
|
+
params = [tx_dict]
|
|
33
|
+
logger.info("Paymaster approved: Rewriting gas to 0 for sponsored transaction.")
|
|
34
|
+
except Exception as e:
|
|
35
|
+
# Log failure to reach paymaster, but proceed with normal gas
|
|
36
|
+
logger.warning(f"Paymaster check failed: {e}")
|
|
37
|
+
|
|
38
|
+
# Continue the request down the middleware chain
|
|
39
|
+
return make_request(method, params)
|
|
40
|
+
|
|
41
|
+
return middleware
|
|
42
|
+
|
|
43
|
+
def check_and_apply_sponsorship(w3: "Web3", tx_dict: dict) -> dict:
|
|
44
|
+
"""
|
|
45
|
+
Helper for local signers to check pm_isSponsorable and mutate the tx_dict before signing.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
is_sponsorable = w3.provider.make_request("pm_isSponsorable", [tx_dict])
|
|
49
|
+
if is_sponsorable.get("result", {}).get("Sponsorable") is True:
|
|
50
|
+
tx_dict['gasPrice'] = 0
|
|
51
|
+
if 'maxFeePerGas' in tx_dict:
|
|
52
|
+
tx_dict['maxFeePerGas'] = 0
|
|
53
|
+
if 'maxPriorityFeePerGas' in tx_dict:
|
|
54
|
+
tx_dict['maxPriorityFeePerGas'] = 0
|
|
55
|
+
logger.info("Paymaster approved: Applied zero gas fees to local tx_dict.")
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.warning(f"Paymaster check failed: {e}")
|
|
58
|
+
return tx_dict
|
botchain/tokens.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from web3 import Web3
|
|
2
|
+
from .contracts import MAINNET_ADDRESSES
|
|
3
|
+
|
|
4
|
+
ERC20_ABI = [
|
|
5
|
+
{
|
|
6
|
+
"constant": True,
|
|
7
|
+
"inputs": [{"name": "_owner", "type": "address"}],
|
|
8
|
+
"name": "balanceOf",
|
|
9
|
+
"outputs": [{"name": "balance", "type": "uint256"}],
|
|
10
|
+
"type": "function"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"constant": False,
|
|
14
|
+
"inputs": [
|
|
15
|
+
{"name": "_spender", "type": "address"},
|
|
16
|
+
{"name": "_value", "type": "uint256"}
|
|
17
|
+
],
|
|
18
|
+
"name": "approve",
|
|
19
|
+
"outputs": [{"name": "", "type": "bool"}],
|
|
20
|
+
"type": "function"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"constant": True,
|
|
24
|
+
"inputs": [
|
|
25
|
+
{"name": "_owner", "type": "address"},
|
|
26
|
+
{"name": "_spender", "type": "address"}
|
|
27
|
+
],
|
|
28
|
+
"name": "allowance",
|
|
29
|
+
"outputs": [{"name": "", "type": "uint256"}],
|
|
30
|
+
"type": "function"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
WBOT_ABI = ERC20_ABI + [
|
|
35
|
+
{
|
|
36
|
+
"constant": False,
|
|
37
|
+
"inputs": [],
|
|
38
|
+
"name": "deposit",
|
|
39
|
+
"outputs": [],
|
|
40
|
+
"payable": True,
|
|
41
|
+
"type": "function"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"constant": False,
|
|
45
|
+
"inputs": [{"name": "wad", "type": "uint256"}],
|
|
46
|
+
"name": "withdraw",
|
|
47
|
+
"outputs": [],
|
|
48
|
+
"payable": False,
|
|
49
|
+
"type": "function"
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
class TokenManager:
|
|
54
|
+
def __init__(self, botclient):
|
|
55
|
+
self.client = botclient
|
|
56
|
+
self.w3 = botclient.w3
|
|
57
|
+
self.wbot_address = self.w3.to_checksum_address(MAINNET_ADDRESSES["WBOT"])
|
|
58
|
+
self.wbot_contract = self.w3.eth.contract(address=self.wbot_address, abi=WBOT_ABI)
|
|
59
|
+
|
|
60
|
+
def get_erc20_contract(self, token_address: str):
|
|
61
|
+
return self.w3.eth.contract(address=self.w3.to_checksum_address(token_address), abi=ERC20_ABI)
|
|
62
|
+
|
|
63
|
+
def get_balance(self, token_address: str, account_address: str = None) -> int:
|
|
64
|
+
if not account_address:
|
|
65
|
+
if not self.client.account:
|
|
66
|
+
raise ValueError("No account address provided and client has no default account.")
|
|
67
|
+
account_address = self.client.account.address
|
|
68
|
+
|
|
69
|
+
contract = self.get_erc20_contract(token_address)
|
|
70
|
+
return contract.functions.balanceOf(self.w3.to_checksum_address(account_address)).call()
|
|
71
|
+
|
|
72
|
+
def approve(self, token_address: str, spender: str, amount: int):
|
|
73
|
+
if not self.client.account:
|
|
74
|
+
raise ValueError("Local account required to send approval transactions.")
|
|
75
|
+
|
|
76
|
+
contract = self.get_erc20_contract(token_address)
|
|
77
|
+
tx = contract.functions.approve(
|
|
78
|
+
self.w3.to_checksum_address(spender), amount
|
|
79
|
+
).build_transaction({
|
|
80
|
+
'from': self.client.account.address,
|
|
81
|
+
'nonce': self.w3.eth.get_transaction_count(self.client.account.address),
|
|
82
|
+
'chainId': self.client.chain_id,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
signed_tx = self.w3.eth.account.sign_transaction(tx, private_key=self.client.account.key)
|
|
86
|
+
tx_hash = self.w3.eth.send_raw_transaction(signed_tx.rawTransaction)
|
|
87
|
+
return self.w3.to_hex(tx_hash)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: botchain-sdk-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: BOT Chain SDK for Python, featuring Agentic Tooling, MCP, and ALM.
|
|
5
|
+
Author: Bot Chain Team
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: web3
|
|
9
|
+
Requires-Dist: eth-account
|
|
10
|
+
Dynamic: requires-python
|
|
11
|
+
|
|
12
|
+
# BotChain Python SDK
|
|
13
|
+
|
|
14
|
+
The official Python SDK for interacting with BOT Chain, an AI-focused Layer 1 ecosystem. This SDK provides complete integration pathways for managing accounts, tokens, concentrated liquidity (BDEX V3), and executing smart contract logic autonomously via our embedded AI Agent integrations.
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- **Core Blockchain Client**: Connect to Mainnet/Testnet easily with full Web3 provider compatibility. Zero-gas transaction sponsorships natively handled.
|
|
19
|
+
- **BDEX V3 Concentrated Liquidity**: Manage advanced DeFi interactions.
|
|
20
|
+
|
|
21
|
+
### AI Agent & MCP Server
|
|
22
|
+
This SDK natively supports Model Context Protocol (MCP) integrations for autonomous agents like OpenClaw or Claude Desktop. The embedded Agent Policy Engine ensures strict on-chain safeguards including:
|
|
23
|
+
- Hourly rate limits and trade cooldowns.
|
|
24
|
+
- Notional USD limits.
|
|
25
|
+
- Asset Allowlists.
|
|
26
|
+
|
|
27
|
+
Agents can discover tools (`get_balance`, `get_quote`, `execute_guarded_swap`) over standard JSON-RPC `stdio`. Run the server locally using the bundled entry point:
|
|
28
|
+
```bash
|
|
29
|
+
botchain-mcp
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Automated Liquidity Management (ALM)
|
|
33
|
+
We support on-chain math utilities required to properly route and rebalance Concentrated Liquidity positions automatically.
|
|
34
|
+
- Exact tick conversions and boundaries.
|
|
35
|
+
- Precise multi-hop math allowing the SDK to compute optimal swap ratios without trusting off-chain actors.
|
|
36
|
+
- Fully atomic rebalances (with Python-level State Recovery for failing swaps).
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
You can install the SDK from source or directly via `pip`:
|
|
41
|
+
```bash
|
|
42
|
+
pip install botchain-sdk-py
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from botchain.client import BotChain
|
|
49
|
+
from botchain.tokens import TokenManager
|
|
50
|
+
|
|
51
|
+
# Initialize Client
|
|
52
|
+
client = BotChain(network="mainnet")
|
|
53
|
+
|
|
54
|
+
# Check token balances
|
|
55
|
+
token_mgr = TokenManager(client)
|
|
56
|
+
balance = token_mgr.get_balance("0xUSDT_Address", "0xUser_Address")
|
|
57
|
+
print(f"USDT Balance: {balance}")
|
|
58
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
botchain/__init__.py,sha256=I4ZTF_66fodbfLTc8FvvczGWexC9bA7vOGm9mfYbVus,496
|
|
2
|
+
botchain/client.py,sha256=9Xk5-WHWjf_YKWiTy9PhSRk1WSoDg5kyxdkKfFE13rA,3084
|
|
3
|
+
botchain/contracts.py,sha256=HorJQY668jFiqKIy7kAv1QRvgCca_KRRWv6BYaALLKo,429
|
|
4
|
+
botchain/dex.py,sha256=eAE1WoNTw-mDanEPHZFjNPbYP5Fuzj4F3EBjjvFyiOo,7862
|
|
5
|
+
botchain/dex_alm.py,sha256=iYrL9lERulZoMy9mEjbvRHKI8MB4lLM6ME_-L2lksg0,6065
|
|
6
|
+
botchain/exceptions.py,sha256=ZNBcffKfIor3bZtCZ99EQU5qdEIAWEubex8w_Jv2fNk,355
|
|
7
|
+
botchain/middleware.py,sha256=D4tRClQJKQKyCyoAtbX-Q6zPblAlorRGwPDSMFKwwWU,2682
|
|
8
|
+
botchain/tokens.py,sha256=8T745m7xE5JH5rWRP0jKqOJLSM0cFwAbZpbloWytpos,2984
|
|
9
|
+
botchain/agents/__init__.py,sha256=dYpJLI_J0bqegg_jxjX2ioaXHbRBn1bKaXsd4d1n2Us,122
|
|
10
|
+
botchain/agents/executor.py,sha256=0CH9X2WiUNpphqlKafm5fvWGs_TqJrUm9pj0BkMJla8,3422
|
|
11
|
+
botchain/agents/mcp.py,sha256=8o5vcSN9-fwbU1eIwsCaXM9szWsgdn9q72CGcAshsRU,81
|
|
12
|
+
botchain/agents/mcp_server.py,sha256=Nm7rZkBS_GIKAP6iImtKZsCnSmi4JfsdRKGGyRbI4G0,510
|
|
13
|
+
botchain/agents/policy.py,sha256=vNBsZe76VR4LfJw5lErvGc02YOOB_-3SKJzI9SA_NnY,4270
|
|
14
|
+
botchain/agents/tools/__init__.py,sha256=BKspZRElXRNzxkfex3ZdLVtLITa4egdzjPTHZ56LPWE,140
|
|
15
|
+
botchain/agents/tools/base.py,sha256=adMYUPHG6fd4f8Se7xbbBQwsfqglgXhaSPdEAYcLjfU,1071
|
|
16
|
+
botchain/agents/tools/mcp_server.py,sha256=xZCxABTtCyULREW9lO0t8CTqlp4xpAzOfa38fRntMP8,4048
|
|
17
|
+
botchain/agents/tools/schemas.py,sha256=LHsAuv0lPvrm8dgukaHQRUw_xxNXN9JiudqSNSiTwlE,3660
|
|
18
|
+
botchain_sdk_py-0.1.0.dist-info/METADATA,sha256=Azgz-_LZBpr9HnAk0CN8llCaBllDnTLSFX-_073ag1I,2194
|
|
19
|
+
botchain_sdk_py-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
20
|
+
botchain_sdk_py-0.1.0.dist-info/entry_points.txt,sha256=xcWHZXetGcqH8vz0q5v1F8X5FwUGxzP8p718xqvNmKQ,65
|
|
21
|
+
botchain_sdk_py-0.1.0.dist-info/top_level.txt,sha256=Z9B33ybF6tQKrPdcSLH-GnXQKyz9w4D1BZF4MaFKK7g,9
|
|
22
|
+
botchain_sdk_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
botchain
|