|
2 | 2 | Jupiter Recurring (DCA) tool for Privy delegated wallets. |
3 | 3 |
|
4 | 4 | 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. |
6 | 6 | """ |
7 | 7 |
|
8 | 8 | import base64 |
9 | | -import json |
10 | 9 | import logging |
11 | 10 | from typing import Dict, Any, List, Optional |
12 | 11 |
|
13 | 12 | 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 |
17 | 15 | from solders.keypair import Keypair # type: ignore |
18 | 16 | from solders.transaction import VersionedTransaction # type: ignore |
19 | 17 | from solders.message import to_bytes_versioned # type: ignore |
|
23 | 21 | logger = logging.getLogger(__name__) |
24 | 22 |
|
25 | 23 |
|
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 | | - |
85 | 24 | async def _get_privy_embedded_wallet( |
86 | | - user_id: str, app_id: str, app_secret: str |
| 25 | + privy_client: AsyncPrivyAPI, user_id: str |
87 | 26 | ) -> 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. |
89 | 28 |
|
90 | 29 | Supports both: |
91 | 30 | - App-first wallets (SDK-created): connector_type == "embedded" with delegated == True |
92 | 31 | - Bot-first wallets (API-created): type == "wallet" with chain_type == "solana" |
93 | 32 | """ |
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 [] |
103 | 36 |
|
104 | 37 | # 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 | + ) |
110 | 46 | if wallet_id and address: |
111 | 47 | return {"wallet_id": wallet_id, "public_key": address} |
112 | 48 |
|
113 | 49 | # 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 | + ) |
119 | 57 | if wallet_id and address: |
120 | 58 | 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 | + ) |
124 | 68 | if wallet_id and address: |
125 | 69 | return {"wallet_id": wallet_id, "public_key": address} |
126 | 70 |
|
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 |
128 | 75 |
|
129 | 76 |
|
130 | 77 | async def _privy_sign_transaction( |
| 78 | + privy_client: AsyncPrivyAPI, |
131 | 79 | wallet_id: str, |
132 | 80 | encoded_tx: str, |
133 | | - app_id: str, |
134 | | - app_secret: str, |
135 | | - privy_auth_key: str, |
| 81 | + signing_key: str, |
136 | 82 | ) -> 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 |
162 | 111 |
|
163 | 112 |
|
164 | 113 | class PrivyRecurringTool(AutoTool): |
|
0 commit comments