Skip to content

Commit b8c35c3

Browse files
add privy
1 parent 3cbb707 commit b8c35c3

File tree

8 files changed

+866
-214
lines changed

8 files changed

+866
-214
lines changed

poetry.lock

Lines changed: 187 additions & 190 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "sakit"
3-
version = "12.0.3"
3+
version = "12.1.0"
44
description = "Solana Agent Kit"
55
authors = ["Bevan Hunt <bevan@bevanhunt.com>"]
66
license = "MIT"
@@ -17,11 +17,11 @@ packages = [{ include = "sakit" }]
1717
python = ">=3.12,<4.0"
1818
httpx = "^0.28.1"
1919
solana-agent = ">=30.0.0"
20-
boto3 = "^1.38.30"
21-
botocore = "^1.38.30"
20+
boto3 = "^1.38.32"
21+
botocore = "^1.38.32"
2222
nemo-agent = "5.0.3"
23-
fastmcp = "^2.6.1"
24-
solana = "^0.36.6"
23+
fastmcp = "^2.7.0"
24+
solana = "^0.36.7"
2525
solders = "^0.26.0"
2626
pynacl = "^1.5.0"
2727
based58 = "^0.1.1"
@@ -41,3 +41,6 @@ solana_swap = "sakit.solana_swap:get_plugin"
4141
solana_balance = "sakit.solana_balance:get_plugin"
4242
solana_price = "sakit.solana_price:get_plugin"
4343
rugcheck = "sakit.rugcheck:get_plugin"
44+
privy_swap = "sakit.privy_swap:get_plugin"
45+
privy_transfer = "sakit.privy_transfer:get_plugin"
46+
privy_balance = "sakit.privy_balance:get_plugin"

