mm-sol 0.2.5__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.
- mm_sol/__init__.py +0 -0
- mm_sol/account.py +90 -0
- mm_sol/balance.py +18 -0
- mm_sol/block.py +58 -0
- mm_sol/cli/__init__.py +0 -0
- mm_sol/cli/cli.py +87 -0
- mm_sol/cli/cli_utils.py +36 -0
- mm_sol/cli/cmd/__init__.py +0 -0
- mm_sol/cli/cmd/balance_cmd.py +75 -0
- mm_sol/cli/cmd/balances_cmd.py +62 -0
- mm_sol/cli/cmd/example_cmd.py +8 -0
- mm_sol/cli/cmd/node_cmd.py +9 -0
- mm_sol/cli/cmd/transfer_sol_cmd.py +41 -0
- mm_sol/cli/cmd/wallet/__init__.py +0 -0
- mm_sol/cli/cmd/wallet/keypair_cmd.py +19 -0
- mm_sol/cli/cmd/wallet/new_cmd.py +14 -0
- mm_sol/cli/examples/balances.yml +11 -0
- mm_sol/cli/examples/transfer-sol.yml +8 -0
- mm_sol/py.typed +0 -0
- mm_sol/rpc.py +232 -0
- mm_sol/solana_cli.py +254 -0
- mm_sol/token.py +133 -0
- mm_sol/transfer.py +80 -0
- mm_sol/types.py +4 -0
- mm_sol/utils.py +38 -0
- mm_sol-0.2.5.dist-info/METADATA +9 -0
- mm_sol-0.2.5.dist-info/RECORD +29 -0
- mm_sol-0.2.5.dist-info/WHEEL +4 -0
- mm_sol-0.2.5.dist-info/entry_points.txt +2 -0
mm_sol/rpc.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from mm_std import Err, Ok, Result, hr
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
5
|
+
|
|
6
|
+
DEFAULT_MAINNET_RPC = "https://api.mainnet-beta.solana.com"
|
|
7
|
+
DEFAULT_TESTNET_RPC = "https://api.testnet.solana.com"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EpochInfo(BaseModel):
|
|
11
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
12
|
+
|
|
13
|
+
epoch: int
|
|
14
|
+
absolute_slot: int = Field(..., alias="absoluteSlot")
|
|
15
|
+
block_height: int = Field(..., alias="blockHeight")
|
|
16
|
+
slot_index: int = Field(..., alias="slotIndex")
|
|
17
|
+
slots_in_epoch: int = Field(..., alias="slotsInEpoch")
|
|
18
|
+
transaction_count: int = Field(..., alias="transactionCount")
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def progress(self) -> float:
|
|
22
|
+
return round(self.slot_index / self.slots_in_epoch * 100, 2)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ClusterNode(BaseModel):
|
|
26
|
+
pubkey: str
|
|
27
|
+
version: str | None
|
|
28
|
+
gossip: str | None
|
|
29
|
+
rpc: str | None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class VoteAccount(BaseModel):
|
|
33
|
+
class EpochCredits(BaseModel):
|
|
34
|
+
epoch: int
|
|
35
|
+
credits: int
|
|
36
|
+
previous_credits: int
|
|
37
|
+
|
|
38
|
+
validator: str
|
|
39
|
+
vote: str
|
|
40
|
+
commission: int
|
|
41
|
+
stake: int
|
|
42
|
+
credits: list[EpochCredits]
|
|
43
|
+
epoch_vote_account: bool
|
|
44
|
+
root_slot: int
|
|
45
|
+
last_vote: int
|
|
46
|
+
delinquent: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BlockProduction(BaseModel):
|
|
50
|
+
class Leader(BaseModel):
|
|
51
|
+
address: str
|
|
52
|
+
produced: int
|
|
53
|
+
skipped: int
|
|
54
|
+
|
|
55
|
+
slot: int
|
|
56
|
+
first_slot: int
|
|
57
|
+
last_slot: int
|
|
58
|
+
leaders: list[Leader]
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def total_produced(self) -> int:
|
|
62
|
+
return sum(leader.produced for leader in self.leaders)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def total_skipped(self) -> int:
|
|
66
|
+
return sum(leader.skipped for leader in self.leaders)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class StakeActivation(BaseModel):
|
|
70
|
+
state: str
|
|
71
|
+
active: int
|
|
72
|
+
inactive: int
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def rpc_call(
|
|
76
|
+
*,
|
|
77
|
+
node: str,
|
|
78
|
+
method: str,
|
|
79
|
+
params: list[Any],
|
|
80
|
+
id_: int = 1,
|
|
81
|
+
timeout: int = 10,
|
|
82
|
+
proxy: str | None = None,
|
|
83
|
+
) -> Result[Any]:
|
|
84
|
+
data = {"jsonrpc": "2.0", "method": method, "params": params, "id": id_}
|
|
85
|
+
if node.startswith("http"):
|
|
86
|
+
return _http_call(node, data, timeout, proxy)
|
|
87
|
+
raise NotImplementedError("ws is not implemented")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _http_call(node: str, data: dict[str, object], timeout: int, proxy: str | None) -> Result[Any]:
|
|
91
|
+
res = hr(node, method="POST", proxy=proxy, timeout=timeout, params=data, json_params=True)
|
|
92
|
+
try:
|
|
93
|
+
if res.is_error():
|
|
94
|
+
return res.to_err_result()
|
|
95
|
+
|
|
96
|
+
err = res.json.get("error", {}).get("message", "")
|
|
97
|
+
if err:
|
|
98
|
+
return res.to_err_result(f"service_error: {err}")
|
|
99
|
+
if "result" in res.json:
|
|
100
|
+
return res.to_ok_result(res.json["result"])
|
|
101
|
+
|
|
102
|
+
return res.to_err_result("unknown_response")
|
|
103
|
+
except Exception as e:
|
|
104
|
+
return res.to_err_result(f"exception: {e!s}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_balance(node: str, address: str, timeout: int = 10, proxy: str | None = None) -> Result[int]:
|
|
108
|
+
"""Returns balance in lamports"""
|
|
109
|
+
return rpc_call(node=node, method="getBalance", params=[address], timeout=timeout, proxy=proxy).and_then(lambda r: r["value"])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_block_height(node: str, timeout: int = 10, proxy: str | None = None) -> Result[int]:
|
|
113
|
+
"""Returns balance in lamports"""
|
|
114
|
+
return rpc_call(node=node, method="getBlockHeight", params=[], timeout=timeout, proxy=proxy)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_slot(node: str, timeout: int = 10, proxy: str | None = None) -> Result[int]:
|
|
118
|
+
return rpc_call(node=node, method="getSlot", params=[], timeout=timeout, proxy=proxy)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_epoch_info(node: str, epoch: int | None = None, timeout: int = 10, proxy: str | None = None) -> Result[EpochInfo]:
|
|
122
|
+
params = [epoch] if epoch else []
|
|
123
|
+
return rpc_call(node=node, method="getEpochInfo", params=params, timeout=timeout, proxy=proxy).and_then(
|
|
124
|
+
lambda r: EpochInfo(**r),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_health(node: str, timeout: int = 10, proxy: str | None = None) -> Result[bool]:
|
|
129
|
+
return rpc_call(node=node, method="getHealth", params=[], timeout=timeout, proxy=proxy).and_then(lambda r: r == "ok")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_cluster_nodes(node: str, timeout: int = 30, proxy: str | None = None) -> Result[list[ClusterNode]]:
|
|
133
|
+
return rpc_call(node=node, method="getClusterNodes", timeout=timeout, proxy=proxy, params=[]).and_then(
|
|
134
|
+
lambda r: [ClusterNode(**n) for n in r],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_vote_accounts(node: str, timeout: int = 30, proxy: str | None = None) -> Result[list[VoteAccount]]:
|
|
139
|
+
res = rpc_call(node=node, method="getVoteAccounts", timeout=timeout, proxy=proxy, params=[])
|
|
140
|
+
if res.is_err():
|
|
141
|
+
return res
|
|
142
|
+
try:
|
|
143
|
+
data = res.unwrap()
|
|
144
|
+
result: list[VoteAccount] = []
|
|
145
|
+
for a in data["current"]:
|
|
146
|
+
result.append( # noqa: PERF401
|
|
147
|
+
VoteAccount(
|
|
148
|
+
validator=a["nodePubkey"],
|
|
149
|
+
vote=a["votePubkey"],
|
|
150
|
+
commission=a["commission"],
|
|
151
|
+
stake=a["activatedStake"],
|
|
152
|
+
credits=[
|
|
153
|
+
VoteAccount.EpochCredits(epoch=c[0], credits=c[1], previous_credits=c[2]) for c in a["epochCredits"]
|
|
154
|
+
],
|
|
155
|
+
delinquent=False,
|
|
156
|
+
epoch_vote_account=a["epochVoteAccount"],
|
|
157
|
+
root_slot=a["rootSlot"],
|
|
158
|
+
last_vote=a["lastVote"],
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
for a in data["delinquent"]:
|
|
162
|
+
result.append( # noqa: PERF401
|
|
163
|
+
VoteAccount(
|
|
164
|
+
validator=a["nodePubkey"],
|
|
165
|
+
vote=a["votePubkey"],
|
|
166
|
+
commission=a["commission"],
|
|
167
|
+
stake=a["activatedStake"],
|
|
168
|
+
credits=[
|
|
169
|
+
VoteAccount.EpochCredits(epoch=c[0], credits=c[1], previous_credits=c[2]) for c in a["epochCredits"]
|
|
170
|
+
],
|
|
171
|
+
delinquent=True,
|
|
172
|
+
epoch_vote_account=a["epochVoteAccount"],
|
|
173
|
+
root_slot=a["rootSlot"],
|
|
174
|
+
last_vote=a["lastVote"],
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
return Ok(result, res.data)
|
|
178
|
+
except Exception as e:
|
|
179
|
+
return Err(e, res.data)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_leader_scheduler(
|
|
183
|
+
node: str,
|
|
184
|
+
slot: int | None = None,
|
|
185
|
+
timeout: int = 10,
|
|
186
|
+
proxy: str | None = None,
|
|
187
|
+
) -> Result[dict[str, list[int]]]:
|
|
188
|
+
return rpc_call(
|
|
189
|
+
node=node,
|
|
190
|
+
method="getLeaderSchedule",
|
|
191
|
+
timeout=timeout,
|
|
192
|
+
proxy=proxy,
|
|
193
|
+
params=[slot],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_block_production(node: str, timeout: int = 60, proxy: str | None = None) -> Result[BlockProduction]:
|
|
198
|
+
res = rpc_call(node=node, method="getBlockProduction", timeout=timeout, proxy=proxy, params=[])
|
|
199
|
+
if res.is_err():
|
|
200
|
+
return res
|
|
201
|
+
try:
|
|
202
|
+
res_ok = res.unwrap()
|
|
203
|
+
slot = res_ok["context"]["slot"]
|
|
204
|
+
first_slot = res_ok["value"]["range"]["firstSlot"]
|
|
205
|
+
last_slot = res_ok["value"]["range"]["lastSlot"]
|
|
206
|
+
leaders = []
|
|
207
|
+
for address, (leader, produced) in res.ok["value"]["byIdentity"].items(): # type: ignore[index]
|
|
208
|
+
leaders.append(BlockProduction.Leader(address=address, produced=produced, skipped=leader - produced))
|
|
209
|
+
return Ok(BlockProduction(slot=slot, first_slot=first_slot, last_slot=last_slot, leaders=leaders), res.data)
|
|
210
|
+
except Exception as e:
|
|
211
|
+
return Err(e, data=res.data)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_stake_activation(node: str, address: str, timeout: int = 60, proxy: str | None = None) -> Result[StakeActivation]:
|
|
215
|
+
return rpc_call(node=node, method="getStakeActivation", timeout=timeout, proxy=proxy, params=[address]).and_then(
|
|
216
|
+
lambda ok: StakeActivation(**ok),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def get_transaction(
|
|
221
|
+
node: str,
|
|
222
|
+
signature: str,
|
|
223
|
+
max_supported_transaction_version: int | None = None,
|
|
224
|
+
encoding: str = "json",
|
|
225
|
+
timeout: int = 60,
|
|
226
|
+
proxy: str | None = None,
|
|
227
|
+
) -> Result[dict[str, object] | None]:
|
|
228
|
+
if max_supported_transaction_version is not None:
|
|
229
|
+
params = [signature, {"maxSupportedTransactionVersion": max_supported_transaction_version, "encoding": encoding}]
|
|
230
|
+
else:
|
|
231
|
+
params = [signature, encoding]
|
|
232
|
+
return rpc_call(node=node, method="getTransaction", timeout=timeout, proxy=proxy, params=params)
|
mm_sol/solana_cli.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import random
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import pydash
|
|
8
|
+
from mm_std import CommandResult, Err, Ok, Result, run_command, run_ssh_command
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ValidatorInfo(BaseModel):
|
|
13
|
+
identity_address: str
|
|
14
|
+
info_address: str
|
|
15
|
+
name: str | None
|
|
16
|
+
keybase: str | None
|
|
17
|
+
website: str | None
|
|
18
|
+
details: str | None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StakeAccount(BaseModel):
|
|
22
|
+
type: str = Field(..., alias="stakeType")
|
|
23
|
+
balance: float | None = Field(..., alias="accountBalance")
|
|
24
|
+
withdrawer: str
|
|
25
|
+
staker: str
|
|
26
|
+
vote: str | None = Field(None, alias="delegatedVoteAccountAddress")
|
|
27
|
+
|
|
28
|
+
@field_validator("balance")
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_lamports_to_sol(cls, v: int | None) -> float | None:
|
|
31
|
+
if v:
|
|
32
|
+
return v / 1_000_000_000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Stake(BaseModel):
|
|
36
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
37
|
+
|
|
38
|
+
stake_address: str = Field(..., alias="stakePubkey")
|
|
39
|
+
withdrawer_address: str = Field(..., alias="withdrawer")
|
|
40
|
+
vote_address: str | None = Field(None, alias="delegatedVoteAccountAddress")
|
|
41
|
+
balance: float | None = Field(..., alias="accountBalance")
|
|
42
|
+
delegated: float | None = Field(None, alias="delegatedStake")
|
|
43
|
+
active: float | None = Field(None, alias="activeStake")
|
|
44
|
+
lock_time: int | None = Field(None, alias="unixTimestamp")
|
|
45
|
+
|
|
46
|
+
@field_validator("balance", "delegated", "active")
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_lamports_to_sol(cls, v: int | None) -> float | None:
|
|
49
|
+
if v:
|
|
50
|
+
return v / 1_000_000_000
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_balance(
|
|
54
|
+
*,
|
|
55
|
+
address: str,
|
|
56
|
+
solana_dir: str = "",
|
|
57
|
+
url: str = "localhost",
|
|
58
|
+
ssh_host: str | None = None,
|
|
59
|
+
ssh_key_path: str | None = None,
|
|
60
|
+
timeout: int = 60,
|
|
61
|
+
) -> Result[Decimal]:
|
|
62
|
+
solana_dir = _solana_dir(solana_dir)
|
|
63
|
+
cmd = f"{solana_dir}solana balance {address} -u {url}"
|
|
64
|
+
res = _exec_cmd(cmd, ssh_host, ssh_key_path, timeout)
|
|
65
|
+
data = {"cmd": cmd, "stdout": res.stdout, "stderr": res.stderr}
|
|
66
|
+
try:
|
|
67
|
+
return Ok(Decimal(res.stdout.replace("SOL", "").strip()), data=data)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
return Err(e, data=data)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_stake_account(
|
|
73
|
+
*,
|
|
74
|
+
address: str,
|
|
75
|
+
solana_dir: str = "",
|
|
76
|
+
url: str = "localhost",
|
|
77
|
+
ssh_host: str | None = None,
|
|
78
|
+
ssh_key_path: str | None = None,
|
|
79
|
+
timeout: int = 60,
|
|
80
|
+
) -> Result[StakeAccount]:
|
|
81
|
+
solana_dir = _solana_dir(solana_dir)
|
|
82
|
+
cmd = f"{solana_dir}solana stake-account --output json -u {url} {address}"
|
|
83
|
+
res = _exec_cmd(cmd, ssh_host, ssh_key_path, timeout)
|
|
84
|
+
data = {"cmd": cmd, "stdout": res.stdout, "stderr": res.stderr}
|
|
85
|
+
try:
|
|
86
|
+
json_res = json.loads(res.stdout)
|
|
87
|
+
return Ok(StakeAccount(**json_res), data=data)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
return Err(e, data=data)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def transfer_with_private_key_file(
|
|
93
|
+
*,
|
|
94
|
+
recipient: str,
|
|
95
|
+
amount: Decimal,
|
|
96
|
+
private_key_path: str,
|
|
97
|
+
solana_dir: str = "",
|
|
98
|
+
url: str = "localhost",
|
|
99
|
+
ssh_host: str | None = None,
|
|
100
|
+
ssh_key_path: str | None = None,
|
|
101
|
+
allow_unfunded_recipient: bool = True,
|
|
102
|
+
timeout: int = 60,
|
|
103
|
+
) -> Result[str]:
|
|
104
|
+
solana_dir = _solana_dir(solana_dir)
|
|
105
|
+
cmd = f"{solana_dir}solana transfer {recipient} {amount} --from {private_key_path} --fee-payer {private_key_path}"
|
|
106
|
+
if allow_unfunded_recipient:
|
|
107
|
+
cmd += " --allow-unfunded-recipient"
|
|
108
|
+
cmd += f" -u {url} --output json"
|
|
109
|
+
res = _exec_cmd(cmd, ssh_host, ssh_key_path, timeout)
|
|
110
|
+
data = {"cmd": cmd, "stdout": res.stdout, "stderr": res.stderr}
|
|
111
|
+
try:
|
|
112
|
+
json_res = json.loads(res.stdout)
|
|
113
|
+
return Ok(json_res["signature"], data=data)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
return Err(e, data=data)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def transfer_with_private_key_str(
|
|
119
|
+
*,
|
|
120
|
+
recipient: str,
|
|
121
|
+
amount: Decimal,
|
|
122
|
+
private_key: str,
|
|
123
|
+
tmp_dir_path: str,
|
|
124
|
+
solana_dir: str = "",
|
|
125
|
+
url: str = "localhost",
|
|
126
|
+
ssh_host: str | None = None,
|
|
127
|
+
ssh_key_path: str | None = None,
|
|
128
|
+
timeout: int = 60,
|
|
129
|
+
) -> Result[str]:
|
|
130
|
+
# make private_key file
|
|
131
|
+
private_key_path = Path(f"{tmp_dir_path}/solana__{random.randint(1, 10_000_000_000)}.json")
|
|
132
|
+
private_key_path.write_text(private_key)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
return transfer_with_private_key_file(
|
|
136
|
+
recipient=recipient,
|
|
137
|
+
amount=amount,
|
|
138
|
+
private_key_path=private_key_path.as_posix(),
|
|
139
|
+
solana_dir=solana_dir,
|
|
140
|
+
url=url,
|
|
141
|
+
ssh_host=ssh_host,
|
|
142
|
+
ssh_key_path=ssh_key_path,
|
|
143
|
+
timeout=timeout,
|
|
144
|
+
)
|
|
145
|
+
finally:
|
|
146
|
+
private_key_path.unlink()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def withdraw_from_vote_account(
|
|
150
|
+
*,
|
|
151
|
+
recipient: str,
|
|
152
|
+
amount: Decimal | Literal["ALL"],
|
|
153
|
+
vote_key_path: str,
|
|
154
|
+
fee_payer_key_path: str,
|
|
155
|
+
solana_dir: str = "",
|
|
156
|
+
url: str = "localhost",
|
|
157
|
+
ssh_host: str | None = None,
|
|
158
|
+
ssh_key_path: str | None = None,
|
|
159
|
+
timeout: int = 60,
|
|
160
|
+
) -> Result[str]:
|
|
161
|
+
solana_dir = _solana_dir(solana_dir)
|
|
162
|
+
cmd = f"{solana_dir}solana withdraw-from-vote-account --keypair {fee_payer_key_path} -u {url} --output json {vote_key_path} {recipient} {amount}" # noqa: E501
|
|
163
|
+
res = _exec_cmd(cmd, ssh_host, ssh_key_path, timeout)
|
|
164
|
+
data = {"cmd": cmd, "stdout": res.stdout, "stderr": res.stderr}
|
|
165
|
+
try:
|
|
166
|
+
json_res = json.loads(res.stdout)
|
|
167
|
+
return Ok(json_res["signature"], data=data)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
return Err(e, data=data)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def get_validators_info(
|
|
173
|
+
*,
|
|
174
|
+
solana_dir: str = "",
|
|
175
|
+
url: str = "localhost",
|
|
176
|
+
ssh_host: str | None = None,
|
|
177
|
+
ssh_key_path: str | None = None,
|
|
178
|
+
timeout: int = 60,
|
|
179
|
+
) -> Result[list[ValidatorInfo]]:
|
|
180
|
+
solana_dir = _solana_dir(solana_dir)
|
|
181
|
+
cmd = f"{solana_dir}solana validator-info get --output json -u {url}"
|
|
182
|
+
res = _exec_cmd(cmd, ssh_host, ssh_key_path, timeout)
|
|
183
|
+
data = {"cmd": cmd, "stdout": res.stdout, "stderr": res.stderr}
|
|
184
|
+
try:
|
|
185
|
+
validators = []
|
|
186
|
+
for v in json.loads(res.stdout):
|
|
187
|
+
validators.append( # noqa: PERF401
|
|
188
|
+
ValidatorInfo(
|
|
189
|
+
info_address=v["infoPubkey"],
|
|
190
|
+
identity_address=v["identityPubkey"],
|
|
191
|
+
name=pydash.get(v, "info.name"),
|
|
192
|
+
keybase=pydash.get(v, "info.keybaseUsername"),
|
|
193
|
+
details=pydash.get(v, "info.details"),
|
|
194
|
+
website=pydash.get(v, "info.website"),
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
return Ok(validators, data=data)
|
|
198
|
+
except Exception as e:
|
|
199
|
+
return Err(e, data=data)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_vote_account_rewards(
|
|
203
|
+
*,
|
|
204
|
+
address: str,
|
|
205
|
+
solana_dir: str = "",
|
|
206
|
+
url: str = "localhost",
|
|
207
|
+
ssh_host: str | None = None,
|
|
208
|
+
ssh_key_path: str | None = None,
|
|
209
|
+
num_rewards_epochs: int = 10,
|
|
210
|
+
timeout: int = 60,
|
|
211
|
+
) -> Result[dict[int, float]]:
|
|
212
|
+
solana_dir = _solana_dir(solana_dir)
|
|
213
|
+
cmd = f"{solana_dir}solana vote-account {address} --with-rewards --num-rewards-epochs={num_rewards_epochs} -u {url}"
|
|
214
|
+
cmd += " --output json 2>/dev/null"
|
|
215
|
+
res = _exec_cmd(cmd, ssh_host, ssh_key_path, timeout)
|
|
216
|
+
data = {"cmd": cmd, "stdout": res.stdout, "stderr": res.stderr}
|
|
217
|
+
try:
|
|
218
|
+
rewards: dict[int, float] = {}
|
|
219
|
+
for r in reversed(json.loads(res.stdout)["epochRewards"]):
|
|
220
|
+
rewards[r["epoch"]] = r["amount"] / 10**9
|
|
221
|
+
return Ok(rewards, data=data)
|
|
222
|
+
except Exception as e:
|
|
223
|
+
return Err(e, data=data)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def get_stakes(
|
|
227
|
+
*,
|
|
228
|
+
vote_address: str = "",
|
|
229
|
+
solana_dir: str = "",
|
|
230
|
+
url: str = "localhost",
|
|
231
|
+
ssh_host: str | None = None,
|
|
232
|
+
ssh_key_path: str | None = None,
|
|
233
|
+
timeout: int = 60,
|
|
234
|
+
) -> Result[list[Stake]]:
|
|
235
|
+
solana_dir = _solana_dir(solana_dir)
|
|
236
|
+
cmd = f"{solana_dir}solana stakes --output json -u {url} {vote_address}"
|
|
237
|
+
res = _exec_cmd(cmd, ssh_host, ssh_key_path, timeout)
|
|
238
|
+
data = {"stdout": res.stdout, "stderr": res.stderr}
|
|
239
|
+
try:
|
|
240
|
+
return Ok([Stake(**x) for x in json.loads(res.stdout)], data=data)
|
|
241
|
+
except Exception as e:
|
|
242
|
+
return Err(e, data=data)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _exec_cmd(cmd: str, ssh_host: str | None, ssh_key_path: str | None, timeout: int) -> CommandResult:
|
|
246
|
+
if ssh_host:
|
|
247
|
+
return run_ssh_command(ssh_host, cmd, ssh_key_path, timeout=timeout)
|
|
248
|
+
return run_command(cmd, timeout=timeout)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _solana_dir(solana_dir: str) -> str:
|
|
252
|
+
if solana_dir and not solana_dir.endswith("/"):
|
|
253
|
+
solana_dir += "/"
|
|
254
|
+
return solana_dir
|
mm_sol/token.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from mm_std import Err, Ok, Result
|
|
2
|
+
from solana.exceptions import SolanaRpcException
|
|
3
|
+
from solana.rpc.types import TokenAccountOpts
|
|
4
|
+
from solders.pubkey import Pubkey
|
|
5
|
+
|
|
6
|
+
from mm_sol.types import Nodes, Proxies
|
|
7
|
+
from mm_sol.utils import get_client, get_node, get_proxy
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_balance(
|
|
11
|
+
node: str,
|
|
12
|
+
owner_address: str,
|
|
13
|
+
token_mint_address: str,
|
|
14
|
+
token_account: str | None = None,
|
|
15
|
+
timeout: float = 10,
|
|
16
|
+
proxy: str | None = None,
|
|
17
|
+
no_token_accounts_return_zero: bool = True,
|
|
18
|
+
) -> Result[int]:
|
|
19
|
+
try:
|
|
20
|
+
client = get_client(node, proxy=proxy, timeout=timeout)
|
|
21
|
+
if token_account:
|
|
22
|
+
res_balance = client.get_token_account_balance(Pubkey.from_string(token_account))
|
|
23
|
+
return Ok(int(res_balance.value.amount))
|
|
24
|
+
|
|
25
|
+
res_accounts = client.get_token_accounts_by_owner(
|
|
26
|
+
Pubkey.from_string(owner_address),
|
|
27
|
+
TokenAccountOpts(mint=Pubkey.from_string(token_mint_address)),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if no_token_accounts_return_zero and not res_accounts.value:
|
|
31
|
+
return Ok(0)
|
|
32
|
+
if not res_accounts.value:
|
|
33
|
+
return Err("no_token_accounts")
|
|
34
|
+
|
|
35
|
+
token_accounts = [a.pubkey for a in res_accounts.value]
|
|
36
|
+
balances = []
|
|
37
|
+
for token_account_ in token_accounts:
|
|
38
|
+
res = client.get_token_account_balance(token_account_)
|
|
39
|
+
if res.value: # type:ignore[truthy-bool]
|
|
40
|
+
balances.append(int(res.value.amount))
|
|
41
|
+
|
|
42
|
+
if len(balances) > 1:
|
|
43
|
+
return Err("there are many non empty token accounts, set token_account explicitly")
|
|
44
|
+
return Ok(balances[0])
|
|
45
|
+
except SolanaRpcException as e:
|
|
46
|
+
return Err(e.error_msg)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
return Err(e)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_balance_with_retries(
|
|
52
|
+
nodes: Nodes,
|
|
53
|
+
owner_address: str,
|
|
54
|
+
token_mint_address: str,
|
|
55
|
+
retries: int,
|
|
56
|
+
token_account: str | None = None,
|
|
57
|
+
timeout: float = 10,
|
|
58
|
+
proxies: Proxies = None,
|
|
59
|
+
no_token_accounts_return_zero: bool = True,
|
|
60
|
+
) -> Result[int]:
|
|
61
|
+
res: Result[int] = Err("not started yet")
|
|
62
|
+
for _ in range(retries):
|
|
63
|
+
res = get_balance(
|
|
64
|
+
get_node(nodes),
|
|
65
|
+
owner_address,
|
|
66
|
+
token_mint_address,
|
|
67
|
+
token_account,
|
|
68
|
+
timeout=timeout,
|
|
69
|
+
proxy=get_proxy(proxies),
|
|
70
|
+
no_token_accounts_return_zero=no_token_accounts_return_zero,
|
|
71
|
+
)
|
|
72
|
+
if res.is_ok():
|
|
73
|
+
return res
|
|
74
|
+
return res
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_decimals(node: str, token_mint_address: str, timeout: float = 10, proxy: str | None = None) -> Result[int]:
|
|
78
|
+
try:
|
|
79
|
+
client = get_client(node, proxy=proxy, timeout=timeout)
|
|
80
|
+
res = client.get_token_supply(Pubkey.from_string(token_mint_address))
|
|
81
|
+
return Ok(res.value.decimals)
|
|
82
|
+
except Exception as e:
|
|
83
|
+
return Err(e)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_decimals_with_retries(
|
|
87
|
+
nodes: Nodes, token_mint_address: str, retries: int, timeout: float = 10, proxies: Proxies = None
|
|
88
|
+
) -> Result[int]:
|
|
89
|
+
res: Result[int] = Err("not started yet")
|
|
90
|
+
for _ in range(retries):
|
|
91
|
+
res = get_decimals(get_node(nodes), token_mint_address, timeout=timeout, proxy=get_proxy(proxies))
|
|
92
|
+
if res.is_ok():
|
|
93
|
+
return res
|
|
94
|
+
return res
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# def transfer_to_wallet_address(
|
|
98
|
+
# *,
|
|
99
|
+
# node: str,
|
|
100
|
+
# private_key: str,
|
|
101
|
+
# recipient_wallet_address: str,
|
|
102
|
+
# token_mint_address: str,
|
|
103
|
+
# amount: int,
|
|
104
|
+
# ) -> Result[str]:
|
|
105
|
+
# try:
|
|
106
|
+
# keypair = account.get_keypair(private_key)
|
|
107
|
+
# token_client = Token(Client(node), Pubkey.from_string(token_mint_address), program_id=TOKEN_PROGRAM_ID, payer=keypair)
|
|
108
|
+
#
|
|
109
|
+
# # get from_token_account
|
|
110
|
+
# res = token_client.get_accounts(keypair.public_key)
|
|
111
|
+
# token_accounts = res["result"]["value"]
|
|
112
|
+
# if len(token_accounts) > 1:
|
|
113
|
+
# return Result(error="many_from_token_accounts", data=res)
|
|
114
|
+
# from_token_account = Pubkey.from_string(token_accounts[0]["pubkey"])
|
|
115
|
+
#
|
|
116
|
+
# # get to_token_account
|
|
117
|
+
# res = token_client.get_accounts(Pubkey.from_string(recipient_wallet_address))
|
|
118
|
+
# token_accounts = res["result"]["value"]
|
|
119
|
+
# if len(token_accounts) > 1:
|
|
120
|
+
# return Result(error="many_to_token_accounts", data=res)
|
|
121
|
+
# elif len(token_accounts) == 1:
|
|
122
|
+
# to_token_account = Pubkey.from_string(token_accounts[0]["pubkey"])
|
|
123
|
+
# else: # create a new to_token_account
|
|
124
|
+
# to_token_account = token_client.create_account(owner=Pubkey.from_string(recipient_wallet_address))
|
|
125
|
+
#
|
|
126
|
+
# res = token_client.transfer(source=from_token_account, dest=to_token_account, owner=keypair, amount=amount)
|
|
127
|
+
# if res.get("result"):
|
|
128
|
+
# return Result(ok=res.get("result"), data=res)
|
|
129
|
+
# return Result(error="unknown_response", data=res)
|
|
130
|
+
# except RPCException as e:
|
|
131
|
+
# return Result(error="rcp_exception", data=str(e))
|
|
132
|
+
# except Exception as e:
|
|
133
|
+
# return Result(error="exception", data=str(e))
|
mm_sol/transfer.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
|
|
3
|
+
import pydash
|
|
4
|
+
from mm_std import Err, Ok, Result
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from solana.rpc.api import Client
|
|
7
|
+
from solders.message import Message
|
|
8
|
+
from solders.pubkey import Pubkey
|
|
9
|
+
from solders.system_program import TransferParams, transfer
|
|
10
|
+
from solders.transaction import Transaction
|
|
11
|
+
|
|
12
|
+
from mm_sol import rpc, utils
|
|
13
|
+
from mm_sol.account import check_private_key, get_keypair
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def transfer_sol(
|
|
17
|
+
*,
|
|
18
|
+
from_address: str,
|
|
19
|
+
private_key_base58: str,
|
|
20
|
+
recipient_address: str,
|
|
21
|
+
amount_sol: Decimal,
|
|
22
|
+
nodes: str | list[str] | None = None,
|
|
23
|
+
attempts: int = 3,
|
|
24
|
+
) -> Result[str]:
|
|
25
|
+
acc = get_keypair(private_key_base58)
|
|
26
|
+
if not check_private_key(from_address, private_key_base58):
|
|
27
|
+
raise ValueError("from_address or private_key_base58 is invalid")
|
|
28
|
+
|
|
29
|
+
lamports = int(amount_sol * 10**9)
|
|
30
|
+
error = None
|
|
31
|
+
data = None
|
|
32
|
+
for _ in range(attempts):
|
|
33
|
+
try:
|
|
34
|
+
client = Client(utils.get_node(nodes))
|
|
35
|
+
# tx = Transaction(from_keypairs=[acc])
|
|
36
|
+
# ti = transfer(
|
|
37
|
+
# TransferParams(from_pubkey=acc.pubkey(), to_pubkey=Pubkey.from_string(recipient_address), lamports=lamports),
|
|
38
|
+
# )
|
|
39
|
+
# tx.add(ti)
|
|
40
|
+
# res = client.send_legacy_transaction(tx, acc)
|
|
41
|
+
ixns = [
|
|
42
|
+
transfer(
|
|
43
|
+
TransferParams(from_pubkey=acc.pubkey(), to_pubkey=Pubkey.from_string(recipient_address), lamports=lamports)
|
|
44
|
+
)
|
|
45
|
+
]
|
|
46
|
+
msg = Message(ixns, acc.pubkey())
|
|
47
|
+
tx = Transaction([acc], msg, client.get_latest_blockhash().value.blockhash)
|
|
48
|
+
res = client.send_transaction(tx)
|
|
49
|
+
data = res.to_json()
|
|
50
|
+
return Ok(str(res.value), data=data)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
error = e
|
|
53
|
+
|
|
54
|
+
return Err(error or "unknown", data=data)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TransferInfo(BaseModel):
|
|
58
|
+
source: str
|
|
59
|
+
destination: str
|
|
60
|
+
lamports: int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def find_transfers(node: str, tx_signature: str) -> Result[list[TransferInfo]]:
|
|
64
|
+
res = rpc.get_transaction(node, tx_signature, encoding="jsonParsed")
|
|
65
|
+
if res.is_err():
|
|
66
|
+
return res # type: ignore[return-value]
|
|
67
|
+
result = []
|
|
68
|
+
try:
|
|
69
|
+
for ix in pydash.get(res.ok, "transaction.message.instructions"):
|
|
70
|
+
program_id = ix.get("programId")
|
|
71
|
+
ix_type = pydash.get(ix, "parsed.type")
|
|
72
|
+
if program_id == "11111111111111111111111111111111" and ix_type == "transfer":
|
|
73
|
+
source = pydash.get(ix, "parsed.info.source")
|
|
74
|
+
destination = pydash.get(ix, "parsed.info.destination")
|
|
75
|
+
lamports = pydash.get(ix, "parsed.info.lamports")
|
|
76
|
+
if source and destination and lamports:
|
|
77
|
+
result.append(TransferInfo(source=source, destination=destination, lamports=lamports))
|
|
78
|
+
return Ok(result, data=res.data)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
return Err(e, res.data)
|
mm_sol/types.py
ADDED