atp-protocol 1.0.0__py3-none-any.whl → 1.2.0__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/__init__.py +4 -7
- atp/config.py +1 -1
- atp/middleware.py +19 -32
- {atp_protocol-1.0.0.dist-info → atp_protocol-1.2.0.dist-info}/METADATA +90 -181
- atp_protocol-1.2.0.dist-info/RECORD +9 -0
- atp/solana_utils.py +0 -1001
- atp/token_prices.py +0 -97
- atp/utils.py +0 -174
- atp/vault.py +0 -75
- atp_protocol-1.0.0.dist-info/RECORD +0 -13
- {atp_protocol-1.0.0.dist-info → atp_protocol-1.2.0.dist-info}/LICENSE +0 -0
- {atp_protocol-1.0.0.dist-info → atp_protocol-1.2.0.dist-info}/WHEEL +0 -0
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
|