...
This commit is contained in:
		
							
								
								
									
										0
									
								
								herolib/clients/stellar/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								herolib/clients/stellar/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										241
									
								
								herolib/clients/stellar/horizon.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								herolib/clients/stellar/horizon.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,241 @@
 | 
			
		||||
from dataclasses import dataclass, field, asdict
 | 
			
		||||
from typing import List, Optional
 | 
			
		||||
from stellar_sdk import Keypair, Server, StrKey
 | 
			
		||||
import json
 | 
			
		||||
import redis
 | 
			
		||||
from stellar.model import StellarAsset, StellarAccount
 | 
			
		||||
import os
 | 
			
		||||
import csv
 | 
			
		||||
import toml
 | 
			
		||||
from herotools.texttools import description_fix
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HorizonServer:
 | 
			
		||||
    def __init__(self, instance: str = "default", network: str = "main", tomlfile: str = "", owner: str = ""):
 | 
			
		||||
        """
 | 
			
		||||
        Load a Stellar account's information using the Horizon server.
 | 
			
		||||
        The Horizon server is an API that allows interaction with the Stellar network. It provides endpoints to submit transactions, check account balances, and perform other operations on the Stellar ledger.
 | 
			
		||||
        All gets cached in redis
 | 
			
		||||
        """
 | 
			
		||||
        self.redis_client = redis.Redis(host='localhost', port=6379, db=0)  # Adjust as needed
 | 
			
		||||
        self.instance = instance
 | 
			
		||||
        if network not in ['main', 'testnet']:
 | 
			
		||||
            raise ValueError("Invalid network value. Must be 'main' or 'testnet'.")
 | 
			
		||||
        self.network = network
 | 
			
		||||
        testnet = self.network == 'testnet'
 | 
			
		||||
        self.server = Server("https://horizon-testnet.stellar.org" if testnet else "https://horizon.stellar.org")
 | 
			
		||||
        self.tomlfile = os.path.expanduser(tomlfile)
 | 
			
		||||
        self.owner = owner
 | 
			
		||||
        if self.tomlfile:
 | 
			
		||||
            self.toml_load()
 | 
			
		||||
 | 
			
		||||
    def account_exists(self, pubkey: str) -> bool:
 | 
			
		||||
        """
 | 
			
		||||
        Check if an account exists in the Redis cache based on the public key.
 | 
			
		||||
        """
 | 
			
		||||
        redis_key = f"stellar:{self.instance}:accounts:{pubkey}"
 | 
			
		||||
        return self.redis_client.exists(redis_key) != None
 | 
			
		||||
 | 
			
		||||
    def account_get(self, key: str, reload: bool = False, name: str = "", description: str = "", cat: str = "") -> StellarAccount:
 | 
			
		||||
        """
 | 
			
		||||
        Load a Stellar account's information.
 | 
			
		||||
 | 
			
		||||
        Args:
 | 
			
		||||
            key (str): The private or public key of the Stellar account.
 | 
			
		||||
            reset (bool, optional): Whether to force a refresh of the cached data. Defaults to False.
 | 
			
		||||
            name (str, optional): Name for the account. Defaults to "".
 | 
			
		||||
            description (str, optional): Description for the account. Defaults to "".
 | 
			
		||||
            owner (str, optional): Owner of the account. Defaults to "".
 | 
			
		||||
            cat (str, optional): Category of the account. Defaults to "".
 | 
			
		||||
 | 
			
		||||
        Returns:
 | 
			
		||||
            StellarAccount: A struct containing the account's information.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        if key == "" and name:
 | 
			
		||||
            for redis_key in self.redis_client.scan_iter(f"stellar:{self.instance}:accounts:*"):
 | 
			
		||||
                data = self.redis_client.get(redis_key)
 | 
			
		||||
                if data:
 | 
			
		||||
                    data = json.loads(str(data))
 | 
			
		||||
                    if data.get('name') == name and data.get('priv_key', data.get('public_key')):
 | 
			
		||||
                        key = data.get('priv_key', data.get('public_key'))
 | 
			
		||||
                        break
 | 
			
		||||
 | 
			
		||||
        if key == "":
 | 
			
		||||
            raise ValueError("No key provided")
 | 
			
		||||
 | 
			
		||||
        # Determine if the key is a public or private key
 | 
			
		||||
        if StrKey.is_valid_ed25519_public_key(key):
 | 
			
		||||
            public_key = key
 | 
			
		||||
            priv_key = ""
 | 
			
		||||
        elif StrKey.is_valid_ed25519_secret_seed(key):
 | 
			
		||||
            priv_key = key
 | 
			
		||||
            keypair = Keypair.from_secret(priv_key)
 | 
			
		||||
            public_key = keypair.public_key
 | 
			
		||||
        else:
 | 
			
		||||
            raise ValueError("Invalid Stellar key provided")
 | 
			
		||||
 | 
			
		||||
        redis_key = f"stellar:{self.instance}:accounts:{public_key}"
 | 
			
		||||
 | 
			
		||||
        data = self.redis_client.get(redis_key)
 | 
			
		||||
        changed = False
 | 
			
		||||
        if data:
 | 
			
		||||
            try:
 | 
			
		||||
                data = json.loads(str(data))
 | 
			
		||||
            except  Exception as e:
 | 
			
		||||
                print(data)
 | 
			
		||||
                raise e
 | 
			
		||||
            data['assets'] = [StellarAsset(**asset) for asset in data['assets']]
 | 
			
		||||
            account =  StellarAccount(**data)
 | 
			
		||||
            if description!="" and description!=account.description:
 | 
			
		||||
                account.description = description
 | 
			
		||||
                changed = True
 | 
			
		||||
            if name!="" and name!=account.name:
 | 
			
		||||
                account.name = name
 | 
			
		||||
                changed = True
 | 
			
		||||
            if self.owner!="" and self.owner!=account.owner:
 | 
			
		||||
                account.owner = self.owner
 | 
			
		||||
                changed = True
 | 
			
		||||
            if cat!="" and cat!=account.cat:
 | 
			
		||||
                account.cat = cat
 | 
			
		||||
                changed = True
 | 
			
		||||
        else:
 | 
			
		||||
            account =  StellarAccount(public_key=public_key, description=description, name=name, priv_key=priv_key, owner=self.owner, cat=cat)
 | 
			
		||||
            changed = True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        if reload or account.assets == []:
 | 
			
		||||
            changed = True
 | 
			
		||||
            if reload:
 | 
			
		||||
                account.assets = []
 | 
			
		||||
            account_data = self.server.accounts().account_id(public_key).call()
 | 
			
		||||
            account.assets.clear()  # Clear existing assets to avoid duplication
 | 
			
		||||
            for balance in account_data['balances']:
 | 
			
		||||
                asset_type = balance['asset_type']
 | 
			
		||||
                if asset_type == 'native':
 | 
			
		||||
                    account.assets.append(StellarAsset(type="XLM", balance=balance['balance']))
 | 
			
		||||
                else:
 | 
			
		||||
                    if 'asset_code' in balance:
 | 
			
		||||
                        account.assets.append(StellarAsset(
 | 
			
		||||
                            type=balance['asset_code'],
 | 
			
		||||
                            issuer=balance['asset_issuer'],
 | 
			
		||||
                            balance=balance['balance']
 | 
			
		||||
                        ))
 | 
			
		||||
            changed = True
 | 
			
		||||
 | 
			
		||||
        # Cache the result in Redis for 1 hour if there were changes
 | 
			
		||||
        if changed:
 | 
			
		||||
            self.account_save(account)
 | 
			
		||||
 | 
			
		||||
        return account
 | 
			
		||||
 | 
			
		||||
    def comment_add(self, pubkey: str, comment: str, ignore_non_exist: bool = False):
 | 
			
		||||
        """
 | 
			
		||||
        Add a comment to a Stellar account based on the public key.
 | 
			
		||||
 | 
			
		||||
        Args:
 | 
			
		||||
            pubkey (str): The public key of the Stellar account.
 | 
			
		||||
            comment (str): The comment to add to the account.
 | 
			
		||||
        """
 | 
			
		||||
        comment = description_fix(comment)
 | 
			
		||||
        if not self.account_exists(pubkey):
 | 
			
		||||
            if ignore_non_exist:
 | 
			
		||||
                return
 | 
			
		||||
            raise ValueError("Account does not exist in the cache")
 | 
			
		||||
        account = self.account_get(pubkey)
 | 
			
		||||
        account.comments.append(comment)
 | 
			
		||||
        self.account_save(account)
 | 
			
		||||
 | 
			
		||||
    def account_save(self, account: StellarAccount):
 | 
			
		||||
        """
 | 
			
		||||
        Save a Stellar account's information to the Redis cache.
 | 
			
		||||
 | 
			
		||||
        Args:
 | 
			
		||||
            account (StellarAccount): The account to save.
 | 
			
		||||
        """
 | 
			
		||||
        redis_key = f"stellar:{self.instance}:accounts:{account.public_key}"
 | 
			
		||||
        self.redis_client.setex(redis_key, 600, json.dumps(asdict(account)))
 | 
			
		||||
 | 
			
		||||
    def reload_cache(self):
 | 
			
		||||
        """
 | 
			
		||||
        Walk over all known accounts and reload their information.
 | 
			
		||||
        """
 | 
			
		||||
        for redis_key in self.redis_client.scan_iter(f"stellar:{self.instance}:accounts:*"):
 | 
			
		||||
            data = self.redis_client.get(redis_key) or ""
 | 
			
		||||
            if data:
 | 
			
		||||
                data = json.loads(str(data))
 | 
			
		||||
                public_key = data.get('public_key')
 | 
			
		||||
                if public_key:
 | 
			
		||||
                    self.account_get(public_key, reload=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    #format is PUBKEY,DESCRIPTION  in text format
 | 
			
		||||
    def load_accounts_csv(self, file_path:str):
 | 
			
		||||
        file_path=os.path.expanduser(file_path)
 | 
			
		||||
        if not os.path.exists(file_path):
 | 
			
		||||
            return Exception(f"Error: File '{file_path}' does not exist.")
 | 
			
		||||
        try:
 | 
			
		||||
            with open(file_path, 'r', newline='') as file:
 | 
			
		||||
                reader = csv.reader(file, delimiter=',')
 | 
			
		||||
                for row in reader:
 | 
			
		||||
                    if row and len(row) >= 2:  # Check if row is not empty and has at least 2 elements
 | 
			
		||||
                        pubkey = row[0].strip()
 | 
			
		||||
                        comment = ','.join(row[1:]).strip()
 | 
			
		||||
                        if self.account_exists(pubkey):
 | 
			
		||||
                            self.comment_add(pubkey, comment)
 | 
			
		||||
        except IOError as e:
 | 
			
		||||
            return Exception(f"Error reading file: {e}")
 | 
			
		||||
        except csv.Error as e:
 | 
			
		||||
            return Exception(f"Error parsing CSV: {e}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            return Exception(f"Error: {e}")
 | 
			
		||||
 | 
			
		||||
    def accounts_get(self) -> List[StellarAccount]:
 | 
			
		||||
        """
 | 
			
		||||
        Retrieve a list of all known Stellar accounts from the Redis cache.
 | 
			
		||||
 | 
			
		||||
        Returns:
 | 
			
		||||
            List[StellarAccount]: A list of StellarAccount objects.
 | 
			
		||||
        """
 | 
			
		||||
        accounts = []
 | 
			
		||||
        for redis_key in self.redis_client.scan_iter(f"stellar:{self.instance}:accounts:*"):
 | 
			
		||||
            pubkey = str(redis_key.split(':')[-1])
 | 
			
		||||
            accounts.append(self.account_get(key=pubkey))
 | 
			
		||||
        return accounts
 | 
			
		||||
 | 
			
		||||
    def toml_save(self):
 | 
			
		||||
        """
 | 
			
		||||
        Save the list of all known Stellar accounts to a TOML file.
 | 
			
		||||
 | 
			
		||||
        Args:
 | 
			
		||||
            file_path (str): The path where the list needs to be saved.
 | 
			
		||||
        """
 | 
			
		||||
        if self.tomlfile == "":
 | 
			
		||||
            raise ValueError("No TOML file path provided")
 | 
			
		||||
        accounts = self.accounts_get()
 | 
			
		||||
        accounts_dict = {account.public_key: asdict(account) for account in accounts}
 | 
			
		||||
        with open(self.tomlfile, 'w') as file:
 | 
			
		||||
            toml.dump( accounts_dict, file)
 | 
			
		||||
 | 
			
		||||
    def toml_load(self):
 | 
			
		||||
        """
 | 
			
		||||
        Load the list of Stellar accounts from a TOML file and save them to the Redis cache.
 | 
			
		||||
 | 
			
		||||
        Args:
 | 
			
		||||
            file_path (str): The path of the TOML file to load.
 | 
			
		||||
        """
 | 
			
		||||
        if not os.path.exists(self.tomlfile):
 | 
			
		||||
            return
 | 
			
		||||
            #raise FileNotFoundError(f"Error: File '{self.tomlfile}' does not exist.")
 | 
			
		||||
        with open(self.tomlfile, 'r') as file:
 | 
			
		||||
            accounts_dict = toml.load(file)
 | 
			
		||||
            for pubkey, account_data in accounts_dict.items():
 | 
			
		||||
                account_data['assets'] = [StellarAsset(**asset) for asset in account_data['assets']]
 | 
			
		||||
                account = StellarAccount(**account_data)
 | 
			
		||||
                self.account_save(account)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def new(instance: str = "default",owner: str = "", network: str = "main", tomlfile: str = "") -> HorizonServer:
 | 
			
		||||
    return HorizonServer(instance=instance, network=network, tomlfile=tomlfile,owner=owner)
 | 
			
		||||
							
								
								
									
										70
									
								
								herolib/clients/stellar/model.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								herolib/clients/stellar/model.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,70 @@
 | 
			
		||||
from dataclasses import dataclass, field, asdict
 | 
			
		||||
from typing import List, Optional
 | 
			
		||||
from stellar_sdk import Keypair, Server, StrKey
 | 
			
		||||
import json
 | 
			
		||||
import redis
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class StellarAsset:
 | 
			
		||||
    type: str
 | 
			
		||||
    balance: float
 | 
			
		||||
    issuer: str = ""
 | 
			
		||||
 | 
			
		||||
    def format_balance(self):
 | 
			
		||||
        balance_float = float(self.balance)
 | 
			
		||||
        formatted_balance = f"{balance_float:,.2f}"
 | 
			
		||||
        if '.' in formatted_balance:
 | 
			
		||||
            formatted_balance = formatted_balance.rstrip('0').rstrip('.')
 | 
			
		||||
        return formatted_balance
 | 
			
		||||
 | 
			
		||||
    def md(self):
 | 
			
		||||
        formatted_balance = self.format_balance()
 | 
			
		||||
        return f"- **{self.type}**: {formatted_balance}"
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class StellarAccount:
 | 
			
		||||
    owner: str
 | 
			
		||||
    priv_key: str = ""
 | 
			
		||||
    public_key: str = ""
 | 
			
		||||
    assets: List[StellarAsset] = field(default_factory=list)
 | 
			
		||||
    name: str = ""
 | 
			
		||||
    description: str = ""
 | 
			
		||||
    comments: List[str] = field(default_factory=list)
 | 
			
		||||
    cat: str = ""
 | 
			
		||||
    question: str = ""
 | 
			
		||||
 | 
			
		||||
    def md(self):
 | 
			
		||||
        result = [
 | 
			
		||||
            f"# Stellar Account: {self.name or 'Unnamed'}","",
 | 
			
		||||
            f"**Public Key**: {self.public_key}",
 | 
			
		||||
            f"**Cat**: {self.cat}",
 | 
			
		||||
            f"**Description**: {self.description[:60]}..." if self.description else "**Description**: None",
 | 
			
		||||
            f"**Question**: {self.question}" if self.question else "**Question**: None",
 | 
			
		||||
            "",
 | 
			
		||||
            "## Assets:",""
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        for asset in self.assets:
 | 
			
		||||
            result.append(asset.md())
 | 
			
		||||
 | 
			
		||||
        if len(self.assets) == 0:
 | 
			
		||||
            result.append("- No assets")
 | 
			
		||||
 | 
			
		||||
        result.append("")
 | 
			
		||||
 | 
			
		||||
        if self.comments:
 | 
			
		||||
            result.append("## Comments:")
 | 
			
		||||
            for comment in self.comments:
 | 
			
		||||
                if '\n' in comment:
 | 
			
		||||
                    multiline_comment = "\n    ".join(comment.split('\n'))
 | 
			
		||||
                    result.append(f"- {multiline_comment}")
 | 
			
		||||
                else:
 | 
			
		||||
                    result.append(f"- {comment}")
 | 
			
		||||
 | 
			
		||||
        return "\n".join(result)
 | 
			
		||||
 | 
			
		||||
    def balance_str(self) -> str:
 | 
			
		||||
        out=[]
 | 
			
		||||
        for asset in self.assets:
 | 
			
		||||
            out.append(f"{asset.type}:{float(asset.balance):,.0f}")
 | 
			
		||||
        return " ".join(out)
 | 
			
		||||
							
								
								
									
										78
									
								
								herolib/clients/stellar/model_accounts.v
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								herolib/clients/stellar/model_accounts.v
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,78 @@
 | 
			
		||||
module stellar
 | 
			
		||||
import freeflowuniverse.crystallib.core.texttools
 | 
			
		||||
 | 
			
		||||
pub struct DigitalAssets {
 | 
			
		||||
pub mut:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub struct Owner {
 | 
			
		||||
pub mut:
 | 
			
		||||
    name        string
 | 
			
		||||
	accounts []Account
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@[params]
 | 
			
		||||
pub struct AccountGetArgs{
 | 
			
		||||
pub mut:
 | 
			
		||||
	name string
 | 
			
		||||
	bctype BlockChainType	
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub fn (self DigitalAssets) account_get(args_ AccountGetArgs) !&Account {
 | 
			
		||||
 | 
			
		||||
    mut accounts := []&Account
 | 
			
		||||
	mut args:=args_
 | 
			
		||||
 | 
			
		||||
	args.name = texttools.name_fix(args.name)
 | 
			
		||||
    
 | 
			
		||||
    for account in self.accounts {
 | 
			
		||||
        if account.name == args.name && account.bctype == args.bctype {
 | 
			
		||||
            accounts<<&account 
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if accounts.len == 0 {
 | 
			
		||||
        return error('No account found with the given name:${args.name} and blockchain type: ${args.bctype}')
 | 
			
		||||
    } else if count > 1 {
 | 
			
		||||
        return error('Multiple accounts found with the given name:${args.name} and blockchain type: ${args.bctype}')
 | 
			
		||||
    }
 | 
			
		||||
        
 | 
			
		||||
    return accounts[0]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub struct Account {
 | 
			
		||||
pub mut:
 | 
			
		||||
    name        string
 | 
			
		||||
    secret      string
 | 
			
		||||
    pubkey      string
 | 
			
		||||
    description string
 | 
			
		||||
    cat         string 
 | 
			
		||||
    owner 		string 
 | 
			
		||||
    assets      []Asset
 | 
			
		||||
	bctype 		BlockChainType
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub struct Asset {
 | 
			
		||||
pub mut:
 | 
			
		||||
	amount      int
 | 
			
		||||
	assettype 		AssetType
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub fn (self Asset) name() string {
 | 
			
		||||
	return self.assettype.name
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub struct AssetType {
 | 
			
		||||
pub mut:
 | 
			
		||||
    name        string
 | 
			
		||||
	issuer      string
 | 
			
		||||
	bctype 		BlockChainType
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub enum BlockChainType{
 | 
			
		||||
	stellar_pub
 | 
			
		||||
	stellar_test
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										46
									
								
								herolib/clients/stellar/testnet.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								herolib/clients/stellar/testnet.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
			
		||||
from typing import Tuple
 | 
			
		||||
from stellar_sdk import Server, Keypair, TransactionBuilder, Network, Asset, Signer, TransactionEnvelope
 | 
			
		||||
import redis
 | 
			
		||||
import requests
 | 
			
		||||
import json
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
def create_account_on_testnet() -> Tuple[str, str]:
 | 
			
		||||
 | 
			
		||||
    def fund(public_key: str) -> float:
 | 
			
		||||
        # Request funds from the Stellar testnet friendbot
 | 
			
		||||
        response = requests.get(f"https://friendbot.stellar.org?addr={public_key}")
 | 
			
		||||
        if response.status_code != 200:
 | 
			
		||||
            raise Exception("Failed to fund new account with friendbot")
 | 
			
		||||
        time.sleep(1)
 | 
			
		||||
        return balance(public_key)
 | 
			
		||||
 | 
			
		||||
    def create_account() -> Tuple[str, str]:
 | 
			
		||||
        # Initialize Redis client
 | 
			
		||||
        redis_client = redis.StrictRedis(host='localhost', port=6379, db=0)
 | 
			
		||||
 | 
			
		||||
        # Generate keypair
 | 
			
		||||
        keypair = Keypair.random()
 | 
			
		||||
        public_key = keypair.public_key
 | 
			
		||||
        secret_key = keypair.secret
 | 
			
		||||
        account_data = {
 | 
			
		||||
            "public_key": public_key,
 | 
			
		||||
            "secret_key": secret_key
 | 
			
		||||
        }
 | 
			
		||||
        redis_client.set("stellartest:testaccount", json.dumps(account_data))
 | 
			
		||||
        time.sleep(1)
 | 
			
		||||
        return public_key, secret_key
 | 
			
		||||
 | 
			
		||||
    # Check if the account already exists in Redis
 | 
			
		||||
    if redis_client.exists("stellartest:testaccount"):
 | 
			
		||||
        account_data = json.loads(redis_client.get("stellartest:testaccount"))
 | 
			
		||||
        public_key = account_data["public_key"]
 | 
			
		||||
        secret_key = account_data["secret_key"]
 | 
			
		||||
        r = balance(public_key)
 | 
			
		||||
        if r < 100:
 | 
			
		||||
            fund(public_key)
 | 
			
		||||
            r = balance(public_key)
 | 
			
		||||
        return public_key, secret_key
 | 
			
		||||
    else:
 | 
			
		||||
        create_account()
 | 
			
		||||
        return create_account_on_testnet()
 | 
			
		||||
		Reference in New Issue
	
	Block a user