Skip to content

Commit 0d7e49d

Browse files
v14.1.9 (#40)
1 parent 291a3cb commit 0d7e49d

File tree

5 files changed

+285
-79
lines changed

5 files changed

+285
-79
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "sakit"
3-
version = "14.1.8"
3+
version = "14.1.9"
44
description = "Solana Agent Kit"
55
authors = ["Bevan Hunt <bevan@bevanhunt.com>"]
66
license = "MIT"

sakit/privy_trigger.py

Lines changed: 60 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
from solders.transaction import VersionedTransaction # type: ignore
1919
from solders.message import to_bytes_versioned # type: ignore
2020

21-
from sakit.utils.trigger import JupiterTrigger
21+
from sakit.utils.trigger import (
22+
JupiterTrigger,
23+
replace_blockhash_in_transaction,
24+
get_fresh_blockhash,
25+
)
2226
from sakit.utils.wallet import send_raw_transaction_with_priority
2327

2428
logger = logging.getLogger(__name__)
@@ -342,14 +346,45 @@ async def _sign_and_execute( # pragma: no cover
342346
transaction_base64: str,
343347
request_id: str,
344348
) -> Dict[str, Any]:
345-
"""Sign with payer (if configured) and Privy, then execute."""
349+
"""
350+
Replace blockhash, sign with payer (if configured) and Privy, then send.
351+
352+
Jupiter's /execute endpoint always times out with 504, so we:
353+
1. Get a fresh blockhash from our RPC
354+
2. Replace the blockhash in Jupiter's transaction
355+
3. Sign with our keys
356+
4. Send directly via our RPC
357+
"""
346358
try:
347-
tx_to_sign = transaction_base64
359+
# RPC URL is required - Jupiter's execute doesn't work
360+
if not self._rpc_url:
361+
return {
362+
"status": "error",
363+
"message": "rpc_url must be configured for trigger orders. Jupiter's execute endpoint is broken.",
364+
}
365+
366+
# Step 1: Get fresh blockhash from our RPC
367+
blockhash_result = await get_fresh_blockhash(self._rpc_url)
368+
if "error" in blockhash_result:
369+
return {
370+
"status": "error",
371+
"message": f"Failed to get blockhash: {blockhash_result['error']}",
372+
}
348373

349-
# If payer is configured, sign with payer first
374+
fresh_blockhash = blockhash_result["blockhash"]
375+
logger.info(f"Got fresh blockhash: {fresh_blockhash}")
376+
377+
# Step 2: Replace blockhash in the transaction
378+
tx_with_new_blockhash = replace_blockhash_in_transaction(
379+
transaction_base64, fresh_blockhash
380+
)
381+
382+
tx_to_sign = tx_with_new_blockhash
383+
384+
# Step 3: If payer is configured, sign with payer first
350385
if self._payer_private_key:
351386
payer_keypair = Keypair.from_base58_string(self._payer_private_key)
352-
tx_bytes = base64.b64decode(transaction_base64)
387+
tx_bytes = base64.b64decode(tx_with_new_blockhash)
353388
transaction = VersionedTransaction.from_bytes(tx_bytes)
354389
message_bytes = to_bytes_versioned(transaction.message)
355390
payer_signature = payer_keypair.sign_message(message_bytes)
@@ -361,7 +396,7 @@ async def _sign_and_execute( # pragma: no cover
361396
)
362397
tx_to_sign = base64.b64encode(bytes(partially_signed)).decode("utf-8")
363398

364-
# Sign with Privy using the official SDK
399+
# Step 4: Sign with Privy using the official SDK
365400
signed_tx = await _privy_sign_transaction(
366401
privy_client,
367402
wallet_id,
@@ -375,28 +410,25 @@ async def _sign_and_execute( # pragma: no cover
375410
"message": "Failed to sign transaction via Privy.",
376411
}
377412

378-
# Send via RPC or fallback to Jupiter execute
379-
if self._rpc_url:
380-
# Use configured RPC (Helius recommended) instead of Jupiter's execute endpoint
381-
tx_bytes = base64.b64decode(signed_tx)
382-
send_result = await send_raw_transaction_with_priority(
383-
rpc_url=self._rpc_url,
384-
tx_bytes=tx_bytes,
385-
)
386-
if not send_result.get("success"):
387-
return {
388-
"status": "error",
389-
"message": send_result.get(
390-
"error", "Failed to send transaction"
391-
),
392-
}
393-
return {"status": "success", "signature": send_result.get("signature")}
394-
else:
395-
# Fallback to Jupiter execute if no RPC configured
396-
exec_result = await trigger.execute(signed_tx, request_id)
397-
if not exec_result.success:
398-
return {"status": "error", "message": exec_result.error}
399-
return {"status": "success", "signature": exec_result.signature}
413+
# Step 5: Send via our RPC
414+
tx_bytes = base64.b64decode(signed_tx)
415+
send_result = await send_raw_transaction_with_priority(
416+
rpc_url=self._rpc_url,
417+
tx_bytes=tx_bytes,
418+
skip_confirmation=False, # Now we can wait - blockhash is from our RPC
419+
confirm_timeout=30.0,
420+
)
421+
422+
if not send_result.get("success"):
423+
return {
424+
"status": "error",
425+
"message": send_result.get("error", "Failed to send transaction"),
426+
}
427+
428+
return {
429+
"status": "success",
430+
"signature": send_result.get("signature"),
431+
}
400432

401433
except Exception as e:
402434
logger.exception(f"Failed to sign and execute: {str(e)}")

sakit/utils/trigger.py

Lines changed: 139 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
import httpx
1414

1515
from solders.transaction import VersionedTransaction # type: ignore
16-
from solders.message import to_bytes_versioned # type: ignore
16+
from solders.message import to_bytes_versioned, MessageV0 # type: ignore
17+
from solders.hash import Hash # type: ignore
1718

1819
logger = logging.getLogger(__name__)
1920

@@ -289,51 +290,82 @@ async def execute( # pragma: no cover
289290
self,
290291
signed_transaction: str,
291292
request_id: str,
293+
max_retries: int = 3,
292294
) -> TriggerExecuteResponse:
293295
"""
294296
Execute a signed trigger order transaction.
295297
296298
Args:
297299
signed_transaction: Base64 encoded signed transaction
298300
request_id: Request ID from create/cancel response
301+
max_retries: Number of retries on 504 timeout (default 3)
299302
300303
Returns:
301304
TriggerExecuteResponse with execution result
302305
"""
306+
import asyncio
307+
303308
payload = {
304309
"signedTransaction": signed_transaction,
305310
"requestId": request_id,
306311
}
307312

308-
try:
309-
# Use longer timeout for execute - Jupiter waits for tx confirmation
310-
async with httpx.AsyncClient(timeout=120.0) as client:
311-
response = await client.post(
312-
f"{self.base_url}/execute",
313-
json=payload,
314-
headers=self._headers,
315-
)
313+
last_error = None
314+
for attempt in range(max_retries + 1):
315+
try:
316+
# Use longer timeout for execute - Jupiter waits for tx confirmation
317+
async with httpx.AsyncClient(timeout=120.0) as client:
318+
response = await client.post(
319+
f"{self.base_url}/execute",
320+
json=payload,
321+
headers=self._headers,
322+
)
323+
324+
# Retry on 504 Gateway Timeout
325+
if response.status_code == 504:
326+
last_error = f"504 Gateway Timeout (attempt {attempt + 1}/{max_retries + 1})"
327+
logger.warning(f"Jupiter execute timed out: {last_error}")
328+
if attempt < max_retries:
329+
await asyncio.sleep(2**attempt) # Exponential backoff
330+
continue
331+
return TriggerExecuteResponse(
332+
success=False,
333+
error=f"Jupiter execute endpoint timed out after {max_retries + 1} attempts",
334+
)
335+
336+
if response.status_code != 200:
337+
return TriggerExecuteResponse(
338+
success=False,
339+
error=f"Failed to execute trigger order: {response.status_code} - {response.text}",
340+
)
341+
342+
data = response.json()
343+
status = data.get("status", "")
316344

317-
if response.status_code != 200:
318345
return TriggerExecuteResponse(
319-
success=False,
320-
error=f"Failed to execute trigger order: {response.status_code} - {response.text}",
346+
success=status.lower() == "success",
347+
status=status,
348+
signature=data.get("signature"),
349+
error=data.get("error"),
350+
code=data.get("code", 0),
351+
raw_response=data,
321352
)
322-
323-
data = response.json()
324-
status = data.get("status", "")
325-
326-
return TriggerExecuteResponse(
327-
success=status.lower() == "success",
328-
status=status,
329-
signature=data.get("signature"),
330-
error=data.get("error"),
331-
code=data.get("code", 0),
332-
raw_response=data,
353+
except httpx.TimeoutException as e:
354+
last_error = (
355+
f"Timeout: {str(e)} (attempt {attempt + 1}/{max_retries + 1})"
333356
)
334-
except Exception as e:
335-
logger.exception("Failed to execute trigger order")
336-
return TriggerExecuteResponse(success=False, error=str(e))
357+
logger.warning(f"Jupiter execute request timed out: {last_error}")
358+
if attempt < max_retries:
359+
await asyncio.sleep(2**attempt)
360+
continue
361+
except Exception as e:
362+
logger.exception("Failed to execute trigger order")
363+
return TriggerExecuteResponse(success=False, error=str(e))
364+
365+
return TriggerExecuteResponse(
366+
success=False,
367+
error=last_error or "Failed after all retries",
368+
)
337369

338370
async def get_orders( # pragma: no cover
339371
self,
@@ -393,6 +425,87 @@ async def get_orders( # pragma: no cover
393425
return {"success": False, "error": str(e), "orders": []}
394426

395427

428+
def replace_blockhash_in_transaction( # pragma: no cover
429+
transaction_base64: str,
430+
new_blockhash: str,
431+
) -> str:
432+
"""
433+
Replace the blockhash in a versioned transaction with a fresh one.
434+
435+
This is necessary when the transaction was created by an external service
436+
(like Jupiter) using their RPC, but we need to send it via a different RPC.
437+
The blockhash must be recent and recognized by the sending RPC.
438+
439+
Args:
440+
transaction_base64: Base64 encoded unsigned transaction
441+
new_blockhash: Fresh blockhash string from our RPC
442+
443+
Returns:
444+
Base64 encoded transaction with replaced blockhash (still unsigned)
445+
"""
446+
transaction_bytes = base64.b64decode(transaction_base64)
447+
transaction = VersionedTransaction.from_bytes(transaction_bytes)
448+
449+
# Get the message and replace blockhash
450+
old_message = transaction.message
451+
452+
# Create new message with updated blockhash
453+
new_message = MessageV0(
454+
header=old_message.header,
455+
account_keys=old_message.account_keys,
456+
recent_blockhash=Hash.from_string(new_blockhash),
457+
instructions=old_message.instructions,
458+
address_table_lookups=old_message.address_table_lookups,
459+
)
460+
461+
# Create new unsigned transaction with new message
462+
# Use default signatures (all zeros) since it's unsigned
463+
new_transaction = VersionedTransaction.populate(
464+
new_message,
465+
transaction.signatures, # Keep placeholder signatures
466+
)
467+
468+
return base64.b64encode(bytes(new_transaction)).decode("utf-8")
469+
470+
471+
async def get_fresh_blockhash(rpc_url: str) -> dict: # pragma: no cover
472+
"""
473+
Get a fresh blockhash from the RPC.
474+
475+
Args:
476+
rpc_url: The RPC endpoint URL
477+
478+
Returns:
479+
Dict with 'blockhash' and 'lastValidBlockHeight' on success,
480+
or 'error' on failure.
481+
"""
482+
payload = {
483+
"jsonrpc": "2.0",
484+
"id": 1,
485+
"method": "getLatestBlockhash",
486+
"params": [{"commitment": "confirmed"}],
487+
}
488+
489+
try:
490+
async with httpx.AsyncClient(timeout=10.0) as client:
491+
response = await client.post(rpc_url, json=payload)
492+
if response.status_code != 200:
493+
return {"error": f"RPC error: {response.status_code}"}
494+
495+
data = response.json()
496+
if "error" in data:
497+
return {"error": f"RPC error: {data['error']}"}
498+
499+
result = data.get("result", {}).get("value", {})
500+
return {
501+
"blockhash": result.get("blockhash"),
502+
"lastValidBlockHeight": result.get("lastValidBlockHeight"),
503+
}
504+
except Exception as e:
505+
logger.exception("Failed to get fresh blockhash")
506+
return {"error": str(e)}
507+
508+
396509
def sign_trigger_transaction( # pragma: no cover
397510
transaction_base64: str,
398511
sign_message_func,

sakit/utils/wallet.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ async def send_raw_transaction_with_priority( # pragma: no cover
120120
tx_bytes: bytes,
121121
skip_preflight: bool = True,
122122
max_retries: int = 5,
123+
confirm_timeout: float = 30.0,
124+
skip_confirmation: bool = False,
123125
) -> Dict[str, any]:
124126
"""
125127
Send a raw transaction to Solana RPC, with Helius priority fee logging if applicable.
@@ -132,10 +134,14 @@ async def send_raw_transaction_with_priority( # pragma: no cover
132134
tx_bytes: The serialized signed transaction bytes
133135
skip_preflight: Skip preflight simulation (default True for pre-signed txs)
134136
max_retries: Number of retries for the RPC call
137+
confirm_timeout: Max seconds to wait for confirmation (default 30s)
138+
skip_confirmation: Skip waiting for confirmation entirely (default False)
135139
136140
Returns:
137141
Dict with 'success' and 'signature' on success, or 'error' on failure.
138142
"""
143+
import asyncio
144+
139145
try:
140146
client = AsyncClient(rpc_url)
141147
try:
@@ -183,19 +189,32 @@ async def send_raw_transaction_with_priority( # pragma: no cover
183189
signature = str(result.value)
184190
logger.info(f"Transaction sent: {signature}")
185191

186-
# Confirm the transaction
192+
# Skip confirmation if requested (useful for pre-signed txs with external blockhashes)
193+
if skip_confirmation:
194+
return {"success": True, "signature": signature}
195+
196+
# Confirm the transaction with timeout
187197
try:
188-
confirmation = await client.confirm_transaction(
189-
result.value,
190-
commitment=Confirmed,
191-
sleep_seconds=0.5,
192-
last_valid_block_height=None,
198+
confirmation = await asyncio.wait_for(
199+
client.confirm_transaction(
200+
result.value,
201+
commitment=Confirmed,
202+
sleep_seconds=0.5,
203+
last_valid_block_height=None,
204+
),
205+
timeout=confirm_timeout,
193206
)
194207
if confirmation.value and confirmation.value[0].err:
195208
return {
196209
"success": False,
197210
"error": f"Transaction failed: {confirmation.value[0].err}",
198211
}
212+
except asyncio.TimeoutError:
213+
logger.warning(
214+
f"Transaction confirmation timed out after {confirm_timeout}s. "
215+
f"Transaction may still land. Signature: {signature}"
216+
)
217+
# Return success anyway - tx was sent, just not confirmed in time
199218
except Exception as confirm_error:
200219
logger.debug(f"Could not confirm transaction: {confirm_error}")
201220
# Still return success since transaction was sent

0 commit comments

Comments
 (0)