sakit/privy_balance.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import logging
2+
from typing import Dict, Any, List, Optional
3+
from solana_agent import AutoTool, ToolRegistry
4+
import httpx
5+
6+
7+
def summarize_alphavybe_balances(api_response: Dict[str, Any]) -> str:
8+
lines = []
9+
sol = api_response.get("solBalance")
10+
sol_usd = api_response.get("solBalanceUsd")
11+
staked_sol = api_response.get("stakedSolBalance")
12+
staked_sol_usd = api_response.get("stakedSolBalanceUsd")
13+
active_staked_sol = api_response.get("activeStakedSolBalance")
14+
active_staked_sol_usd = api_response.get("activeStakedSolBalanceUsd")
15+
total_usd = api_response.get("totalTokenValueUsd")
16+
total_usd_change = api_response.get("totalTokenValueUsd1dChange")
17+
owner = api_response.get("ownerAddress")
18+
19+
lines.append(f"Wallet: {owner}")
20+
if sol is not None:
21+
lines.append(f"SOL balance: {sol} (~${sol_usd})")
22+
if staked_sol is not None:
23+
lines.append(f"Staked SOL: {staked_sol} (~${staked_sol_usd})")
24+
if active_staked_sol is not None:
25+
lines.append(
26+
f"Active Staked SOL: {active_staked_sol} (~${active_staked_sol_usd})"
27+
)
28+
if total_usd is not None:
29+
lines.append(
30+
f"Total wallet value: ${total_usd} (24h change: {total_usd_change})"
31+
)
32+
33+
tokens = api_response.get("data", [])
34+
if tokens:
35+
lines.append("Top tokens:")
36+
for token in tokens[:10]:
37+
name = token.get("name") or token.get("symbol") or token.get("mintAddress")
38+
amount = token.get("amount")
39+
symbol = token.get("symbol") or ""
40+
value_usd = token.get("valueUsd")
41+
verified = "✅" if token.get("verified") else "❌"
42+
lines.append(f" - {name} ({symbol}): {amount} (${value_usd}) {verified}")
43+
else:
44+
lines.append("No SPL tokens found.")
45+
return "\n".join(lines)
46+
47+
48+
async def get_privy_embedded_wallet_address(
49+
user_id: str, app_id: str, app_secret: str
50+
) -> Optional[str]:
51+
url = f"https://auth.privy.io/api/v1/users/{user_id}"
52+
headers = {"privy-app-id": app_id}
53+
auth = (app_id, app_secret)
54+
async with httpx.AsyncClient() as client:
55+
resp = await client.get(url, headers=headers, auth=auth, timeout=10)
56+
resp.raise_for_status()
57+
data = resp.json()
58+
for acct in data.get("linked_accounts", []):
59+
if acct.get("connector_type") == "embedded" and acct.get("delegated"):
60+
return acct["public_key"]
61+
return None
62+
63+
64+
class PrivyBalanceCheckerTool(AutoTool):
65+
def __init__(self, registry: Optional[ToolRegistry] = None):
66+
super().__init__(
67+
name="privy_balance",
68+
description="Check SOL and SPL token balances for a Privy delegated embedded wallet using AlphaVybe API.",
69+
registry=registry,
70+
)
71+
self.api_key = None
72+
self.app_id = None
73+
self.app_secret = None
74+
75+
def get_schema(self) -> Dict[str, Any]:
76+
return {
77+
"type": "object",
78+
"properties": {
79+
"user_id": {
80+
"type": "string",
81+
"description": "Privy user id (did) to check delegated embedded wallet balance.",
82+
}
83+
},
84+
"required": ["user_id"],
85+
"additionalProperties": False,
86+
}
87+
88+
def configure(self, config: Dict[str, Any]) -> None:
89+
tool_cfg = config.get("tools", {}).get("privy_balance", {})
90+
self.api_key = tool_cfg.get("api_key")
91+
self.app_id = tool_cfg.get("app_id")
92+
self.app_secret = tool_cfg.get("app_secret")
93+
94+
async def execute(self, user_id: str) -> Dict[str, Any]:
95+
if not all([self.api_key, self.app_id, self.app_secret]):
96+
return {"status": "error", "message": "Privy or AlphaVybe config missing."}
97+
wallet_address = await get_privy_embedded_wallet_address(
98+
user_id, self.app_id, self.app_secret
99+
)
100+
if not wallet_address:
101+
return {
102+
"status": "error",
103+
"message": "No delegated embedded wallet found for user.",
104+
}
105+
url = f"https://api.vybenetwork.xyz/account/token-balance/{wallet_address}?limit=10&sortByDesc=valueUsd"
106+
headers = {
107+
"accept": "application/json",
108+
"X-API-KEY": self.api_key,
109+
}
110+
try:
111+
async with httpx.AsyncClient(timeout=10.0) as client:
112+
resp = await client.get(url, headers=headers, timeout=15)
113+
resp.raise_for_status()
114+
data = resp.json()
115+
summary = summarize_alphavybe_balances(data)
116+
return {
117+
"status": "success",
118+
"result": summary,
119+
}
120+
except Exception as e:
121+
logging.exception(f"Privy balance check error: {e}")
122+
return {"status": "error", "message": str(e)}
123+
124+
125+
class PrivyBalanceCheckerPlugin:
126+
def __init__(self):
127+
self.name = "privy_balance"
128+
self.config = None
129+
self.tool_registry = None
130+
self._tool = None
131+
132+
@property
133+
def description(self):
134+
return "Plugin for checking SOL and SPL token balances for a Privy delegated embedded wallet using AlphaVybe API."
135+
136+
def initialize(self, tool_registry: ToolRegistry) -> None:
137+
self.tool_registry = tool_registry
138+
self._tool = PrivyBalanceCheckerTool(registry=tool_registry)
139+
140+
def configure(self, config: Dict[str, Any]) -> None:
141+
self.config = config
142+
if self._tool:
143+
self._tool.configure(self.config)
144+
145+
def get_tools(self) -> List[AutoTool]:
146+
return [self._tool] if self._tool else []
147+
148+
149+
def get_plugin():
150+
return PrivyBalanceCheckerPlugin()

