Files
status-python-sdk/bot/account.py
T
Nick Ninov b49680ac50 PyPI: Selectable Backup folder
- Backup folder can be manually selected on local machine
- Related to https://github.com/status-im/status-python-sdk/issues/2
2026-06-17 23:48:55 +03:00

1253 lines
49 KiB
Python

from typing import Optional, Union, Generator, Any
import requests, datetime, re, logging, os, json, ast, shutil, eth_abi, shutil
import pandas as pd
from PIL import Image
from PIL.JpegImagePlugin import JpegImageFile
from .signal import Signal
from .logger import Logger
class Account:
# Enum mappings from original wakuext.py
__mappings = {
"contact_request": {
0: "none", # No action taken / no association - initial state
1: "mutual", # Friends
2: "sent", # Request sent from the bot
3: "received", # Request sent from another account
4: "dismissed" # Request cancelled
}
}
__prefix_mapping = {
"messaging": "wakuext",
"urls": "sharedurls",
"wallets": "wallet",
"account": "accounts",
"identity": "multiaccounts"
}
__keccak256_selectors = {
"transfer": "a9059cbb" # keccak256("transfer(address,uint256)")[:4]
}
def __init__(self, domain: str = "localhost", port: int = 8080, is_secure: bool = False, backup_folder: Optional[str] = None):
"""
Work with your own Status App account
Parameters:
- `domain` - the domain name where Status Backend is running. If running locally it would be `localhost` and if it's running in a container it would be the image's name.
- `port` - the port to connect to Status Backend. Verify the port in the Docker files.
- `is_secure` - if `http` or `https` should be used
- `backup_folder` - where backup files will be created and stored
"""
# Path of the account data in the Docker container for Status Backend
self.__docker_data_folder = "./data-dir"
# Path of the backups in the Docker container for Status Backend
self.__docker_backup_folder = "./root/.config/Status/backups"
self.__backup_folder = backup_folder
# As the docker-compose.yaml folder is at the moment
# NOTE: This might change for initial release
self.__backup_sdk_folder = os.path.join(os.path.dirname(os.path.dirname(__file__)), "backups")
os.makedirs(self.__backup_sdk_folder, exist_ok=True)
# Path of where images will be uploaded to Status Backend
self.__docker_asset_folder = "./assets"
# As the docker-compose.yaml folder is at the moment
# NOTE: This might change for initial release
self.__assets_local_folder = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets")
os.makedirs(self.__assets_local_folder, exist_ok=True)
self.__logger = Logger()
self.__timestamp_divisor = 1_000
self.__kd_iterations = 256000
self.__is_messenger_launched = False
# Information for the logged in account
self.__info = {}
# Information for Production chains (chain id -> chain name)
self.__chains = {}
# Monitor if wallet is used in `login` and raise an exception if
# a RPC endpoint for `wallet` is called without `infura_token`
self.__is_wallet_set = False
# All available tokens in Status Backend
self.__available_tokens = pd.DataFrame()
# All available ISO 4217 currencies
self.__iso4217_ccy = []
# All tokens in Status Backend
self.__http_base_url = f"http{'s' if is_secure else ''}://{domain}:{port}/statusgo/"
self.__ws_base_url = f"ws://{domain}:{port}/"
self.__urls = {
"http": {
"initialize": f"{self.__http_base_url}InitializeApplication",
"login": f"{self.__http_base_url}LoginAccount",
"create": f"{self.__http_base_url}CreateAccountAndLogin",
"restore": f"{self.__http_base_url}RestoreAccountAndLogin",
"logout": f"{self.__http_base_url}Logout",
"create_backup": f"{self.__http_base_url}PerformLocalBackup",
"load_backup": f"{self.__http_base_url}LoadLocalBackup",
"rpc": f"{self.__http_base_url}CallRPC",
"transaction": f"{self.__http_base_url}SendTransactionV2"
},
"socket": {
"signals": f"{self.__ws_base_url}signals"
}
}
self.__signal = Signal(self.__urls["socket"]["signals"])
# Initialize profile
self.available_accounts
# In case if there is a hanging logged in session
self.logout()
def login(self, password: str, key_uid: Optional[str] = None, display_name: Optional[str] = None, mnemonic: Optional[str] = None, infura_token: Optional[str] = None, coingecko_api_key: Optional[str] = None):
"""
Login to the given account. If it does not exist,
it will be created and automatically logged in.
Parameters:
- `password` - your Status password
- `key_uid` - your key unique identifier. If not provided `display_name` will be used to fetch it. This means that each `display_name` can be linked to one `key_uid`
- `display_name` - your Status display name. Use `display_name` and `password` parameter combination if you have a 1 to 1 mapping (each display name has a unique `key_uid`)
- `mnemonic` - the mnemonic when creating an account. Use this field with `password` and `display_name` to recover an account
- `infura_token` - https://www.infura.io/ RPC token to allow Status Backend to use a wallet
- `coingecko_api_key` - https://www.coingecko.com/ API key to allow Status Backend to use a wallet
"""
if not key_uid and not display_name:
raise ValueError("Please provide either a Key Unique Identifier (key_uid) or a Display Name (display_name)...")
available_accounts = self.available_accounts
# Login combination: display_name + password
if not key_uid:
for account in available_accounts:
if account["display_name"] != display_name:
continue
key_uid = account["key_uid"]
break
# Login combination: key_uid + password
else:
available_key_uids = [current["key_uid"] for current in available_accounts]
if key_uid not in available_key_uids:
info = "\n".join([f"{current['key_uid']} - {current['display_name']}" for current in self.available_accounts])
raise ValueError(f"Given Key Unique Identifier is invalid...\nAvailable Key Unique Identifiers:\n{info}")
is_new_account = isinstance(key_uid, type(None))
is_recovery = not isinstance(mnemonic, type(None)) and not key_uid
url_key = "login"
params = {
"keyUid": key_uid,
"password": password,
'kdfIterations': self.__kd_iterations
}
if is_new_account or is_recovery:
self.__validate_display_name(display_name)
params = {
"rootDataDir": self.__docker_data_folder,
"kdfIterations": self.__kd_iterations,
"displayName": display_name,
"password": password,
"customizationColor": "primary",
"wakuV2LightClient": False,
"thirdpartyServicesEnabled": True,
}
else:
self.logger.info(f"Logging in with Key UID - {key_uid}")
if is_new_account:
self.logger.info(f"Creating account with display_name {display_name}")
url_key = "create"
if is_recovery:
url_key = "restore"
params["mnemonic"] = mnemonic
self.logger.info(f"Restoring account for given mnemonics")
self.logout()
# Wallet usage
if infura_token:
params["infuraToken"] = infura_token
if coingecko_api_key:
params["coingeckoApiKey"] = coingecko_api_key
if infura_token and coingecko_api_key:
self.__is_wallet_set = True
url = self.__urls["http"][url_key]
params.update({
"logEnabled": True,
"logToStderr": True,
"logLevel": "INFO",
})
response = requests.post(url, json=params)
signal_event = self.__signal.get("node.login")
if signal_event["is_error"]:
raise Exception(f"There was an error with Status Backend...\n{signal_event['error_message']}")
self.logger.info("Successfully logged in!")
event: dict = signal_event["event"]["settings"]
self.__info = {
"public_key": event["public-key"],
"url": None,
"emojis": event["emojiHash"],
"key_uid": event["key-uid"],
"compressed_key": event["compressedKey"],
"mnemonic": event.get("mnemonic", mnemonic),
"display_name": event["display-name"],
"bio": event.get("bio", ""),
"password": password,
"wallet_address": event["dapps-address"],
"logged_in_timestamp": datetime.datetime.now()
}
self.__info["url"] = self.__call_rpc("urls", "shareUserURLWithData", [event["public-key"]]).get("result")
# Messenger can be activated only when logged in
self.__start_messenger()
if is_recovery:
self.logger.info("Updating remote display name")
self.display_name = event["display-name"]
self.logger.info("Successfully updated display name!")
self.__load_backup()
return self
def logout(self):
"""
Logout of Status app. In a way this method behaves as a Status cleaner
"""
response = requests.post(self.__urls["http"]["logout"])
self.__info = {}
self.__is_messenger_launched = False
self.__is_wallet_set = False
return self
@property
def logger(self) -> logging.Logger:
return self.__logger
@property
def available_accounts(self) -> list[dict]:
"""
All locally available accounts
"""
response = requests.post(self.__urls["http"]["initialize"], json={
"dataDir": self.__docker_data_folder
})
data: dict = response.json()
accounts: list[dict] = data.get("accounts", [])
if not isinstance(accounts, list):
accounts = []
current_available_accounts = [
{
"display_name": account["name"],
"key_uid": account["key-uid"],
"created_at": datetime.datetime.fromtimestamp(account["timestamp"])
}
for account in accounts
]
return current_available_accounts
@property
def info(self) -> dict:
"""
Overall information for currently logged in account.
Can also be used to verify if the user has logged in.
"""
if not self.__info:
raise Exception("Make sure you are logged in to your Status account with login() first...")
return self.__info
@property
def display_name(self) -> str:
"""
Get the current display name
"""
return self.info["display_name"]
@display_name.setter
def display_name(self, name: str):
self.__validate_display_name(name)
output = self.__call_rpc("messaging", "setDisplayName", [name])
# It seems that if a valid name is given, it will be instantly updated
# However after tracing the signals, an `envelope.sent` is sent a bit
# after the name has been changed.
self.signal.get("envelope.sent")
self.__info["display_name"] = name
@property
def bio(self) -> str:
"""
Get the current bio
"""
return self.info["bio"]
@bio.setter
def bio(self, bio: Any):
if isinstance(bio, type(None)):
bio = ""
bio = str(bio).strip()
# Limit based from Status App
CHARACTERS = 240
if len(bio) > CHARACTERS:
raise ValueError(f"Bio cannot be longer than {CHARACTERS} characters...")
self.__call_rpc("messaging", "setBio", [bio])
# It seems that if a valid bio is given, it will be instantly updated
# However after tracing the signals, an `envelope.sent` is sent a bit
# after the bio has been updated.
self.signal.get("envelope.sent")
self.__info["bio"] = bio
@bio.deleter
def bio(self):
self.bio = ""
@property
def profile_picture(self) -> Optional[JpegImageFile]:
"""
Get current profile picture
"""
files = [
os.path.join(self.__assets_local_folder, f)
for f in os.listdir(self.__assets_local_folder)
if os.path.isfile(os.path.join(self.__assets_local_folder, f))
]
if not files:
return
latest_file_path = max(files, key=os.path.getctime)
for file in files:
if file == latest_file_path:
continue
os.remove(file)
return Image.open(latest_file_path)
@profile_picture.setter
def profile_picture(self, file_path: str):
if not isinstance(file_path, str):
return
if not os.path.exists(file_path):
raise Exception(f"File path {file_path} does not exist")
suffix = (".jpg", ".png", ".jpeg")
if not file_path.endswith(suffix):
raise Exception(f"Image must be one of the following extensions: {suffix}")
file_name = os.path.basename(file_path)
extension = file_name.split(".")[-1]
asset_file_name = f"profile.{extension}"
asset_file_path = os.path.join(self.__assets_local_folder, asset_file_name)
docker_file_path = self.__docker_asset_folder + "/" + asset_file_name
for file_name in os.listdir(self.__assets_local_folder):
current_file_path = os.path.join(self.__assets_local_folder, file_name)
if not os.path.isfile(current_file_path) or current_file_path == file_path:
continue
os.remove(current_file_path)
try:
shutil.copy(file_path, asset_file_path)
except shutil.SameFileError:
self.logger.info("File is already in asset path")
img = Image.open(asset_file_path)
params = [
self.info["key_uid"],
docker_file_path,
0,
0,
*img.size
]
self.logger.info(f"Setting {file_path} as profile picture")
self.__call_rpc("identity", "storeIdentityImage", params)
self.logger.info(f"Profile picture has been updated!")
@property
def contacts(self) -> dict[str, dict]:
"""
Get the contacts that the bot has.
This includes contacts that have interacted with us. If a contact has removed us (or the bot has removed us)
NOTE: We do not use internal state so we can get dynamic values such as:
- Is currently active
- Is currently blocked
- Current display name
- Current bio
Terminology for Status contact requests:
- approved - when both `contact_state` and `external_contact_state` are `mutual`
- sent request - when `contact_state` is `sent` and `external_contact_state` is `none`
- received request - when `contact_state` is `received`
"""
data = self.__call_rpc("messaging", "contacts")
raw: list[dict] = data.get("result", [])
if not raw:
return {}
# dict format can be used in restricting functionality
# such as - `send_message` and `remove_contact`
contacts = {
contact["id"]: {
"public_key": contact["id"],
"url": self.__call_rpc("urls", "shareUserURLWithData", [contact["id"]]).get("result"),
"chat_id": contact["id"],
"compressed_key": contact["compressedKey"],
"emojis": contact["emojiHash"],
"contact_state": self.__mappings["contact_request"][contact["contactRequestState"]],
"external_contact_state": self.__mappings["contact_request"][contact["contactRequestRemoteState"]],
"has_added_us": contact["hasAddedUs"],
"added": contact["added"],
"mutual": contact["mutual"],
"display_name": contact["displayName"],
"bio": contact["bio"],
"wallet_address": contact["address"],
"last_updated": datetime.datetime.fromtimestamp(contact["lastUpdated"] / self.__timestamp_divisor) if contact["lastUpdated"] > 0 else None
}
for contact in raw
}
return contacts
@property
def signal(self) -> Signal:
"""
Work with different Status event signals.
To get a full list of all available signals,
feel free to use `signal.available_signals`.
"""
self.info
return self.__signal
@property
def communities(self) -> list[dict]:
"""
Get the communities that the bot is in.
NOTE: We do not use internal state so we can get dynamic values such as:
- Current community description
- Current number of community members
- Current channels' names, descriptions and permissions
"""
data = self.__call_rpc("messaging", "communities")
raw: list[dict] = data.get("result", [])
if not raw:
return []
to_datetime = lambda key, mapping: (datetime.datetime.fromtimestamp(mapping[key]) if mapping[key] > 0 else None) if key in mapping else None
communities = [
{
"id": community["id"],
"url": self.__call_rpc("urls", "shareCommunityURLWithData", [community["id"]]).get("result"),
"name": community["name"],
"verified": community["verified"],
"description": community["description"],
"dialog": community["introMessage"],
"leaving_message": community["outroMessage"],
"tags": community["tags"],
"is_member": community["isMember"],
"joined": community["verified"],
"joined_timestamp": to_datetime("joinedAt", community),
"requested_timestamp": to_datetime("requestedToJoinAt", community),
"encrypted": community["encrypted"],
"members": len(community["members"]),
"channels": [
{
"id": chat["id"],
"chat_id": community["id"] + chat["id"],
"url": self.__call_rpc("urls", "shareCommunityChannelURLWithData", [community["id"], chat["id"]]).get("result"),
"name": chat["name"],
"description": chat["description"],
"permissions": {
"posting": chat["canPost"],
"viewing": chat["canView"],
"reactions": chat["canPostReactions"],
"token_gated": chat["tokenGated"]
}
}
for chat in community["chats"].values()
]
}
for community in raw
]
return communities
@property
def chats(self) -> list[dict]:
"""
All chats that the bot can send messages to.
This property combines `self.communities` and `self.contacts` chats.
"""
communities = [
{"type": "channel", "id": chat["chat_id"], "name": f"{community['name']} #{chat['name']}"}
for community in self.communities
for chat in community["channels"]
if chat["permissions"]["posting"] and community["is_member"]
]
contacts = [
{"type": "contact", "id": contact["chat_id"], "name": contact["display_name"]}
for contact in self.contacts.values()
]
# Group chats in RPC endpoint are chat type 3
data = self.__call_rpc("messaging", "activeChats")
result: Optional[list[dict]] = data.get("result", [])
if not result:
result = []
group_chats = [
{"type": "group_chat", "id": active_chat["id"], "name": active_chat["name"]}
for active_chat in result
if active_chat["chatType"] == 3
]
return contacts + communities + group_chats
@property
def chains(self) -> dict[int, str]:
"""
All available production chains that are
used in Status Backend.
"""
# NOTE: An internal state can be used because chains
# would be consistent for the currently pulled Status Bacckend image
if self.__chains:
return self.__chains
result = self.__call_rpc("wallets", "getEthereumChains").get("result", [])
key = "Prod"
self.__chains = {chain[key]["chainId"]: chain[key]["chainName"] for chain in result if chain.get(key)}
return self.__chains
@property
def balance(self) -> pd.DataFrame:
"""
Get the account's balance
"""
empty = pd.DataFrame(columns=["timestamp", "address", "chain_id", "amount", "symbol"])
params = [[self.info["wallet_address"]], True]
results = self.__call_rpc("wallets", "fetchOrGetCachedWalletBalances", params).get("result", {}).get(self.info["wallet_address"].lower(), [])
if not results:
return empty.copy()
balance = pd.DataFrame(results)
column_mapping = {"tokenAddress": "address", "tokenChainId": "chain_id", "balance": "amount", "hasError": "error"}
balance = balance.rename(columns=column_mapping)[list(column_mapping.values())]\
.astype({"chain_id": "int8", "amount": "float64"})
query = (balance["amount"] != 0) & (~balance["error"])
if query.sum() == 0:
return empty.copy()
redundant_columns = ["error", "decimals", "cross_chain_id", "source_id"]
available_tokens = self.get_tokens()
balance = balance.loc[query].merge(available_tokens, "left", ["address", "chain_id"])\
.drop(redundant_columns, axis=1)\
.drop_duplicates()\
.sort_values("chain_id", ascending=True)\
.reset_index(drop=True)\
balance.insert(0, "timestamp", datetime.datetime.now())
return balance.copy()
@property
def community_members(self) -> pd.DataFrame:
"""
Get enriched member data for all visible communities that the account belongs to.
NOTE: This performs an additional RPC call for each member to fetch profile
details, so it can be slower for large communities.
This can be useful for analyzing community membership, such as identifying
suspicious profiles or filtering for genuine community members.
"""
data = self.__call_rpc("messaging", "communities")
raw: list[dict] = data.get("result", [])
if not raw:
return pd.DataFrame()
members = []
for community in raw:
for public_key, info in community.get("members", {}).items():
response: dict = self.__call_rpc("messaging", "getContactByID", [public_key])
result: dict = response.get("result") or {}
url = self.__call_rpc("urls", "shareUserURLWithData", [public_key]).get("result")
members.append({
"community_id": community["id"],
"community_name": community["name"],
"public_key": public_key,
"chat_id": public_key,
"display_name": result.get("displayName"),
"url": url,
"bio": result.get("bio", ""),
**info,
})
if not members:
return pd.DataFrame()
members = pd.DataFrame(members)
members.columns = [self.__camel_to_snake(column) for column in members.columns]
members = members.assign(
# Accounts with no display names are populated as they appear in the Status URL
display_name = members["display_name"].fillna(
members["compressed_key"].str[:3] + "..." + members["url"].str[-6:]
)
).drop(["last_update_clock", "color_id"], axis=1)\
.rename(
# Initial display name of the account when it was created
columns={"alias": "status_alias"}
)
return members.copy()
def __getitem__(self, key: str) -> pd.DataFrame:
"""
Get the fiat currency balance
"""
ccy = key.upper()
if ccy not in self.__get_fiat_ccy():
raise Exception(f"{ccy} is an invalid fiat (ISO 4217) currency code...")
balance = self.balance
tokens = (balance["chain_id"].astype(str) + "-" + balance["address"]).to_list()
result = self.__call_rpc("wallets", "fetchPrices", [tokens, [ccy]]).get("result", {})
if not result:
return pd.DataFrame()
rates = pd.DataFrame([
{
"chain_id": int(address.split("-")[0]),
"address": address.split("-")[1],
"rate": price,
"ccy": ccy,
}
for address, prices in result.items()
for ccy, price in prices.items()
])
balance = balance.merge(rates, "left", ["chain_id", "address"])
balance["fiat_value"] = balance["amount"] * balance["rate"]
return balance.copy()
def send_message(self, chat_id: str, message: str):
"""
Send a message to the given chat.
Parameters:
- `chat_id` - the chat ID can be found in `self.chats`
- `message` - the message that will be sent. Currently only text messages are supported
"""
params = [{
"chatId": chat_id,
"text": message,
"contentType": 1, # Send message only. Future versions can have different message types (audio, image, etc.)
"responseTo": ""
}]
self.__call_rpc("messaging", "sendChatMessage", params)
def listen_messages(self) -> Generator:
"""
Listen for new **RAW** messages continuously. Can be used for real time processing.
"""
for message in self.signal.listen("messages.new"):
event: dict = message.get("event", {})
if "chats" in event or "messages" in event:
yield message
def get_messages(self, chat_id: str, start_timestamp: Optional[datetime.datetime] = None, end_timestamp: Optional[datetime.datetime] = None) -> list[dict]:
"""
Get all of the messages in the given start and end timestamps.
Messages are returned in descending order (newest to oldest).
Messages can be fetched for removed contacts as well.
Parameters:
- `chat_id` - the chat ID can be found in `self.chats`
- `start_timestamp` - the start timestamp for message extraction. If not provided all early messages will be fetched.
- `end_timestamp` - the end timestamp for message extraction. If not provided all latest messages will be fetched.
"""
# NOTE: Order of params matters when making the RCP call
params = {
"chat_id": chat_id,
"cursor": "",
"limit": 500
}
all_messages = []
# Keys that need to be converted to datetime.datetime
timestamp_keys = []
finished = False
while not finished:
data = self.__call_rpc("messaging", "chatMessages", list(params.values()))
result: dict[str, Union[str, list[dict]]] = data.get("result", {})
messages: Optional[list[dict]] = result.get("messages")
cursor: Optional[str] = result.get("cursor")
if not cursor:
cursor = ""
if not messages:
messages = []
if messages and not timestamp_keys:
timestamp_keys = [key for key in result["messages"][0].keys() if "timestamp" in key.lower()]
for message in messages:
point = {
self.__camel_to_snake(key): value if key not in timestamp_keys else datetime.datetime.fromtimestamp(value / self.__timestamp_divisor)
for key, value in message.items()
}
if isinstance(point.get("bridge_message"), str):
point["bridge_message"] = json.loads(json.dumps(ast.literal_eval(point["bridge_message"])))
if start_timestamp and point["timestamp"] < start_timestamp:
finished = True
break
if end_timestamp and point["timestamp"] > end_timestamp:
continue
all_messages.append(point)
if len(cursor) > 0:
params["cursor"] = cursor
else:
finished = True
return all_messages
def add_contact(self, public_key: str, display_name: Optional[str] = None):
"""
Send a contact request / approve a contact.
Parameters:
- `public_key` - the contact's public key
- `display_name` - this field is required if the `public_key` does not appear in your contacts. This will set their display name (can be different from the one the other user has chosen)
"""
if public_key == self.info["public_key"]:
return self
contacts = list(self.contacts.values())
if not display_name:
for contact in contacts:
if public_key != contact["public_key"]:
continue
display_name = contact["display_name"]
break
if not display_name:
raise ValueError(f"Cannot add contact {public_key}...\nPlease make sure you add display_name for contacts that you are sending friend requests to and have never interacted with before!")
params = [{"id": public_key, "nickname": "", "displayName": display_name, "ensName": ""}]
self.__call_rpc("messaging", "addContact", params)
return self
def remove_contact(self, public_key: str) -> bool:
"""
Remove the contact / decline a contact request.
Parameters:
- `public_key` - the contact's public key
Output:
- If `True` the user has been removed. If `False` the user has not been removed (either not a contact or not a friend)
"""
contact_info = self.contacts.get(public_key, {})
# Cannot remove a contact that is not in your contact
if not contact_info:
return False
# Contact has already been removed
if contact_info["contact_state"] == "none":
return False
params = [public_key]
self.__call_rpc("messaging", "removeContact", params)
return True
def send_request_community(self, url: str) -> Optional[datetime.datetime]:
"""
Send a request to join a community
Parameters:
- `url` - the community's URL
Output:
- the timestamp the request was sent
"""
data = self.__call_rpc("urls", "parseSharedURL", [url])
raw: dict = data.get("result", {})
community_key = raw["community"]["communityId"]
params = [{"communityKey": community_key, "waitForResponse": True, "tryDatabase": True}]
data = self.__call_rpc("messaging", "fetchCommunity", params)
raw: dict = data.get("result", {})
community_id = raw["id"]
params = [{
"communityId": community_id,
"addressesToReveal": [self.info["wallet_address"]],
"airdropAddress": self.info["wallet_address"]
}]
data = self.__call_rpc("messaging", "requestToJoinCommunity", params)
return datetime.datetime.fromtimestamp(raw.get("requestedToJoinAt", datetime.datetime.now().timestamp()))
def backup(self) -> str:
"""
Create a `.bkp` (Backup) for the account. If the backup was not successful, a custom exception will be raised.
Output:
- the file path of the backup. The name is unique per account.
"""
self.info
response = requests.post(self.__urls["http"]["create_backup"])
result: dict = response.json()
file_path = result.get("filePath")
if not file_path or (isinstance(file_path, str) and len(file_path) == 0):
raise Exception(f"There was an error with creating a backup for {self.info['display_name']}")
file_name = os.path.basename(file_path)
sdk_file_path = os.path.join(self.__backup_sdk_folder, file_name)
file_path = sdk_file_path
if self.__backup_folder:
file_path = os.path.join(self.__backup_folder, file_name)
shutil.move(sdk_file_path, file_path)
return file_path
def get_tokens(self) -> pd.DataFrame:
"""
Get all tokens that can be used in Status.
Output:
- DataFrame of all tokens
"""
if len(self.__available_tokens) > 0:
return self.__available_tokens.copy()
columns = ["chainId", "address", "symbol", "decimals", "crossChainId"]
info = []
result: list[dict] = self.__call_rpc("wallets", "getAllTokenLists").get("result", [])
for current in result:
if len(current["tokens"]) == 0:
continue
data = pd.DataFrame(current["tokens"])[columns]
data = data.assign(
decimals = data["decimals"].astype("int8"),
chainId = data["chainId"].astype("int8"),
crossChainId = data["crossChainId"].apply(lambda value: None if len(value) == 0 else value),
source_id = current["name"]
)
info.append(data)
if info:
self.__available_tokens = pd.concat(info, ignore_index=True)
self.__available_tokens.columns = [self.__camel_to_snake(column) for column in self.__available_tokens.columns]
return self.__available_tokens.copy()
def get_balance(self, token_addresses: Union[list[str], str], chain_ids: Union[list[int], int] = 1, wallets: Optional[Union[list[str], str]] = None, ccy: Optional[Union[str, list[str]]] = None) -> pd.DataFrame:
"""
Get the current amount for the provided token addresses, chain IDs and wallets.
Parameters:
- `token_addresses` - the token addresses as they appear in `get_tokens()`
- `chain_ids` - chain IDs as they appear in `self.chains`
- `wallets` - if left blank, the account's `wallet_address` will be used. However other wallets can be monitored as well.
- `ccy` - the fiat currency the tokens will be converted to.
Output:
- DataFrame containing the current balance and fiat amount (if `ccy` is provided)
"""
if isinstance(wallets, str):
wallets = [wallets]
elif isinstance(wallets, type(None)):
wallets = [self.info["wallet_address"]]
if isinstance(wallets, list):
wallets = list(set(wallets))
if isinstance(token_addresses, str):
token_addresses = [token_addresses]
if isinstance(token_addresses, list):
token_addresses = list(set(token_addresses))
if isinstance(chain_ids, int):
chain_ids = [chain_ids]
if isinstance(ccy, str):
ccy = [ccy]
elif isinstance(ccy, type(None)):
ccy = []
if ccy:
ccy = [
current.upper()
for current in ccy
if current.upper() in self.__get_fiat_ccy()
]
tokens = self.__get_valid_tokens(chain_ids, token_addresses)
result: dict[str, dict[str, dict[str, str]]] = self.__call_rpc("wallets", "getBalancesByChain", [wallets, tokens]).get("result", {})
data = [
{
"chain_id": int(chain_id),
"wallet_address": wallet_address,
"token_address": token_address,
"amount": token_hex_amount,
}
for chain_id, chain_info in result.items()
for wallet_address, wallet_info in chain_info.items()
for token_address, token_hex_amount in wallet_info.items()
]
if not data:
return pd.DataFrame()
data = pd.DataFrame(data)
available_tokens = self.get_tokens()
column_mapping = {"chain_id": "chain_id", "address": "token_address", "symbol": "token_symbol", "decimals": "decimals"}
data: pd.DataFrame = data.merge(
available_tokens[list(column_mapping.keys())].rename(columns=column_mapping).drop_duplicates(),
"left",
["chain_id", "token_address"]
)
final_columns = ["timestamp", "wallet_address", "token_address", "token_symbol", "amount", "chain_id"]
data = data.assign(
timestamp = datetime.datetime.now(),
chain_id = data["chain_id"].astype("int8"),
amount = data.apply(lambda row: int(row["amount"], 16) / (10 ** row["decimals"]), axis=1)
)[final_columns]
if not ccy:
return data.copy()
result = self.__call_rpc("wallets", "fetchPrices", [tokens, ccy]).get("result", {})
if not result:
return data.copy()
rates = pd.DataFrame([
{
"chain_id": int(address.split("-")[0]),
"token_address": address.split("-")[1],
"ccy": ccy,
"price": price
}
for address, prices in result.items()
for ccy, price in prices.items()
])
data = data.merge(rates, "outer", ["chain_id", "token_address"])
return data
def get_market(self, token_addresses: Union[list[str], str], chain_ids: Union[list[int], int] = 1, ccy: str = "USD") -> pd.DataFrame:
"""
Get market information for the provided token addresses and chain IDs.
Parameters:
- `token_addresses` - the token addresses as they appear in `get_tokens()`
- `chain_ids` - chain IDs as they appear in `self.chains`
- `ccy` - the fiat currency the market values will be fetched for.
Output:
- DataFrame containing the current market values and fiat amount
"""
if isinstance(token_addresses, str):
token_addresses = [token_addresses]
if isinstance(token_addresses, list):
token_addresses = list(set(token_addresses))
if isinstance(chain_ids, int):
chain_ids = [chain_ids]
ccy = ccy.upper()
available_ccy = self.__get_fiat_ccy()
if ccy not in available_ccy:
raise Exception(f"Given currency {ccy} is invalid...\nAvailable ISO 4217 currencies: {available_ccy}")
tokens = self.__get_valid_tokens(chain_ids, token_addresses)
market_info = pd.DataFrame([
{
"chain_id": int(token_address.split("-")[0]),
"address": token_address.split("-")[1],
"currency": ccy,
**info
}
for token_address, info in self.__call_rpc("wallets", "fetchMarketValues", [tokens, ccy]).get("result", {}).items()
])
market_info: pd.DataFrame = market_info.assign(
timestamp = datetime.datetime.now(),
chain_id = market_info["chain_id"].astype("int8")
)
market_info = market_info.merge(
self.get_tokens()[["chain_id", "address", "symbol"]].drop_duplicates(),
"left",
["chain_id", "address"]
)
column_mapping = {
"timestamp": "timestamp",
"chain_id": "chain_id",
"address": "token_address",
"symbol": "token_symbol",
"currency": "fiat_ccy",
"MKTCAP": "market_cap",
"HIGHDAY": "high_price",
"LOWDAY": "low_price",
"CHANGE24HOUR": "pnl_24hr",
"CHANGEPCTDAY": "pct_change",
"CHANGEPCTHOUR": "pct_change_1hr",
"CHANGEPCT24HOUR": "pct_change_24hr"
}
market_info = market_info.rename(columns=column_mapping)[list(column_mapping.values())]
return market_info.copy()
def send_transaction(self, address: str, symbol: str, amount: float, chain_id: int = 1) -> Optional[str]:
"""
Send crypto to specified `address`
Parameters:
- `address` - the wallet address of the receiver
- `symbol` - either a valid Status token symbol from `def get_tokens()` or its address
- `amount` - the amount that will be sent to the `address`
- `chain_id` - valid Chain from `self.chains`
Output:
- Transaction hash that to monitor the transactions progress
"""
is_eth = symbol == "ETH"
is_address = symbol.startswith("0x")
symbol = symbol.upper()
tokens = self.get_tokens()[["chain_id", "address", "symbol", "decimals"]].drop_duplicates().reset_index(drop=True)
query = (tokens["address" if is_address else "symbol"] == symbol) & (tokens["chain_id"] == chain_id)
if query.sum() == 0:
raise Exception(f"Given {'address' if is_address else 'symbol'} {symbol} on chain ID {chain_id} does not exist...")
token_info = tokens.loc[query].to_dict("records")[0]
balance = self.balance
query = (balance["address"] == token_info["address"]) & (balance["chain_id"] == chain_id)
if query.sum() == 0:
raise Exception(f"Given {'address' if is_address else 'symbol'} {symbol} on chain ID {chain_id} was not found in your wallet ({self.info['wallet_address']})...")
wallet_amount = balance.loc[query].reset_index(drop=True)["amount"].iloc[0]
if amount > wallet_amount:
raise Exception(f"Given {'address' if is_address else 'symbol'} {symbol} on chain ID {chain_id} has {wallet_amount} but you are trying to send {amount}...")
raw_amount = int(amount * (10**token_info["decimals"]))
tx = {
"version": 1,
"from": self.info["wallet_address"],
"to": address if is_eth else token_info["address"],
"value": hex(raw_amount) if is_eth else "0x0",
"fromChainID": chain_id,
"toChainID": chain_id,
}
if not is_eth:
encoded_args = eth_abi.encode(["address", "uint256"], [address, raw_amount]).hex()
tx["data"] = "0x" + self.__keccak256_selectors["transfer"] + encoded_args
payload = {
"password": self.info["password"],
"txArgs": tx
}
response = requests.post(self.__urls["http"]["transaction"], json=payload)
transaction_hash: str = response.json().get("result")
url = f"http://etherscan.io/tx/{transaction_hash}"
self.logger.info(f"Transaction: {url}")
return transaction_hash
def __start_messenger(self):
"""
Start the decentralized messaging service.
This is required for messages to be received / sent.
"""
if self.__is_messenger_launched:
return
self.logger.info("Starting messaging")
self.__call_rpc("messaging", "startMessenger")
self.__signal.get("wakuv2.peerstats")
self.__is_messenger_launched = True
self.logger.info("Messaging launched")
def __del__(self):
"""
Handles automatic logout when calling `del`
and after running `python`
"""
try:
self.logout()
except Exception:
pass
try:
self.__signal.close(None)
except Exception:
pass
def call_rpc(self, prefix: str, method_name: str, params: Optional[Union[list, dict]] = None) -> dict:
"""
For faster development purposes
"""
return self.__call_rpc(prefix, method_name, params)
def __load_backup(self):
"""
Try to load every file in the Docker volume
when an account recover is done.
"""
folder = self.__backup_folder if self.__backup_folder else self.__backup_sdk_folder
for file_name in os.listdir(folder):
if not file_name.endswith(".bkp"):
continue
file_path = os.path.join(folder, file_name)
sdk_file_path = os.path.join(self.__backup_sdk_folder, file_name)
if sdk_file_path != file_path:
shutil.copy(file_path, sdk_file_path)
params = {
"filePath": os.path.join(self.__docker_backup_folder, file_name).replace("\\", "/")
}
self.logger.info(f"Trying to load {file_path}")
response = requests.post(self.__urls["http"]["load_backup"], json=params)
error: str = response.json().get("error", "")
if sdk_file_path != file_path:
os.remove(sdk_file_path)
if len(error) == 0:
self.__signal.get("messages.new")
self.logger.info(f"Successfully loaded file!")
break
self.logger.warning(error)
def __call_rpc(self, prefix: str, method_name: str, params: Optional[Union[list, dict]] = None) -> dict:
"""
Make RPC calls to Status Backend
Parameters:
- `prefix` - the prefix of the method name
- `method_name` - the method name as it is in the backend
- `params` - RPC call parameters
Output:
- the raw output from the RPC method
"""
# Quick initialization check - RPC calls
# can be made only after the user has logged in
self.info
name = self.__prefix_mapping.get(prefix)
if not name:
raise ValueError(f"Name {name} does not exist... Available options: {list(self.__prefix_mapping.keys())}")
if name == "wallet" and not self.__is_wallet_set:
raise Exception(f"Cannot use this method without setting an `infura_token` and `coingecko_api_key` when calling `login`.")
data = {
'jsonrpc': '2.0',
# NOTE: Waku may be renamed to Logos Messaging (or something similar)
'method': f'{name}_{method_name}',
'id': None # Original code has an incrementing ID but it does not make a difference
}
if params:
data["params"] = params
response = requests.get(self.__urls["http"]["rpc"], json=data)
return response.json()
def __get_fiat_ccy(self) -> list[str]:
"""
Get the fiat currency symbols that Status has access to
https://www.iso.org/iso-4217-currency-codes.html
https://www.iban.com/currency-codes
Output:
- list of all fiat currency symbols
"""
if self.__iso4217_ccy:
return self.__iso4217_ccy
self.__iso4217_ccy = [
ccy.upper()
for ccy in self.__call_rpc("wallets", "getCachedCurrencyFormats").get("result", {}).keys()
if len(ccy) == 3 and ccy.upper() != "XXX"
]
return self.__iso4217_ccy
def __get_valid_tokens(self, chain_ids: list[int], token_addresses: list[str]) -> list[str]:
available_chains = list(self.chains.keys())
available_tokens = self.get_tokens()
tokens = []
for chain_id in chain_ids:
if chain_id not in available_chains:
continue
query = available_tokens["address"].isin(token_addresses) & (available_tokens["chain_id"] == chain_id)
selected = available_tokens.loc[query].reset_index(drop=True).copy()
if len(selected) == 0:
continue
tokens += selected.apply(lambda row: f"{row['chain_id']}-{row['address']}", axis=1).unique().tolist()
return tokens
def __camel_to_snake(self, name: str) -> str:
"""
Used to make camel case Status Backend keys
more Pythonic (snake case). Function is used
when the entire raw data point is returned.
Parameters:
- `name` - camel case dictionary key
Output:
- snake case `name`
"""
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
s2 = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s1)
return s2.lower()
def __validate_display_name(self, name: str) -> bool:
"""
Validate the display name based on Status App rules.
Validation most probably is dealt with on the GUI side
of the application instead of the backend.
Status App validation rules:
- Use A-Z and 0-9, hyphens and underscores only
- Display name must be at least 5 characters long
- Display name can't start or end with a space
Parameters:
- `name` - the name that the user wants to use to login / create account / change
Output:
- `True` if the name was successfully changed. A
"""
if name != name.strip():
raise ValueError("Display name cannot start or end with a space.")
if len(name) < 5:
raise ValueError("Display name must be at least 5 characters long.")
if len(name) > 24:
raise ValueError("Display name cannot be more than 24 characters long.")
if not re.fullmatch(r"[A-Za-z0-9 _-]+", name):
raise ValueError("Display name can contain only A-Z, 0-9, hyphens (-), underscores (_) and spaces.")
return True