atp-protocol 1.0.0__py3-none-any.whl → 1.0.1__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.
atp/solana_utils.py DELETED
@@ -1,1001 +0,0 @@
1
- """
2
- Solana helpers used by the ATP Gateway.
3
-
4
- This module intentionally keeps Solana-specific logic isolated from the FastAPI app.
5
-
6
- Security note:
7
- - `verify_solana_transaction()` currently verifies *success* and that a transaction exists,
8
- but it does NOT parse instructions to prove the recipient + amount match expectations.
9
- For production-grade payment validation, you should parse the transaction and enforce:
10
- - payer == expected sender
11
- - recipient == configured treasury pubkey
12
- - amount == expected lamports / token amount
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import asyncio
18
- import json
19
- from typing import Any, Dict, Iterable, List, Optional, Tuple
20
-
21
- from loguru import logger
22
- from solana.rpc.async_api import AsyncClient as SolanaClient
23
- from solana.rpc.types import TxOpts
24
- from solders.hash import Hash
25
- from solders.keypair import Keypair
26
- from solders.pubkey import Pubkey
27
- from solders.signature import Signature
28
- from solders.system_program import TransferParams, transfer
29
- from solders.transaction import Transaction as SoldersTransaction
30
-
31
- from atp import config
32
- from atp.schemas import PaymentToken
33
-
34
-
35
- def _dbg_enabled() -> bool:
36
- return bool(getattr(config, "ATP_SOLANA_DEBUG", False))
37
-
38
-
39
- def _dbg(msg: str, *args: Any) -> None:
40
- """Emit debug logs only when ATP_SOLANA_DEBUG=true."""
41
- if _dbg_enabled():
42
- logger.debug(msg, *args)
43
-
44
-
45
- def _type_name(v: Any) -> str:
46
- try:
47
- return type(v).__name__
48
- except Exception:
49
- return "<unknown>"
50
-
51
-
52
- def _as_dict(obj: Any) -> Any:
53
- """Best-effort conversion of solana-py response objects into plain dicts."""
54
- if obj is None:
55
- return None
56
- if isinstance(obj, dict):
57
- return obj
58
- # solana-py response objects may expose `.to_json()`
59
- to_json = getattr(obj, "to_json", None)
60
- if callable(to_json):
61
- try:
62
- return json.loads(to_json())
63
- except Exception:
64
- pass
65
- # fallback: try __dict__
66
- d = getattr(obj, "__dict__", None)
67
- return d if isinstance(d, dict) else obj
68
-
69
-
70
- def _unwrap_get_transaction_response(
71
- resp: Any,
72
- ) -> Optional[Dict[str, Any]]:
73
- """Extract the transaction payload from various solana-py response shapes."""
74
- if resp is None:
75
- return None
76
-
77
- if isinstance(resp, dict):
78
- # classic JSON-RPC envelope: {"result": {...}} or newer: {"value": {...}}
79
- tx = resp.get("result") or resp.get("value")
80
- return tx if isinstance(tx, dict) else None
81
-
82
- value = getattr(resp, "value", None)
83
- if isinstance(value, dict):
84
- return value
85
- if value is not None:
86
- value_dict = _as_dict(value)
87
- return value_dict if isinstance(value_dict, dict) else None
88
-
89
- # some builds use `.result`
90
- result = getattr(resp, "result", None)
91
- if isinstance(result, dict):
92
- return result
93
-
94
- # last resort: dictify whole response and re-unwrap
95
- d = _as_dict(resp)
96
- if isinstance(d, dict):
97
- tx = d.get("result") or d.get("value")
98
- return tx if isinstance(tx, dict) else None
99
- return None
100
-
101
-
102
- def _iter_instructions_json_parsed(
103
- tx: Dict[str, Any],
104
- ) -> Iterable[Dict[str, Any]]:
105
- """
106
- Iterate over instructions and inner instructions from a jsonParsed transaction payload.
107
- """
108
- try:
109
- message = (tx.get("transaction") or {}).get("message") or {}
110
- instructions = message.get("instructions") or []
111
- for ix in instructions:
112
- if isinstance(ix, dict):
113
- yield ix
114
-
115
- meta = tx.get("meta") or {}
116
- inner = meta.get("innerInstructions") or []
117
- for inner_entry in inner:
118
- if not isinstance(inner_entry, dict):
119
- continue
120
- for ix in inner_entry.get("instructions") or []:
121
- if isinstance(ix, dict):
122
- yield ix
123
- except Exception:
124
- return
125
-
126
-
127
- def _safe_int(v: Any) -> Optional[int]:
128
- if v is None:
129
- return None
130
- if isinstance(v, bool):
131
- return None
132
- if isinstance(v, int):
133
- return v
134
- if isinstance(v, float):
135
- return int(v)
136
- if isinstance(v, str):
137
- s = v.strip()
138
- if not s:
139
- return None
140
- try:
141
- return int(float(s))
142
- except Exception:
143
- return None
144
- return None
145
-
146
-
147
- def _coerce_blockhash(v: Any) -> Optional[Hash]:
148
- """Coerce a blockhash value into a solders `Hash` across solana-py response variants."""
149
- if v is None:
150
- return None
151
- if isinstance(v, Hash):
152
- return v
153
- if isinstance(v, str):
154
- s = v.strip()
155
- if not s:
156
- return None
157
- return Hash.from_string(s)
158
- # Some solana-py versions may return objects that stringify to the base58 blockhash.
159
- try:
160
- s = str(v).strip()
161
- if s:
162
- return Hash.from_string(s)
163
- except Exception:
164
- return None
165
- return None
166
-
167
-
168
- def _coerce_signature(v: Any) -> Optional[Signature]:
169
- """Coerce a transaction signature into a solders `Signature` (for RPC calls)."""
170
- if v is None:
171
- return None
172
- if isinstance(v, Signature):
173
- return v
174
- try:
175
- s = str(v).strip()
176
- if not s:
177
- return None
178
- return Signature.from_string(s)
179
- except Exception:
180
- return None
181
-
182
-
183
- def _signature_str(v: Any) -> str:
184
- """Return a stable string representation of a tx signature for logging/JSON."""
185
- return str(v).strip()
186
-
187
-
188
- def _token_balances_by_owner_and_mint(
189
- meta: Dict[str, Any],
190
- ) -> Tuple[Dict[Tuple[str, str], int], Dict[Tuple[str, str], int]]:
191
- """
192
- Return (pre, post) mappings for summed SPL token balances keyed by (owner, mint),
193
- using raw base-unit `amount` strings from uiTokenAmount.
194
- """
195
-
196
- def collect(entries: Any) -> Dict[Tuple[str, str], int]:
197
- out: Dict[Tuple[str, str], int] = {}
198
- if not isinstance(entries, list):
199
- return out
200
- for e in entries:
201
- if not isinstance(e, dict):
202
- continue
203
- owner = e.get("owner")
204
- mint = e.get("mint")
205
- ui = e.get("uiTokenAmount") or {}
206
- amount_str = ui.get("amount")
207
- if not isinstance(owner, str) or not isinstance(
208
- mint, str
209
- ):
210
- continue
211
- amt = _safe_int(amount_str)
212
- if amt is None:
213
- continue
214
- key = (owner, mint)
215
- out[key] = out.get(key, 0) + amt
216
- return out
217
-
218
- pre = collect(meta.get("preTokenBalances"))
219
- post = collect(meta.get("postTokenBalances"))
220
- return pre, post
221
-
222
-
223
- async def verify_solana_transaction(
224
- sig: Any,
225
- expected_amount_units: int,
226
- sender: str,
227
- payment_token: PaymentToken = PaymentToken.SOL,
228
- expected_recipient: Optional[str] = None,
229
- expected_mint: Optional[str] = None,
230
- commitment: str = "confirmed",
231
- max_wait_seconds: int = 30,
232
- poll_interval_seconds: float = 1.0,
233
- ) -> tuple[bool, str]:
234
- """Verify a Solana transaction signature (best-effort).
235
-
236
- This function is used after submitting a payment transaction to ensure the tx:
237
- - exists on-chain
238
- - is not failed (no error recorded)
239
- - has retrievable transaction details
240
-
241
- Args:
242
- sig: Transaction signature (base58 string).
243
- expected_amount_units: Expected amount in smallest units (lamports for SOL, token base units for SPL).
244
- NOTE: currently not enforced (see security note at top of file).
245
- sender: Expected sender public key string.
246
- NOTE: currently not enforced (see security note at top of file).
247
- payment_token: Which token type this payment represents (SOL/USDC).
248
-
249
- Returns:
250
- (ok, message) where ok is True when the transaction looks successful.
251
-
252
- Production behavior:
253
- - Fetches the transaction in `jsonParsed` form and validates:
254
- - tx exists and is successful
255
- - **sender**, **recipient**, and **amount** match expectations for SOL transfers
256
- - for SPL tokens, validates owner-level token balance deltas by (owner, mint)
257
-
258
- Args (additional):
259
- expected_recipient: Required for production-grade verification. For SOL this is the destination pubkey.
260
- For SPL tokens, pass the expected **owner** (e.g., treasury pubkey) whose token holdings should increase.
261
- expected_mint: For SPL tokens, the expected mint address (e.g., USDC mint).
262
- commitment: Desired confirmation level (processed|confirmed|finalized). Used for polling.
263
- max_wait_seconds: Max time to wait for `get_transaction` to become available.
264
- poll_interval_seconds: Poll interval while waiting for tx details.
265
- """
266
- try:
267
- # Keep both forms:
268
- # - sig_str: JSON/log safe
269
- # - sig_rpc: solders Signature for solana-py builds that require it
270
- sig_str = _signature_str(sig)
271
- sig_rpc: Any = _coerce_signature(sig_str) or sig_str
272
- _dbg(
273
- "verify_solana_transaction start: sig_str={} sig_type={} sig_rpc_type={} commitment={} token={}",
274
- sig_str,
275
- _type_name(sig),
276
- _type_name(sig_rpc),
277
- commitment,
278
- getattr(payment_token, "value", payment_token),
279
- )
280
-
281
- def _normalize_confirmation_status(
282
- status: Any,
283
- ) -> Optional[str]:
284
- """
285
- Normalize various confirmation status shapes into one of:
286
- processed | confirmed | finalized
287
-
288
- solana-py / solders may return:
289
- - "confirmed" (str)
290
- - TransactionConfirmationStatus.Finalized (enum-like)
291
- - TransactionConfirmationStatus.Finalized wrapped in other objects
292
- """
293
- if status is None:
294
- return None
295
- if isinstance(status, str):
296
- s = status.strip().lower()
297
- return s or None
298
-
299
- # Enum-like: prefer `.name`
300
- name = getattr(status, "name", None)
301
- if isinstance(name, str) and name:
302
- return name.strip().lower()
303
-
304
- # Sometimes `.value` is present
305
- value = getattr(status, "value", None)
306
- if isinstance(value, str) and value:
307
- return value.strip().lower()
308
-
309
- # Fallback: stringification often looks like "TransactionConfirmationStatus.Finalized"
310
- try:
311
- s = str(status).strip()
312
- if not s:
313
- return None
314
- if "." in s:
315
- s = s.split(".")[-1]
316
- return s.strip().lower()
317
- except Exception:
318
- return None
319
-
320
- def _commitment_ok(status: Any) -> bool:
321
- normalized = _normalize_confirmation_status(status)
322
- if not normalized:
323
- return False
324
- want = (
325
- _normalize_confirmation_status(commitment)
326
- or "confirmed"
327
- )
328
- rank = {"processed": 1, "confirmed": 2, "finalized": 3}
329
- return rank.get(normalized, 0) >= rank.get(want, 2)
330
-
331
- async with SolanaClient(config.SOLANA_RPC_URL) as client:
332
- # Wait for signature status + tx details to become available.
333
- # Even after `confirm_transaction`, some RPC nodes take a moment before `get_transaction` returns data.
334
- tx: Optional[Dict[str, Any]] = None
335
- last_status: Optional[str] = None
336
- import time
337
-
338
- deadline = time.time() + max_wait_seconds
339
- while time.time() < deadline:
340
- try:
341
- st = await client.get_signature_statuses(
342
- [sig_rpc]
343
- )
344
- except TypeError:
345
- st = await client.get_signature_statuses(
346
- [sig_str]
347
- )
348
- _dbg(
349
- "get_signature_statuses: sig_rpc_type={} resp_type={} resp={}",
350
- _type_name(sig_rpc),
351
- _type_name(st),
352
- _as_dict(st),
353
- )
354
- if st.value and st.value[0] is not None:
355
- if st.value[0].err:
356
- return False, "Transaction failed on-chain."
357
- last_status = getattr(
358
- st.value[0], "confirmation_status", None
359
- ) or getattr(
360
- st.value[0], "confirmationStatus", None
361
- )
362
- # Some versions expose dict-like status objects
363
- if isinstance(st.value[0], dict):
364
- last_status = st.value[0].get(
365
- "confirmationStatus"
366
- ) or st.value[0].get("confirmation_status")
367
- _dbg(
368
- "signature status row: row_type={} confirmation_status={} normalized_ok={}",
369
- _type_name(st.value[0]),
370
- last_status,
371
- _commitment_ok(last_status or "confirmed"),
372
- )
373
-
374
- # Try to fetch tx details; prefer jsonParsed, but fall back if unsupported.
375
- try:
376
- tx_details = await client.get_transaction(
377
- sig_rpc,
378
- encoding="jsonParsed",
379
- max_supported_transaction_version=0,
380
- )
381
- except TypeError:
382
- # Some solana-py versions expect a string signature and/or don't support encoding kwarg.
383
- try:
384
- tx_details = await client.get_transaction(
385
- sig_str,
386
- encoding="jsonParsed",
387
- max_supported_transaction_version=0,
388
- )
389
- except TypeError:
390
- tx_details = await client.get_transaction(
391
- sig_str,
392
- max_supported_transaction_version=0,
393
- )
394
- _dbg(
395
- "get_transaction: sig_str={} resp_type={} resp_keys={}",
396
- sig_str,
397
- _type_name(tx_details),
398
- (
399
- list(
400
- _unwrap_get_transaction_response(
401
- tx_details
402
- ).keys()
403
- )
404
- if _unwrap_get_transaction_response(
405
- tx_details
406
- )
407
- else None
408
- ),
409
- )
410
-
411
- tx = _unwrap_get_transaction_response(tx_details)
412
- if tx and _commitment_ok(last_status or "confirmed"):
413
- break
414
-
415
- await asyncio.sleep(poll_interval_seconds)
416
-
417
- if not tx:
418
- return False, "Could not fetch transaction details."
419
-
420
- meta = tx.get("meta") or {}
421
- if meta.get("err") is not None:
422
- return False, "Transaction failed on-chain."
423
-
424
- if payment_token == PaymentToken.SOL:
425
- if not expected_recipient:
426
- return (
427
- False,
428
- "Missing expected_recipient for SOL verification.",
429
- )
430
-
431
- # Prefer instruction parsing (most precise)
432
- matched = False
433
- for ix in _iter_instructions_json_parsed(tx):
434
- if (
435
- ix.get("program") or ix.get("programId")
436
- ) not in {"system"}:
437
- continue
438
- parsed = ix.get("parsed")
439
- if not isinstance(parsed, dict):
440
- continue
441
- if parsed.get("type") != "transfer":
442
- continue
443
- info = parsed.get("info") or {}
444
- source = info.get("source")
445
- dest = info.get("destination")
446
- lamports = _safe_int(info.get("lamports"))
447
- if (
448
- source == sender
449
- and dest == expected_recipient
450
- and lamports == expected_amount_units
451
- ):
452
- matched = True
453
- break
454
-
455
- if matched:
456
- logger.info(
457
- f"SOL payment verified: sig={sig_str} sender={sender} recipient={expected_recipient} lamports={expected_amount_units}"
458
- )
459
- return True, "Verified"
460
-
461
- # Fallback: balance delta check (less precise but still strong for simple transfers)
462
- msg = (tx.get("transaction") or {}).get(
463
- "message"
464
- ) or {}
465
- keys: List[Any] = msg.get("accountKeys") or []
466
- # accountKeys may be list[str] or list[dict{pubkey:...}]
467
- key_strs: List[str] = []
468
- for k in keys:
469
- if isinstance(k, str):
470
- key_strs.append(k)
471
- elif isinstance(k, dict) and isinstance(
472
- k.get("pubkey"), str
473
- ):
474
- key_strs.append(k["pubkey"])
475
- try:
476
- sender_idx = key_strs.index(sender)
477
- recipient_idx = key_strs.index(expected_recipient)
478
- except ValueError:
479
- return (
480
- False,
481
- "Sender/recipient not found in transaction account keys.",
482
- )
483
-
484
- pre = meta.get("preBalances") or []
485
- post = meta.get("postBalances") or []
486
- if not (
487
- isinstance(pre, list) and isinstance(post, list)
488
- ):
489
- return (
490
- False,
491
- "Missing balance arrays for verification.",
492
- )
493
- if (
494
- sender_idx >= len(pre)
495
- or sender_idx >= len(post)
496
- or recipient_idx >= len(pre)
497
- or recipient_idx >= len(post)
498
- ):
499
- return False, "Balance indices out of range."
500
-
501
- sender_delta = _safe_int(post[sender_idx]) - _safe_int(pre[sender_idx]) # type: ignore[operator]
502
- recipient_delta = _safe_int(post[recipient_idx]) - _safe_int(pre[recipient_idx]) # type: ignore[operator]
503
- if sender_delta is None or recipient_delta is None:
504
- return False, "Unable to compute balance deltas."
505
-
506
- if (
507
- recipient_delta == expected_amount_units
508
- and sender_delta <= -expected_amount_units
509
- ):
510
- logger.info(
511
- f"SOL payment verified (delta): sig={sig_str} sender={sender} recipient={expected_recipient} lamports={expected_amount_units}"
512
- )
513
- return True, "Verified"
514
-
515
- return (
516
- False,
517
- "Payment mismatch: could not find exact SOL transfer to expected recipient/amount.",
518
- )
519
-
520
- # SPL token verification (e.g., USDC)
521
- if not expected_recipient:
522
- return (
523
- False,
524
- "Missing expected_recipient (owner) for SPL verification.",
525
- )
526
- mint = expected_mint or (
527
- config.USDC_MINT_ADDRESS
528
- if payment_token == PaymentToken.USDC
529
- else None
530
- )
531
- if not mint:
532
- return (
533
- False,
534
- "Missing expected_mint for SPL verification.",
535
- )
536
-
537
- pre_map, post_map = _token_balances_by_owner_and_mint(
538
- meta
539
- )
540
- rec_key = (expected_recipient, mint)
541
- snd_key = (sender, mint)
542
- recipient_delta = post_map.get(rec_key, 0) - pre_map.get(
543
- rec_key, 0
544
- )
545
- sender_delta = post_map.get(snd_key, 0) - pre_map.get(
546
- snd_key, 0
547
- )
548
-
549
- if (
550
- recipient_delta == expected_amount_units
551
- and sender_delta <= -expected_amount_units
552
- ):
553
- logger.info(
554
- f"SPL payment verified: sig={sig_str} sender={sender} owner={expected_recipient} mint={mint} amount={expected_amount_units}"
555
- )
556
- return True, "Verified"
557
-
558
- return (
559
- False,
560
- "Payment mismatch: SPL token balance deltas do not match expected amount.",
561
- )
562
-
563
- except Exception as e:
564
- # Log full traceback server-side for debugging/ops.
565
- # Avoid logging any secrets; signature + pubkeys are safe.
566
- logger.opt(exception=True).error(
567
- "verify_solana_transaction failed: sig={} token={} sender={} expected_recipient={} expected_amount_units={} commitment={}",
568
- sig_str if "sig_str" in locals() else _signature_str(sig),
569
- getattr(payment_token, "value", payment_token),
570
- sender,
571
- expected_recipient,
572
- expected_amount_units,
573
- commitment,
574
- )
575
- return False, f"Verification error: {str(e)}"
576
-
577
-
578
- def parse_keypair_from_string(private_key_str: str) -> Keypair:
579
- """Parse a Solana payer keypair from a string (without logging secrets).
580
-
581
- Supported formats:
582
- - JSON array of ints (common Solana exported secret key array)
583
- - Base58 keypair string (when supported by the installed solders build)
584
-
585
- Args:
586
- private_key_str: Secret key encoding.
587
-
588
- Returns:
589
- solders `Keypair`
590
-
591
- Raises:
592
- ValueError: If the key is empty or the format is unsupported/invalid.
593
- """
594
- try:
595
- s = (private_key_str or "").strip()
596
- if not s:
597
- raise ValueError("Empty private_key")
598
-
599
- if s.startswith("["):
600
- arr = json.loads(s)
601
- if not isinstance(arr, list) or not all(
602
- isinstance(x, int) for x in arr
603
- ):
604
- raise ValueError(
605
- "Invalid JSON private key; expected a list of ints"
606
- )
607
- key_bytes = bytes(arr)
608
- return Keypair.from_bytes(key_bytes)
609
-
610
- if hasattr(Keypair, "from_base58_string"):
611
- return Keypair.from_base58_string(s) # type: ignore[attr-defined]
612
-
613
- raise ValueError(
614
- "Unsupported private_key format for this runtime. Provide a JSON array of ints."
615
- )
616
- except Exception:
617
- # Never log the key itself. Just log that parsing failed.
618
- logger.opt(exception=True).error(
619
- "parse_keypair_from_string failed"
620
- )
621
- raise
622
-
623
-
624
- async def send_and_confirm_sol_payment(
625
- *,
626
- payer: Keypair,
627
- recipient_pubkey_str: str,
628
- lamports: int,
629
- skip_preflight: bool,
630
- commitment: str,
631
- ) -> str:
632
- """Build, sign, send, and confirm a native SOL transfer.
633
-
634
- Args:
635
- payer: solders `Keypair` that pays the lamports and signs the tx.
636
- recipient_pubkey_str: Recipient pubkey (base58 string).
637
- lamports: Amount of SOL to send in lamports (must be > 0).
638
- skip_preflight: If True, skip preflight simulation.
639
- commitment: Confirmation commitment to wait for (processed|confirmed|finalized).
640
-
641
- Returns:
642
- Transaction signature (base58 string).
643
-
644
- Raises:
645
- ValueError: If lamports <= 0.
646
- RuntimeError: If blockhash cannot be fetched, tx cannot be sent, or tx fails.
647
- """
648
- if lamports <= 0:
649
- raise ValueError("lamports must be > 0")
650
-
651
- try:
652
- recipient = Pubkey.from_string(recipient_pubkey_str)
653
- _dbg(
654
- "send_and_confirm_sol_payment start: payer_pubkey={} recipient={} lamports={} skip_preflight={} commitment={}",
655
- str(payer.pubkey()),
656
- recipient_pubkey_str,
657
- lamports,
658
- skip_preflight,
659
- commitment,
660
- )
661
- ix = transfer(
662
- TransferParams(
663
- from_pubkey=payer.pubkey(),
664
- to_pubkey=recipient,
665
- lamports=lamports,
666
- )
667
- )
668
-
669
- async with SolanaClient(config.SOLANA_RPC_URL) as client:
670
- latest = await client.get_latest_blockhash()
671
- blockhash_val: Any = None
672
- if isinstance(latest, dict):
673
- blockhash_val = (
674
- (latest.get("result") or {}).get("value") or {}
675
- ).get("blockhash")
676
- else:
677
- value = getattr(latest, "value", None)
678
- blockhash_val = (
679
- getattr(value, "blockhash", None)
680
- if value
681
- else None
682
- )
683
- _dbg(
684
- "get_latest_blockhash: resp_type={} blockhash_val_type={} blockhash_val={}",
685
- _type_name(latest),
686
- _type_name(blockhash_val),
687
- blockhash_val,
688
- )
689
-
690
- recent_blockhash = _coerce_blockhash(blockhash_val)
691
- if not recent_blockhash:
692
- raise RuntimeError(
693
- f"Could not fetch latest blockhash: {latest}"
694
- )
695
- _dbg(
696
- "recent_blockhash coerced: type={} value={}",
697
- _type_name(recent_blockhash),
698
- recent_blockhash,
699
- )
700
-
701
- if hasattr(SoldersTransaction, "new_signed_with_payer"):
702
- tx = SoldersTransaction.new_signed_with_payer( # type: ignore[attr-defined]
703
- [ix],
704
- payer.pubkey(),
705
- [payer],
706
- recent_blockhash,
707
- )
708
- else:
709
- from solders.message import Message
710
-
711
- msg = Message.new_with_blockhash(
712
- [ix], payer.pubkey(), recent_blockhash
713
- )
714
- tx = SoldersTransaction.new_unsigned(msg)
715
- tx.sign([payer], recent_blockhash)
716
-
717
- raw_tx = (
718
- tx.to_bytes()
719
- if hasattr(tx, "to_bytes")
720
- else bytes(tx)
721
- )
722
-
723
- resp = await client.send_raw_transaction(
724
- raw_tx,
725
- opts=TxOpts(
726
- skip_preflight=skip_preflight,
727
- preflight_commitment=commitment,
728
- ),
729
- )
730
- _dbg(
731
- "send_raw_transaction: resp_type={} resp={}",
732
- _type_name(resp),
733
- _as_dict(resp),
734
- )
735
-
736
- sig = (
737
- resp.get("result")
738
- if isinstance(resp, dict)
739
- else getattr(resp, "value", None)
740
- or getattr(resp, "result", None)
741
- )
742
- if not sig:
743
- raise RuntimeError(
744
- f"Failed to send transaction: {resp}"
745
- )
746
- sig_str = _signature_str(sig)
747
- if not sig_str:
748
- raise RuntimeError(
749
- f"Failed to parse transaction signature: {sig}"
750
- )
751
- sig_rpc: Any = _coerce_signature(sig_str) or sig_str
752
- _dbg(
753
- "signature parsed: sig_str={} sig_type={} sig_rpc_type={}",
754
- sig_str,
755
- _type_name(sig),
756
- _type_name(sig_rpc),
757
- )
758
-
759
- if hasattr(client, "confirm_transaction"):
760
- # solana-py version differences:
761
- # - some builds want a string signature
762
- # - others want a solders `Signature`
763
- try:
764
- await client.confirm_transaction(sig_rpc, commitment=commitment) # type: ignore[arg-type]
765
- except TypeError:
766
- await client.confirm_transaction(sig_str, commitment=commitment) # type: ignore[arg-type]
767
- else:
768
- for _ in range(30):
769
- try:
770
- st = await client.get_signature_statuses(
771
- [sig_rpc]
772
- )
773
- except TypeError:
774
- st = await client.get_signature_statuses(
775
- [sig_str]
776
- )
777
- if st.value and st.value[0] is not None:
778
- if st.value[0].err:
779
- raise RuntimeError(
780
- "Transaction failed on-chain"
781
- )
782
- return sig_str
783
- await asyncio.sleep(1)
784
-
785
- return sig_str
786
- except Exception:
787
- # Log full traceback with non-sensitive context. Do NOT log payer secret key.
788
- logger.opt(exception=True).error(
789
- "send_and_confirm_sol_payment failed: payer={} recipient={} lamports={} skip_preflight={} commitment={}",
790
- str(payer.pubkey()),
791
- recipient_pubkey_str,
792
- lamports,
793
- skip_preflight,
794
- commitment,
795
- )
796
- raise
797
-
798
-
799
- async def send_and_confirm_split_sol_payment(
800
- *,
801
- payer: Keypair,
802
- treasury_pubkey_str: str,
803
- recipient_pubkey_str: str,
804
- treasury_lamports: int,
805
- recipient_lamports: int,
806
- skip_preflight: bool,
807
- commitment: str,
808
- ) -> str:
809
- """Build, sign, send, and confirm a split SOL transfer (treasury fee + recipient payment).
810
-
811
- Sends two transfers in a single atomic transaction:
812
- 1. Treasury receives the processing fee
813
- 2. Recipient receives the remainder
814
-
815
- Args:
816
- payer: solders `Keypair` that pays the lamports and signs the tx.
817
- treasury_pubkey_str: Treasury pubkey (base58 string) for processing fee.
818
- recipient_pubkey_str: Recipient pubkey (base58 string) for main payment.
819
- treasury_lamports: Amount of SOL to send to treasury (must be >= 0).
820
- recipient_lamports: Amount of SOL to send to recipient (must be > 0).
821
- skip_preflight: If True, skip preflight simulation.
822
- commitment: Confirmation commitment to wait for (processed|confirmed|finalized).
823
-
824
- Returns:
825
- Transaction signature (base58 string).
826
-
827
- Raises:
828
- ValueError: If recipient_lamports <= 0 or treasury_lamports < 0.
829
- RuntimeError: If blockhash cannot be fetched, tx cannot be sent, or tx fails.
830
- """
831
- if recipient_lamports <= 0:
832
- raise ValueError("recipient_lamports must be > 0")
833
- if treasury_lamports < 0:
834
- raise ValueError("treasury_lamports must be >= 0")
835
-
836
- try:
837
- treasury = Pubkey.from_string(treasury_pubkey_str)
838
- recipient = Pubkey.from_string(recipient_pubkey_str)
839
- _dbg(
840
- "send_and_confirm_split_sol_payment start: payer_pubkey={} treasury={} recipient={} treasury_lamports={} recipient_lamports={} skip_preflight={} commitment={}",
841
- str(payer.pubkey()),
842
- treasury_pubkey_str,
843
- recipient_pubkey_str,
844
- treasury_lamports,
845
- recipient_lamports,
846
- skip_preflight,
847
- commitment,
848
- )
849
-
850
- # Create transfer instructions
851
- instructions = []
852
- if treasury_lamports > 0:
853
- instructions.append(
854
- transfer(
855
- TransferParams(
856
- from_pubkey=payer.pubkey(),
857
- to_pubkey=treasury,
858
- lamports=treasury_lamports,
859
- )
860
- )
861
- )
862
- instructions.append(
863
- transfer(
864
- TransferParams(
865
- from_pubkey=payer.pubkey(),
866
- to_pubkey=recipient,
867
- lamports=recipient_lamports,
868
- )
869
- )
870
- )
871
-
872
- async with SolanaClient(config.SOLANA_RPC_URL) as client:
873
- latest = await client.get_latest_blockhash()
874
- blockhash_val: Any = None
875
- if isinstance(latest, dict):
876
- blockhash_val = (
877
- (latest.get("result") or {}).get("value") or {}
878
- ).get("blockhash")
879
- else:
880
- value = getattr(latest, "value", None)
881
- blockhash_val = (
882
- getattr(value, "blockhash", None)
883
- if value
884
- else None
885
- )
886
- _dbg(
887
- "get_latest_blockhash: resp_type={} blockhash_val_type={} blockhash_val={}",
888
- _type_name(latest),
889
- _type_name(blockhash_val),
890
- blockhash_val,
891
- )
892
-
893
- recent_blockhash = _coerce_blockhash(blockhash_val)
894
- if not recent_blockhash:
895
- raise RuntimeError(
896
- f"Could not fetch latest blockhash: {latest}"
897
- )
898
- _dbg(
899
- "recent_blockhash coerced: type={} value={}",
900
- _type_name(recent_blockhash),
901
- recent_blockhash,
902
- )
903
-
904
- if hasattr(SoldersTransaction, "new_signed_with_payer"):
905
- tx = SoldersTransaction.new_signed_with_payer( # type: ignore[attr-defined]
906
- instructions,
907
- payer.pubkey(),
908
- [payer],
909
- recent_blockhash,
910
- )
911
- else:
912
- from solders.message import Message
913
-
914
- msg = Message.new_with_blockhash(
915
- instructions, payer.pubkey(), recent_blockhash
916
- )
917
- tx = SoldersTransaction.new_unsigned(msg)
918
- tx.sign([payer], recent_blockhash)
919
-
920
- raw_tx = (
921
- tx.to_bytes()
922
- if hasattr(tx, "to_bytes")
923
- else bytes(tx)
924
- )
925
-
926
- resp = await client.send_raw_transaction(
927
- raw_tx,
928
- opts=TxOpts(
929
- skip_preflight=skip_preflight,
930
- preflight_commitment=commitment,
931
- ),
932
- )
933
- _dbg(
934
- "send_raw_transaction: resp_type={} resp={}",
935
- _type_name(resp),
936
- _as_dict(resp),
937
- )
938
-
939
- sig = (
940
- resp.get("result")
941
- if isinstance(resp, dict)
942
- else getattr(resp, "value", None)
943
- or getattr(resp, "result", None)
944
- )
945
- if not sig:
946
- raise RuntimeError(
947
- f"Failed to send transaction: {resp}"
948
- )
949
- sig_str = _signature_str(sig)
950
- if not sig_str:
951
- raise RuntimeError(
952
- f"Failed to parse transaction signature: {sig}"
953
- )
954
- sig_rpc: Any = _coerce_signature(sig_str) or sig_str
955
- _dbg(
956
- "signature parsed: sig_str={} sig_type={} sig_rpc_type={}",
957
- sig_str,
958
- _type_name(sig),
959
- _type_name(sig_rpc),
960
- )
961
-
962
- if hasattr(client, "confirm_transaction"):
963
- # solana-py version differences:
964
- # - some builds want a string signature
965
- # - others want a solders `Signature`
966
- try:
967
- await client.confirm_transaction(sig_rpc, commitment=commitment) # type: ignore[arg-type]
968
- except TypeError:
969
- await client.confirm_transaction(sig_str, commitment=commitment) # type: ignore[arg-type]
970
- else:
971
- for _ in range(30):
972
- try:
973
- st = await client.get_signature_statuses(
974
- [sig_rpc]
975
- )
976
- except TypeError:
977
- st = await client.get_signature_statuses(
978
- [sig_str]
979
- )
980
- if st.value and st.value[0] is not None:
981
- if st.value[0].err:
982
- raise RuntimeError(
983
- "Transaction failed on-chain"
984
- )
985
- return sig_str
986
- await asyncio.sleep(1)
987
-
988
- return sig_str
989
- except Exception:
990
- # Log full traceback with non-sensitive context. Do NOT log payer secret key.
991
- logger.opt(exception=True).error(
992
- "send_and_confirm_split_sol_payment failed: payer={} treasury={} recipient={} treasury_lamports={} recipient_lamports={} skip_preflight={} commitment={}",
993
- str(payer.pubkey()),
994
- treasury_pubkey_str,
995
- recipient_pubkey_str,
996
- treasury_lamports,
997
- recipient_lamports,
998
- skip_preflight,
999
- commitment,
1000
- )
1001
- raise