sakit/privy_swap.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import base64
2+
import json
3+
import logging
4+
from typing import Dict, Any, List, Optional
5+
from solana_agent import AutoTool, ToolRegistry
6+
import httpx
7+
from cryptography.hazmat.primitives import hashes, serialization
8+
from cryptography.hazmat.primitives.asymmetric import ec
9+
from sakit.utils.wallet import SolanaWalletClient
10+
from sakit.utils.swap import TradeManager
11+
12+
13+
def canonicalize(obj):
14+
return json.dumps(obj, sort_keys=True, separators=(",", ":"))
15+
16+
17+
def get_authorization_signature(url, body, privy_app_id, privy_auth_key):
18+
payload = {
19+
"version": 1,
20+
"method": "POST",
21+
"url": url,
22+
"body": body,
23+
"headers": {"privy-app-id": privy_app_id},
24+
}
25+
serialized_payload = canonicalize(payload)
26+
private_key_string = privy_auth_key.replace("wallet-auth:", "")
27+
private_key_pem = (
28+
f"-----BEGIN PRIVATE KEY-----\n{private_key_string}\n-----END PRIVATE KEY-----"
29+
)
30+
private_key = serialization.load_pem_private_key(
31+
private_key_pem.encode("utf-8"), password=None
32+
)
33+
signature = private_key.sign(
34+
serialized_payload.encode("utf-8"), ec.ECDSA(hashes.SHA256())
35+
)
36+
return base64.b64encode(signature).decode("utf-8")
37+
38+
39+
async def get_privy_embedded_wallet(
40+
user_id: str, app_id: str, app_secret: str
41+
) -> Optional[Dict[str, str]]:
42+
url = f"https://auth.privy.io/api/v1/users/{user_id}"
43+
headers = {"privy-app-id": app_id}
44+
auth = (app_id, app_secret)
45+
async with httpx.AsyncClient() as client:
46+
resp = await client.get(url, headers=headers, auth=auth, timeout=10)
47+
if resp.status_code != 200:
48+
logging.error(f"Privy API error: {resp.text}")
49+
resp.raise_for_status()
50+
data = resp.json()
51+
for acct in data.get("linked_accounts", []):
52+
if acct.get("connector_type") == "embedded" and acct.get("delegated"):
53+
return {"wallet_id": acct["id"], "public_key": acct["public_key"]}
54+
return None
55+
56+
57+
async def privy_sign_and_send(
58+
wallet_id: str, encoded_tx: str, app_id: str, app_secret: str, privy_auth_key: str
59+
) -> Dict[str, Any]:
60+
url = f"https://api.privy.io/v1/wallets/{wallet_id}/rpc"
61+
auth_string = f"{app_id}:{app_secret}"
62+
encoded_auth = base64.b64encode(auth_string.encode()).decode()
63+
body = {
64+
"method": "signAndSendTransaction",
65+
"caip2": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
66+
"params": {"transaction": encoded_tx, "encoding": "base64"},
67+
}
68+
signature = get_authorization_signature(
69+
url=url, body=body, privy_app_id=app_id, privy_auth_key=privy_auth_key
70+
)
71+
headers = {
72+
"Authorization": f"Basic {encoded_auth}",
73+
"privy-app-id": app_id,
74+
"privy-authorization-signature": signature,
75+
"Content-Type": "application/json",
76+
}
77+
async with httpx.AsyncClient() as client:
78+
resp = await client.post(url, headers=headers, json=body, timeout=20)
79+
if resp.status_code != 200:
80+
logging.error(f"Privy API error: {resp.text}")
81+
resp.raise_for_status()
82+
return resp.json()
83+
84+
85+
class PrivySwapTool(AutoTool):
86+
def __init__(self, registry: Optional[ToolRegistry] = None):
87+
super().__init__(
88+
name="privy_swap",
89+
description="Swap tokens using Jupiter via Privy delegated wallet.",
90+
registry=registry,
91+
)
92+
self.app_id = None
93+
self.app_secret = None
94+
self.signing_key = None
95+
self.rpc_url = None
96+
self.jupiter_url = None
97+
self.fee_payer = None
98+
99+
def get_schema(self) -> Dict[str, Any]:
100+
return {
101+
"type": "object",
102+
"properties": {
103+
"user_id": {"type": "string", "description": "Privy user id (did)"},
104+
"output_mint": {
105+
"type": "string",
106+
"description": "The mint address of the token to receive.",
107+
},
108+
"input_amount": {
109+
"type": "number",
110+
"description": "The amount of the input token to swap.",
111+
},
112+
"input_mint": {
113+
"type": "string",
114+
"description": "The mint address of the token to swap from.",
115+
},
116+
},
117+
"required": ["user_id", "output_mint", "input_amount", "input_mint"],
118+
"additionalProperties": False,
119+
}
120+
121+
def configure(self, config: Dict[str, Any]) -> None:
122+
tool_cfg = config.get("tools", {}).get("privy_swap", {})
123+
self.app_id = tool_cfg.get("app_id")
124+
self.app_secret = tool_cfg.get("app_secret")
125+
self.signing_key = tool_cfg.get("signing_key")
126+
self.rpc_url = tool_cfg.get("rpc_url")
127+
self.jupiter_url = tool_cfg.get("jupiter_url")
128+
self.fee_payer = tool_cfg.get("fee_payer")
129+
130+
async def execute(
131+
self,
132+
user_id: str,
133+
output_mint: str,
134+
input_amount: float,
135+
input_mint: str,
136+
slippage_bps: int = 300,
137+
) -> Dict[str, Any]:
138+
if not all(
139+
[
140+
self.app_id,
141+
self.app_secret,
142+
self.signing_key,
143+
self.rpc_url,
144+
self.fee_payer,
145+
]
146+
):
147+
return {"status": "error", "message": "Privy config missing."}
148+
wallet_info = await get_privy_embedded_wallet(
149+
user_id, self.app_id, self.app_secret
150+
)
151+
if not wallet_info:
152+
return {
153+
"status": "error",
154+
"message": "No delegated embedded wallet found for user.",
155+
}
156+
wallet_id = wallet_info["wallet_id"]
157+
try:
158+
wallet = SolanaWalletClient(
159+
self.rpc_url, None, wallet_info["public_key"], self.fee_payer
160+
)
161+
transaction = await TradeManager.trade(
162+
wallet,
163+
output_mint,
164+
input_amount,
165+
input_mint,
166+
slippage_bps,
167+
self.jupiter_url,
168+
True,
169+
)
170+
encoded_transaction = base64.b64encode(bytes(transaction)).decode("utf-8")
171+
result = await privy_sign_and_send(
172+
wallet_id,
173+
encoded_transaction,
174+
self.app_id,
175+
self.app_secret,
176+
self.signing_key,
177+
)
178+
return {"status": "success", "result": result}
179+
except Exception as e:
180+
logging.exception(f"Privy swap failed: {str(e)}")
181+
return {"status": "error", "message": str(e)}
182+
183+
184+
class PrivySwapPlugin:
185+
def __init__(self):
186+
self.name = "privy_swap"
187+
self.config = None
188+
self.tool_registry = None
189+
self._tool = None
190+
191+
@property
192+
def description(self):
193+
return "Plugin for swapping tokens using Jupiter via Privy delegated wallet."
194+
195+
def initialize(self, tool_registry: ToolRegistry) -> None:
196+
self.tool_registry = tool_registry
197+
self._tool = PrivySwapTool(registry=tool_registry)
198+
199+
def configure(self, config: Dict[str, Any]) -> None:
200+
self.config = config
201+
if self._tool:
202+
self._tool.configure(self.config)
203+
204+
def get_tools(self) -> List[AutoTool]:
205+
return [self._tool] if self._tool else []
206+
207+
208+
def get_plugin():
209+
return PrivySwapPlugin()

0 commit comments

Comments
 (0)