Skip to content

Commit 0e5c237

Browse files
v13.3.4 (#23)
1 parent 68edbfc commit 0e5c237

File tree

9 files changed

+1727
-1289
lines changed

9 files changed

+1727
-1289
lines changed

poetry.lock

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

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "sakit"
3-
version = "13.3.3"
3+
version = "13.3.4"
44
description = "Solana Agent Kit"
55
authors = ["Bevan Hunt <bevan@bevanhunt.com>"]
66
license = "MIT"
@@ -26,6 +26,7 @@ solders = "^0.27.1"
2626
pynacl = "^1.6.1"
2727
based58 = "^0.1.1"
2828
openai = "*"
29+
privy-client = "^0.5.0"
2930

3031

3132

sakit/privy_recurring.py

Lines changed: 66 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,16 @@
22
Jupiter Recurring (DCA) tool for Privy delegated wallets.
33
44
Enables creating, canceling, and managing DCA orders using Jupiter Recurring API
5-
with Privy embedded wallets.
5+
with Privy embedded wallets. Uses the official Privy Python SDK.
66
"""
77

88
import base64
9-
import json
109
import logging
1110
from typing import Dict, Any, List, Optional
1211

1312
from solana_agent import AutoTool, ToolRegistry
14-
import httpx
15-
from cryptography.hazmat.primitives import hashes, serialization
16-
from cryptography.hazmat.primitives.asymmetric import ec
13+
from privy import AsyncPrivyAPI
14+
from privy.lib.authorization_signatures import get_authorization_signature
1715
from solders.keypair import Keypair # type: ignore
1816
from solders.transaction import VersionedTransaction # type: ignore
1917
from solders.message import to_bytes_versioned # type: ignore
@@ -23,142 +21,93 @@
2321
logger = logging.getLogger(__name__)
2422

2523

26-
def _canonicalize(obj):
27-
"""Canonicalize JSON for Privy signature."""
28-
return json.dumps(obj, sort_keys=True, separators=(",", ":"))
29-
30-
31-
def _get_authorization_signature(url, body, privy_app_id, privy_auth_key):
32-
"""Generate Privy authorization signature."""
33-
payload = {
34-
"version": 1,
35-
"method": "POST",
36-
"url": url,
37-
"body": body,
38-
"headers": {"privy-app-id": privy_app_id},
39-
}
40-
serialized_payload = _canonicalize(payload)
41-
private_key_string = privy_auth_key.replace("wallet-auth:", "")
42-
43-
# Try to load the key - it could be in different formats
44-
private_key = None
45-
46-
# First, try as PKCS#8 PEM format (standard format from openssl genpkey)
47-
try:
48-
private_key_pem = f"-----BEGIN PRIVATE KEY-----\n{private_key_string}\n-----END PRIVATE KEY-----"
49-
private_key = serialization.load_pem_private_key(
50-
private_key_pem.encode("utf-8"), password=None
51-
)
52-
except (ValueError, TypeError):
53-
pass
54-
55-
# If that fails, try as EC PRIVATE KEY (SEC1) format
56-
if private_key is None:
57-
try:
58-
ec_key_pem = f"-----BEGIN EC PRIVATE KEY-----\n{private_key_string}\n-----END EC PRIVATE KEY-----"
59-
private_key = serialization.load_pem_private_key(
60-
ec_key_pem.encode("utf-8"), password=None
61-
)
62-
except (ValueError, TypeError):
63-
pass
64-
65-
# If that fails, try loading as raw DER bytes (PKCS#8)
66-
if private_key is None:
67-
try:
68-
der_bytes = base64.b64decode(private_key_string)
69-
private_key = serialization.load_der_private_key(der_bytes, password=None)
70-
except (ValueError, TypeError):
71-
pass
72-
73-
if private_key is None:
74-
raise ValueError(
75-
"Could not load private key. Expected base64-encoded PKCS#8 or SEC1 format. "
76-
"Generate with: openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256"
77-
)
78-
79-
signature = private_key.sign(
80-
serialized_payload.encode("utf-8"), ec.ECDSA(hashes.SHA256())
81-
)
82-
return base64.b64encode(signature).decode("utf-8")
83-
84-
8524
async def _get_privy_embedded_wallet(
86-
user_id: str, app_id: str, app_secret: str
25+
privy_client: AsyncPrivyAPI, user_id: str
8726
) -> Optional[Dict[str, str]]:
88-
"""Get Privy embedded wallet info for a user.
27+
"""Get Privy embedded wallet info for a user using the official SDK.
8928
9029
Supports both:
9130
- App-first wallets (SDK-created): connector_type == "embedded" with delegated == True
9231
- Bot-first wallets (API-created): type == "wallet" with chain_type == "solana"
9332
"""
94-
url = f"https://auth.privy.io/api/v1/users/{user_id}"
95-
headers = {"privy-app-id": app_id}
96-
auth = (app_id, app_secret)
97-
async with httpx.AsyncClient() as client:
98-
resp = await client.get(url, headers=headers, auth=auth, timeout=10)
99-
if resp.status_code != 200:
100-
logger.error(f"Privy API error: {resp.text}")
101-
return None
102-
data = resp.json()
33+
try:
34+
user = await privy_client.users.get(user_id)
35+
linked_accounts = user.linked_accounts or []
10336

10437
# First, try to find embedded wallet with delegation
105-
for acct in data.get("linked_accounts", []):
106-
if acct.get("connector_type") == "embedded" and acct.get("delegated"):
107-
wallet_id = acct.get("id")
108-
# Use 'address' field if 'public_key' is null (common for API-created wallets)
109-
address = acct.get("address") or acct.get("public_key")
38+
for acct in linked_accounts:
39+
if getattr(acct, "connector_type", None) == "embedded" and getattr(
40+
acct, "delegated", False
41+
):
42+
wallet_id = getattr(acct, "id", None)
43+
address = getattr(acct, "address", None) or getattr(
44+
acct, "public_key", None
45+
)
11046
if wallet_id and address:
11147
return {"wallet_id": wallet_id, "public_key": address}
11248

11349
# Then, try to find bot-first wallet (API-created via privy_create_wallet)
114-
for acct in data.get("linked_accounts", []):
115-
acct_type = acct.get("type", "")
116-
if acct_type == "wallet" and acct.get("chain_type") == "solana":
117-
wallet_id = acct.get("id")
118-
address = acct.get("address") or acct.get("public_key")
50+
for acct in linked_accounts:
51+
acct_type = getattr(acct, "type", "")
52+
if acct_type == "wallet" and getattr(acct, "chain_type", None) == "solana":
53+
wallet_id = getattr(acct, "id", None)
54+
address = getattr(acct, "address", None) or getattr(
55+
acct, "public_key", None
56+
)
11957
if wallet_id and address:
12058
return {"wallet_id": wallet_id, "public_key": address}
121-
if "solana" in acct_type.lower() and "embedded" in acct_type.lower():
122-
wallet_id = acct.get("id")
123-
address = acct.get("address") or acct.get("public_key")
59+
if (
60+
acct_type
61+
and "solana" in acct_type.lower()
62+
and "embedded" in acct_type.lower()
63+
):
64+
wallet_id = getattr(acct, "id", None)
65+
address = getattr(acct, "address", None) or getattr(
66+
acct, "public_key", None
67+
)
12468
if wallet_id and address:
12569
return {"wallet_id": wallet_id, "public_key": address}
12670

127-
return None
71+
return None
72+
except Exception as e:
73+
logger.error(f"Privy API error getting user {user_id}: {e}")
74+
return None
12875

12976

13077
async def _privy_sign_transaction(
78+
privy_client: AsyncPrivyAPI,
13179
wallet_id: str,
13280
encoded_tx: str,
133-
app_id: str,
134-
app_secret: str,
135-
privy_auth_key: str,
81+
signing_key: str,
13682
) -> Optional[str]:
137-
"""Sign transaction via Privy."""
138-
url = f"https://api.privy.io/v1/wallets/{wallet_id}/rpc"
139-
auth_string = f"{app_id}:{app_secret}"
140-
encoded_auth = base64.b64encode(auth_string.encode()).decode()
141-
body = {
142-
"method": "signTransaction",
143-
"caip2": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
144-
"params": {"transaction": encoded_tx, "encoding": "base64"},
145-
}
146-
signature = _get_authorization_signature(
147-
url=url, body=body, privy_app_id=app_id, privy_auth_key=privy_auth_key
148-
)
149-
headers = {
150-
"Authorization": f"Basic {encoded_auth}",
151-
"privy-app-id": app_id,
152-
"privy-authorization-signature": signature,
153-
"Content-Type": "application/json",
154-
}
155-
async with httpx.AsyncClient() as client:
156-
resp = await client.post(url, headers=headers, json=body, timeout=20)
157-
if resp.status_code != 200:
158-
logger.error(f"Privy API error: {resp.text}")
159-
return None
160-
result = resp.json()
161-
return result.get("data", {}).get("signedTransaction")
83+
"""Sign a Solana transaction via Privy using the official SDK."""
84+
try:
85+
url = f"https://api.privy.io/v1/wallets/{wallet_id}/rpc"
86+
body = {
87+
"method": "signTransaction",
88+
"params": {"transaction": encoded_tx, "encoding": "base64"},
89+
}
90+
91+
auth_signature = get_authorization_signature(
92+
url=url,
93+
body=body,
94+
method="POST",
95+
app_id=privy_client.app_id,
96+
private_key=signing_key,
97+
)
98+
99+
result = await privy_client.wallets.rpc(
100+
wallet_id=wallet_id,
101+
method="signTransaction",
102+
params={"transaction": encoded_tx, "encoding": "base64"},
103+
chain_type="solana",
104+
privy_authorization_signature=auth_signature,
105+
)
106+
107+
return result.data.signed_transaction if result.data else None
108+
except Exception as e:
109+
logger.error(f"Privy API error signing transaction: {e}")
110+
return None
162111

163112

164113
class PrivyRecurringTool(AutoTool):

0 commit comments

Comments
 (0)