helius-python 0.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.
helius/client.py ADDED
@@ -0,0 +1,941 @@
1
+ from typing import Annotated, Any, Literal
2
+
3
+ import httpx
4
+ from dotenv import dotenv_values
5
+ from pydantic import BaseModel, Field, TypeAdapter, validate_call
6
+
7
+ from helius.models import (
8
+ Account,
9
+ Block,
10
+ BlockCommitment,
11
+ ClusterNode,
12
+ EpochInfo,
13
+ EpochSchedule,
14
+ InflationGovernor,
15
+ InflationRate,
16
+ LamportAccount,
17
+ PerformanceSample,
18
+ SignatureStatus,
19
+ Supply,
20
+ TokenAccount,
21
+ TokenAccountBalance,
22
+ TokenSupply,
23
+ Transaction,
24
+ TransactionSignature,
25
+ VotingAccount,
26
+ )
27
+
28
+
29
+ class HeliusClient:
30
+ # BUG: check which endpoints return meaningful data in context
31
+ # BUG: handle helius errors that do not show by HTTP response code
32
+ # TODO: check all http methods and all guides and implement pagination and other non-implemented features
33
+ # TODO: consider refactoring | None = None to Optional[] = None
34
+ def __init__(
35
+ self,
36
+ *,
37
+ base_url: str = "https://mainnet.helius-rpc.com",
38
+ api_key: str | None = None,
39
+ ) -> None:
40
+ self.base_url = base_url
41
+ self.api_key = api_key or dotenv_values().get("HELIUS_API_KEY")
42
+ if not self.api_key:
43
+ raise ValueError("No API key provided.")
44
+ self._client = httpx.Client(
45
+ base_url=self.base_url,
46
+ params={"api-key": self.api_key},
47
+ )
48
+
49
+ def __enter__(self):
50
+ return self
51
+
52
+ def __exit__(self, exc_type, exc_value, traceback):
53
+ self.close()
54
+
55
+ def __del__(self):
56
+ self.close()
57
+
58
+ def close(self) -> None:
59
+ self._client.close()
60
+
61
+ def _send(self, json: dict, method="POST", url="/") -> dict:
62
+ response = self._client.request(method=method, url=url, json=json)
63
+ response.raise_for_status()
64
+ return response.json()
65
+
66
+ def get_account_info(
67
+ self,
68
+ public_key: str,
69
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
70
+ encoding: (
71
+ Literal["base58", "base64", "base64+zstd", "jsonParsed"] | None
72
+ ) = None,
73
+ data_slice_offset: int | None = None,
74
+ data_slice_length: int | None = None,
75
+ min_context_slot: int | None = None,
76
+ ) -> tuple[dict, Account | None]:
77
+ if (data_slice_offset is None) != (data_slice_length is None):
78
+ raise ValueError(
79
+ "Set both data_slice_length and data_slice_offset or neither."
80
+ )
81
+ request = (
82
+ RpcRequest(method="getAccountInfo")
83
+ .add(public_key)
84
+ .set("commitment", commitment)
85
+ .set("encoding", encoding)
86
+ .set(
87
+ "dataSlice",
88
+ (
89
+ {"offset": data_slice_offset, "length": data_slice_length}
90
+ if data_slice_offset is not None
91
+ else None
92
+ ),
93
+ )
94
+ .set("minContextSlot", min_context_slot)
95
+ .build()
96
+ )
97
+ response = self._send(request)
98
+ context = response["result"]["context"]
99
+ value = response["result"]["value"]
100
+ account_info = Account.model_validate(value) if value is not None else None
101
+ return context, account_info
102
+
103
+ def get_balance(
104
+ self,
105
+ public_key: str,
106
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
107
+ min_context_slot: int | None = None,
108
+ ) -> tuple[dict, int]:
109
+ request = (
110
+ RpcRequest(method="getBalance")
111
+ .add(public_key)
112
+ .set("commitment", commitment)
113
+ .set("minContextSlot", min_context_slot)
114
+ .build()
115
+ )
116
+ response = self._send(request)
117
+ context = response["result"]["context"]
118
+ value = response["result"]["value"]
119
+ return context, value
120
+
121
+ def get_block(
122
+ self,
123
+ slot: int,
124
+ commitment: Literal["finalized", "confirmed"] | None = None,
125
+ encoding: Literal["jsonParsed", "base58", "base64", "base64+std"] | None = None,
126
+ transaction_details: (
127
+ Literal["full", "accounts", "signatures", "none"] | None
128
+ ) = None,
129
+ rewards: bool | None = None,
130
+ max_supported_transcation_version: int | None = None,
131
+ ) -> Block | None:
132
+ request = (
133
+ RpcRequest(method="getBlock")
134
+ .add(slot)
135
+ .set("commitment", commitment)
136
+ .set("encoding", encoding)
137
+ .set("transactionDetails", transaction_details)
138
+ .set("rewards", rewards)
139
+ .set("maxSupportedTransactionVersion", max_supported_transcation_version)
140
+ .build()
141
+ )
142
+ response = self._send(request)
143
+ block = Block.model_validate(response["result"])
144
+ return block
145
+
146
+ def get_block_commitment(
147
+ self,
148
+ slot: int,
149
+ ) -> BlockCommitment:
150
+ request = RpcRequest(method="getBlockCommitment").add(slot).build()
151
+ response = self._send(request)
152
+ block_commitment = BlockCommitment.model_validate(response["result"])
153
+ return block_commitment
154
+
155
+ def get_block_height(
156
+ self,
157
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
158
+ min_context_slot: int | None = None,
159
+ ) -> int:
160
+ request = (
161
+ RpcRequest(method="getBlockHeight")
162
+ .set("commitment", commitment)
163
+ .set("minContextSlot", min_context_slot)
164
+ .build()
165
+ )
166
+ response = self._send(request)
167
+ return response["result"]
168
+
169
+ def get_block_production(
170
+ self,
171
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
172
+ first_slot: int | None = None,
173
+ last_slot: int | None = None,
174
+ identity: str | None = None,
175
+ ) -> tuple[dict, dict]:
176
+ """
177
+ At least one of identity or first_slot must be provided.
178
+ """
179
+ if first_slot is None:
180
+ if identity is None:
181
+ raise ValueError(
182
+ "At least one of identity or first_slot must be provided."
183
+ )
184
+ if last_slot is not None:
185
+ raise ValueError("To set last_slot, first_slot is required.")
186
+ if last_slot is None:
187
+ range = None
188
+ else:
189
+ range = {"firstSlot": first_slot}
190
+ if last_slot is not None:
191
+ range.update({"lastSlot": last_slot})
192
+ request = (
193
+ RpcRequest(method="getBlockProduction")
194
+ .set("commitment", commitment)
195
+ .set("range", range)
196
+ .set("identity", identity)
197
+ .build()
198
+ )
199
+ response = self._send(request)
200
+ context = response["result"]["context"]
201
+ value = response["result"]["value"]
202
+ return context, value
203
+
204
+ def get_blocks(
205
+ self,
206
+ start_slot: int,
207
+ end_slot: int | None,
208
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
209
+ ) -> list[int]:
210
+ """
211
+ If not provided, the query will return blocks up to the latest confirmed slot from start_slot.
212
+ The range between start_slot and end_slot (or latest slot if end_slot is omitted) must not exceed 500,000 slots.
213
+ """
214
+ request = (
215
+ RpcRequest(method="getBlocks")
216
+ .add(start_slot)
217
+ .add(end_slot)
218
+ .set("commitment", commitment)
219
+ .build()
220
+ )
221
+ response = self._send(request)
222
+ return response["result"]
223
+
224
+ def get_blocks_with_limit(
225
+ self,
226
+ start_slot: int,
227
+ limit: int,
228
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
229
+ ) -> list[int]:
230
+ request = (
231
+ RpcRequest(method="getBlocksWithLimit")
232
+ .add(start_slot)
233
+ .add(limit)
234
+ .set("commitment", commitment)
235
+ .build()
236
+ )
237
+ response = self._send(request)
238
+ return response["result"]
239
+
240
+ def get_block_time(self, slot: int) -> int | None:
241
+ request = RpcRequest(method="getBlockTime").add(slot).build()
242
+ response = self._send(request)
243
+ return response["result"]
244
+
245
+ def get_cluster_nodes(self) -> list[ClusterNode]:
246
+ request = RpcRequest(method="getClusterNodes").build()
247
+ response = self._send(request)
248
+ ta = TypeAdapter(list[ClusterNode])
249
+ cluster_nodes = ta.validate_python(response["result"])
250
+ return cluster_nodes
251
+
252
+ def get_epoch_info(
253
+ self,
254
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
255
+ min_context_slot: int | None = None,
256
+ ) -> EpochInfo:
257
+ request = (
258
+ RpcRequest(method="getEpochInfo")
259
+ .set("commitment", commitment)
260
+ .set("minContextSlot", min_context_slot)
261
+ .build()
262
+ )
263
+ response = self._send(request)
264
+ epoch_info = EpochInfo.model_validate(response["result"])
265
+ return epoch_info
266
+
267
+ def get_epoch_schedule(self) -> EpochSchedule:
268
+ request = RpcRequest(method="getEpochSchedule").build()
269
+ response = self._send(request)
270
+ epoch_schedule = EpochSchedule.model_validate(response["result"])
271
+ return epoch_schedule
272
+
273
+ def get_fee_for_message(
274
+ self,
275
+ message: str,
276
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
277
+ min_context_slot: int | None = None,
278
+ ) -> tuple[dict, int | None]:
279
+ request = (
280
+ RpcRequest(method="getFeeForMessage")
281
+ .add(message)
282
+ .set("commitment", commitment)
283
+ .set("minContextSlot", min_context_slot)
284
+ .build()
285
+ )
286
+ response = self._send(request)
287
+ context = response["result"]["context"]
288
+ value = response["result"]["value"]
289
+ return context, value
290
+
291
+ def get_first_available_block(self) -> int:
292
+ request = RpcRequest(method="getFirstAvailableBlock").build()
293
+ response = self._send(request)
294
+ return response["result"]
295
+
296
+ def get_genesis_hash(self) -> str:
297
+ request = RpcRequest(method="getGenesisHash").build()
298
+ response = self._send(request)
299
+ return response["result"]
300
+
301
+ def get_health(self) -> bool:
302
+ request = RpcRequest(method="getHealth").build()
303
+ response = self._send(request)
304
+ if "result" in response and response["result"] == "ok":
305
+ return True
306
+ else:
307
+ return False
308
+
309
+ def get_highest_snapshot_slot(self) -> dict:
310
+ request = RpcRequest(method="getHighestSnapshotSlot").build()
311
+ response = self._send(request)
312
+ return response["result"]
313
+
314
+ def get_identity(self) -> str:
315
+ request = RpcRequest(method="getIdentity").build()
316
+ response = self._send(request)
317
+ identity = response["result"]["identity"]
318
+ return identity
319
+
320
+ def get_inflation_governor(
321
+ self,
322
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
323
+ ) -> InflationGovernor:
324
+ request = (
325
+ RpcRequest(method="getInflationGovernor")
326
+ .set("commitment", commitment)
327
+ .build()
328
+ )
329
+ response = self._send(request)
330
+ inflation_governor = InflationGovernor.model_validate(response["result"])
331
+ return inflation_governor
332
+
333
+ def get_inflation_rate(self) -> InflationRate:
334
+ request = RpcRequest(method="getInflationRate").build()
335
+ response = self._send(request)
336
+ inflation_rate = InflationRate.model_validate(response["result"])
337
+ return inflation_rate
338
+
339
+ # TODO: getInflationReward
340
+
341
+ def get_largest_accounts(
342
+ self,
343
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
344
+ filter: Literal["circulating", "nonCirculating"] | None = None,
345
+ ) -> tuple[dict, list[LamportAccount]]:
346
+ request = (
347
+ RpcRequest(method="getLargestAccounts")
348
+ .set("commitment", commitment)
349
+ .set("filter", filter)
350
+ .build()
351
+ )
352
+ response = self._send(request)
353
+ context = response["result"]["context"]
354
+ value = response["result"]["value"]
355
+ ta = TypeAdapter(list[LamportAccount])
356
+ largest_accounts = ta.validate_python(value)
357
+ return context, largest_accounts
358
+
359
+ def get_latest_blockhash(
360
+ self,
361
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
362
+ min_context_slot: int | None = None,
363
+ ) -> tuple[dict, str, int]:
364
+ request = (
365
+ RpcRequest(method="getLatestBlockhash")
366
+ .set("commitment", commitment)
367
+ .set("minContextSlot", min_context_slot)
368
+ .build()
369
+ )
370
+ response = self._send(request)
371
+ context = response["result"]["context"]
372
+ value = response["result"]["value"]
373
+ blockhash = value["blockhash"]
374
+ last_valid_block_height = value["lastValidBlockHeight"]
375
+ return context, blockhash, last_valid_block_height
376
+
377
+ def get_leader_schedule(
378
+ self,
379
+ slot: int | None = None,
380
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
381
+ identity: str | None = None,
382
+ ) -> dict[str, list[int]] | None:
383
+ request = (
384
+ RpcRequest(method="getLeaderSchedule")
385
+ .add(slot)
386
+ .set("commitment", commitment)
387
+ .set("identity", identity)
388
+ .build()
389
+ )
390
+ response = self._send(request)
391
+ result = response["result"]
392
+ return result
393
+
394
+ def get_max_retransmit_slot(self) -> int:
395
+ request = RpcRequest(method="getMaxRetransmitSlot").build()
396
+ response = self._send(request)
397
+ result = response["result"]
398
+ return result
399
+
400
+ def get_max_shred_insert_slot(self) -> int:
401
+ request = RpcRequest(method="getMaxShredInsertSlot").build()
402
+ response = self._send(request)
403
+ result = response["result"]
404
+ return result
405
+
406
+ def get_minimum_balance_for_rent_exemption(
407
+ self,
408
+ data_length: int,
409
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
410
+ ) -> int:
411
+ request = (
412
+ RpcRequest(method="getMinimumBalanceForRentExemption")
413
+ .add(data_length)
414
+ .set("commitment", commitment)
415
+ .build()
416
+ )
417
+ response = self._send(request)
418
+ result = response["result"]
419
+ return result
420
+
421
+ def get_multiple_accounts(
422
+ self,
423
+ pubkeys: list[str],
424
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
425
+ encoding: (
426
+ Literal["base64", "base58", "base64+zstd", "jsonParsed"] | None
427
+ ) = None,
428
+ data_slice_offset: int | None = None,
429
+ data_slice_length: int | None = None,
430
+ ) -> tuple[dict, list[Account | None]]:
431
+ if (data_slice_offset is None) != (data_slice_length is None):
432
+ raise ValueError(
433
+ "Set both data_slice_length and data_slice_offset or neither."
434
+ )
435
+ if (
436
+ data_slice_length is not None
437
+ and data_slice_offset is not None
438
+ and encoding not in ["base58", "base64", "base64+zstd"]
439
+ ):
440
+ raise ValueError(
441
+ "Data slice is only available for base58, base64, or base64+zstd encodings."
442
+ )
443
+ request = (
444
+ RpcRequest(method="getMultipleAccounts")
445
+ .add(pubkeys)
446
+ .set("commitment", commitment)
447
+ .set("encoding", encoding)
448
+ .set(
449
+ "dataSlice",
450
+ (
451
+ {"offset": data_slice_offset, "length": data_slice_length}
452
+ if data_slice_offset is not None
453
+ else None
454
+ ),
455
+ )
456
+ .build()
457
+ )
458
+ response = self._send(request)
459
+ context = response["result"]["context"]
460
+ value = response["result"]["value"]
461
+ accounts = [Account.model_validate(i) if i is not None else None for i in value]
462
+ return context, accounts
463
+
464
+ @validate_call
465
+ def get_program_accounts(
466
+ self,
467
+ program_id: str,
468
+ commitment: Literal["confirmed", "finalized", "processed"] | None = None,
469
+ min_context_slot: int | None = None,
470
+ with_context: bool | None = None,
471
+ encoding: (
472
+ Literal["jsonParsed", "base58", "base64", "base64+zstd"] | None
473
+ ) = None,
474
+ data_slice_offset: int | None = None,
475
+ data_slice_length: int | None = None,
476
+ changed_since_slot: int | None = None,
477
+ filters: list[dict] | None = None,
478
+ ) -> list[tuple[str, Account]]:
479
+ if (data_slice_offset is None) != (data_slice_length is None):
480
+ raise ValueError(
481
+ "Set both data_slice_length and data_slice_offset or neither."
482
+ )
483
+ request = (
484
+ RpcRequest(method="getProgramAccounts")
485
+ .add(program_id)
486
+ .set("commitment", commitment)
487
+ .set("minContextSlot", min_context_slot)
488
+ .set("withContext", with_context)
489
+ .set("encoding", encoding)
490
+ .set(
491
+ "dataSlice",
492
+ (
493
+ {"offset": data_slice_offset, "length": data_slice_length}
494
+ if data_slice_offset is not None
495
+ else None
496
+ ),
497
+ )
498
+ .set("changedSinceSlot", changed_since_slot)
499
+ .set("filters", filters)
500
+ .build()
501
+ )
502
+ response = self._send(request)
503
+ result = response["result"]
504
+ return [(i["pubkey"], Account.model_validate(i["account"])) for i in result]
505
+
506
+ def get_recent_performance_samples(
507
+ self,
508
+ limit: int | None = None,
509
+ ) -> list[PerformanceSample]:
510
+ request = RpcRequest(method="getRecentPerformanceSamples").add(limit).build()
511
+ response = self._send(request)
512
+ ta = TypeAdapter(list[PerformanceSample])
513
+ return ta.validate_python(response["result"])
514
+
515
+ def get_recent_prioritization_fees(
516
+ self,
517
+ locked_writable_accounts: list[str] | None = None,
518
+ ) -> list[tuple[int, int]]:
519
+ request = (
520
+ RpcRequest(method="getRecentPrioritizationFees")
521
+ .add(locked_writable_accounts)
522
+ .build()
523
+ )
524
+ response = self._send(request)
525
+ return [(i["slot"], i["prioritizationFee"]) for i in response["result"]]
526
+
527
+ @validate_call
528
+ def get_signatures_for_address(
529
+ self,
530
+ address: str,
531
+ limit: Annotated[int, Field(ge=1, le=1000)] = 1000,
532
+ before: str | None = None,
533
+ until: str | None = None,
534
+ commitment: Literal["finalized", "confirmed"] | None = None,
535
+ min_context_slot: int | None = None,
536
+ ) -> list[TransactionSignature]:
537
+ request = (
538
+ RpcRequest(method="getSignaturesForAddress")
539
+ .add(address)
540
+ .set("limit", limit)
541
+ .set("before", before)
542
+ .set("until", until)
543
+ .set("commitment", commitment)
544
+ .set("minContextSlot", min_context_slot)
545
+ .build()
546
+ )
547
+ response = self._send(request)
548
+ ta = TypeAdapter(list[TransactionSignature])
549
+ transaction_signatures = ta.validate_python(response["result"])
550
+ return transaction_signatures
551
+
552
+ def get_signature_statuses(
553
+ self,
554
+ signatures: list[str],
555
+ search_transaction_history: bool | None = None,
556
+ ) -> tuple[dict, list[SignatureStatus | None]]:
557
+ request = (
558
+ RpcRequest(method="getSignatureStatuses")
559
+ .add(signatures)
560
+ .set("searchTransactionHistory", search_transaction_history)
561
+ .build()
562
+ )
563
+ response = self._send(request)
564
+ context = response["result"]["context"]
565
+ signature_statuses = [
566
+ SignatureStatus.model_validate(value) if value is not None else value
567
+ for value in response["result"]["value"]
568
+ ]
569
+ return context, signature_statuses
570
+
571
+ def get_slot(
572
+ self,
573
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
574
+ min_context_slot: int | None = None,
575
+ ) -> int:
576
+ request = (
577
+ RpcRequest(method="getSlot")
578
+ .set("commitment", commitment)
579
+ .set("minContextSlot", min_context_slot)
580
+ .build()
581
+ )
582
+ response = self._send(request)
583
+ return response["result"]
584
+
585
+ def get_slot_leader(
586
+ self,
587
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
588
+ min_context_slot: int | None = None,
589
+ ) -> str:
590
+ request = (
591
+ RpcRequest(method="getSlotLeader")
592
+ .set("commitment", commitment)
593
+ .set("minContextSlot", min_context_slot)
594
+ .build()
595
+ )
596
+ response = self._send(request)
597
+ return response["result"]
598
+
599
+ @validate_call
600
+ def get_slot_leaders(
601
+ self,
602
+ start_slot: int,
603
+ limit: Annotated[int, Field(ge=1, le=5000)],
604
+ ) -> list[str]:
605
+ request = RpcRequest(method="getSlotLeaders").add(start_slot).add(limit).build()
606
+ response = self._send(request)
607
+ return response["result"]
608
+
609
+ def get_stake_minimum_delegation(
610
+ self,
611
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
612
+ ) -> tuple[dict, int]:
613
+ request = (
614
+ RpcRequest(method="getStakeMinimumDelegation")
615
+ .set("commitment", commitment)
616
+ .build()
617
+ )
618
+ response = self._send(request)
619
+ context = response["result"]["context"]
620
+ value = response["result"]["value"]
621
+ return context, value
622
+
623
+ def get_supply(
624
+ self,
625
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
626
+ exclude_non_circulating_accounts_list: bool | None = None,
627
+ ) -> tuple[dict, Supply]:
628
+ request = (
629
+ RpcRequest(method="getSupply")
630
+ .set("commitment", commitment)
631
+ .set(
632
+ "excludeNonCirculatingAccountsList",
633
+ exclude_non_circulating_accounts_list,
634
+ )
635
+ .build()
636
+ )
637
+ response = self._send(request)
638
+ context = response["result"]["context"]
639
+ supply = Supply.model_validate(response["result"]["value"])
640
+ return context, supply
641
+
642
+ # TODO: use getProgramAccountsV2 which supports pagination
643
+
644
+ def get_token_account_balance(
645
+ self,
646
+ token_account: str,
647
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
648
+ ) -> tuple[dict, TokenAccountBalance]:
649
+ request = (
650
+ RpcRequest(method="getTokenAccountBalance")
651
+ .add(token_account)
652
+ .set("commitment", commitment)
653
+ .build()
654
+ )
655
+ response = self._send(request)
656
+ context = response["result"]["context"]
657
+ balance = TokenAccountBalance.model_validate(response["result"]["value"])
658
+ return context, balance
659
+
660
+ def get_token_accounts_by_delegate(
661
+ self,
662
+ delegate_pub_key: str,
663
+ mint: str | None = None,
664
+ program_id: str | None = None,
665
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
666
+ encoding: (
667
+ Literal["base58", "base64", "base64+zstd", "jsonParsed"] | None
668
+ ) = None,
669
+ data_slice_offset: int | None = None,
670
+ data_slice_length: int | None = None,
671
+ min_context_slot: int | None = None,
672
+ ) -> tuple[dict, list[tuple[str, Account]]]:
673
+ if (mint is None) == (program_id is None):
674
+ raise ValueError("Provide exactly one of mint or program_id.")
675
+ if (data_slice_offset is None) != (data_slice_length is None):
676
+ raise ValueError(
677
+ "Set both data_slice_offset and data_slice_length or neither."
678
+ )
679
+ if data_slice_offset is not None and encoding == "jsonParsed":
680
+ raise ValueError(
681
+ "dataSlice is only for bas58, bas64 and base64+zstd encodings."
682
+ )
683
+ filter = {"mint": mint} if mint is not None else {"programId": program_id}
684
+ request = (
685
+ RpcRequest(method="getTokenAccountsByDelegate")
686
+ .add(delegate_pub_key)
687
+ .add(filter)
688
+ .set("commitment", commitment)
689
+ .set("encoding", encoding)
690
+ .set(
691
+ "dataSlice",
692
+ (
693
+ {"offset": data_slice_offset, "length": data_slice_length}
694
+ if data_slice_offset is not None
695
+ else None
696
+ ),
697
+ )
698
+ .set("minContextSlot", min_context_slot)
699
+ .build()
700
+ )
701
+ response = self._send(request)
702
+ context = response["result"]["context"]
703
+ token_accounts = [
704
+ (i["pubkey"], Account.model_validate(i["account"]))
705
+ for i in response["result"]["value"]
706
+ ]
707
+ return context, token_accounts
708
+
709
+ def get_token_accounts_by_owner(
710
+ self,
711
+ owner_pub_key: str,
712
+ mint: str | None = None,
713
+ program_id: str | None = None,
714
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
715
+ encoding: (
716
+ Literal["base58", "base64", "base64+zstd", "jsonParsed"] | None
717
+ ) = None,
718
+ data_slice_offset: int | None = None,
719
+ data_slice_length: int | None = None,
720
+ min_context_slot: int | None = None,
721
+ ) -> tuple[dict, list[tuple[str, Account]]]:
722
+ if (mint is None) == (program_id is None):
723
+ raise ValueError("Provide exactly one of mint or program_id.")
724
+ if (data_slice_offset is None) != (data_slice_length is None):
725
+ raise ValueError(
726
+ "Set both data_slice_offset and data_slice_length or neither."
727
+ )
728
+ if data_slice_offset is not None and encoding == "jsonParsed":
729
+ raise ValueError(
730
+ "dataSlice is only for bas58, bas64 and base64+zstd encodings."
731
+ )
732
+ filter = {"mint": mint} if mint is not None else {"programId": program_id}
733
+ request = (
734
+ RpcRequest(method="getTokenAccountsByOwner")
735
+ .add(owner_pub_key)
736
+ .add(filter)
737
+ .set("commitment", commitment)
738
+ .set("encoding", encoding)
739
+ .set(
740
+ "dataSlice",
741
+ (
742
+ {"offset": data_slice_offset, "length": data_slice_length}
743
+ if data_slice_offset is not None
744
+ else None
745
+ ),
746
+ )
747
+ .set("minContextSlot", min_context_slot)
748
+ .build()
749
+ )
750
+ response = self._send(request)
751
+ context = response["result"]["context"]
752
+ token_accounts = [
753
+ (i["pubkey"], Account.model_validate(i["account"]))
754
+ for i in response["result"]["value"]
755
+ ]
756
+ return context, token_accounts
757
+
758
+ # TODO: use getTokenAccountsByOwnerV2 and do pagination
759
+
760
+ def get_token_largest_accounts(
761
+ self,
762
+ mint: str,
763
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
764
+ ) -> tuple[dict, list[TokenAccount]]:
765
+ request = (
766
+ RpcRequest(method="getTokenLargestAccounts")
767
+ .add(mint)
768
+ .set("commitment", commitment)
769
+ .build()
770
+ )
771
+ response = self._send(request)
772
+ context = response["result"]["context"]
773
+ ta = TypeAdapter(list[TokenAccount])
774
+ largest_accounts = ta.validate_python(response["result"]["value"])
775
+ return context, largest_accounts
776
+
777
+ def get_token_supply(
778
+ self,
779
+ mint_address: str,
780
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
781
+ ) -> tuple[dict, TokenSupply]:
782
+ request = (
783
+ RpcRequest(method="getTokenSupply")
784
+ .add(mint_address)
785
+ .set("commitment", commitment)
786
+ .build()
787
+ )
788
+ response = self._send(request)
789
+ context = response["result"]["context"]
790
+ token_supply = TokenSupply.model_validate(response["result"]["value"])
791
+ return context, token_supply
792
+
793
+ def get_transaction(
794
+ self,
795
+ transaction_signature: str,
796
+ commitment: Literal["finalized", "confirmed"] | None = None,
797
+ encoding: Literal["json", "jsonParsed", "base58", "base64"] | None = None,
798
+ max_supported_transaction_version: int | None = None,
799
+ ) -> Transaction:
800
+ request = (
801
+ RpcRequest(method="getTransaction")
802
+ .add(transaction_signature)
803
+ .set("commitment", commitment)
804
+ .set("encoding", encoding)
805
+ .set("maxSupportedTransactionVersion", max_supported_transaction_version)
806
+ .build()
807
+ )
808
+ response = self._send(request)
809
+ transaction = Transaction.model_validate(response["result"])
810
+ return transaction
811
+
812
+ def get_transaction_count(
813
+ self,
814
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
815
+ min_context_slot: int | None = None,
816
+ ) -> int:
817
+ request = (
818
+ RpcRequest(method="getTransactionCount")
819
+ .set("commitment", commitment)
820
+ .set("minContextSlot", min_context_slot)
821
+ .build()
822
+ )
823
+ response = self._send(request)
824
+ return response["result"]
825
+
826
+ def get_version(self) -> tuple[str, int]:
827
+ request = RpcRequest(method="getVersion").build()
828
+ response = self._send(request)
829
+ result = response["result"]
830
+ return result["solana-core"], result["feature-set"]
831
+
832
+ def get_vote_accounts(
833
+ self,
834
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
835
+ vote_pubkey: str | None = None,
836
+ keep_unstaked_delinquents: bool | None = None,
837
+ delinquent_slot_distance: int | None = None,
838
+ ) -> tuple[list[VotingAccount], list[VotingAccount]]:
839
+ request = (
840
+ RpcRequest(method="getVoteAccounts")
841
+ .set("commitment", commitment)
842
+ .set("votePubkey", vote_pubkey)
843
+ .set("keepUnstakedDelinquents", keep_unstaked_delinquents)
844
+ .set("delinquentSlotDistance", delinquent_slot_distance)
845
+ .build()
846
+ )
847
+ response = self._send(request)
848
+ ta = TypeAdapter(list[VotingAccount])
849
+ current = ta.validate_python(response["result"]["current"])
850
+ delinquent = ta.validate_python(response["result"]["delinquent"])
851
+ return current, delinquent
852
+
853
+ def is_blockhash_valid(
854
+ self,
855
+ blockhash: str,
856
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
857
+ min_context_slot: int | None = None,
858
+ ) -> tuple[dict, bool]:
859
+ request = (
860
+ RpcRequest(method="isBlockhashValid")
861
+ .add(blockhash)
862
+ .set("commitment", commitment)
863
+ .set("minContextSlot", min_context_slot)
864
+ .build()
865
+ )
866
+ response = self._send(request)
867
+ context = response["result"]["context"]
868
+ value = response["result"]["value"]
869
+ return context, value
870
+
871
+ def request_airdrop(
872
+ self,
873
+ public_key: str,
874
+ lamports: int,
875
+ commitment: Literal["finalized", "confirmed", "processed"] | None = None,
876
+ ) -> str:
877
+ """
878
+ Only available on Devnet and Testnet, not Mainnet Beta.
879
+ """
880
+ request = (
881
+ RpcRequest(method="requestAirdrop")
882
+ .add(public_key)
883
+ .add(lamports)
884
+ .set("commitment", commitment)
885
+ .build()
886
+ )
887
+ response = self._send(request)
888
+ return response["result"]
889
+
890
+ def minimum_ledger_slot(self) -> int:
891
+ request = RpcRequest(method="minimumLedgerSlot").build()
892
+ response = self._send(request)
893
+ return response["result"]
894
+
895
+
896
+ class RpcRequest:
897
+ class Request(BaseModel):
898
+ jsonrpc: str
899
+ method: str
900
+ params: list[Any] | None = None
901
+ id: str | int | None
902
+
903
+ def __init__(
904
+ self,
905
+ *,
906
+ jsonrpc: str = "2.0",
907
+ method: str,
908
+ id: str | int | None = 1,
909
+ ):
910
+ self._jsonrpc = jsonrpc
911
+ self._method = method
912
+ self._id = id
913
+ self._positional: list[Any] = []
914
+ self._config: dict[str, Any] = {}
915
+
916
+ def add(self, value, can_be_none: bool = False):
917
+ if value is not None:
918
+ self._positional.append(value)
919
+ elif can_be_none:
920
+ self._positional.append(None)
921
+ return self
922
+
923
+ def set(self, key: str, value, can_be_none: bool = False):
924
+ if value is not None:
925
+ self._config.update({key: value})
926
+ elif can_be_none:
927
+ self._config.update({key: None})
928
+ return self
929
+
930
+ def build(self):
931
+ params = self._positional if self._positional else []
932
+ if self._config:
933
+ params.append(self._config)
934
+ request = {
935
+ "jsonrpc": self._jsonrpc,
936
+ "method": self._method,
937
+ "id": self._id,
938
+ }
939
+ if params:
940
+ request.update({"params": params})
941
+ return self.Request(**request).model_dump()