From 6ff1afd46ddb59882e6ffea8a286da84d5e00601 Mon Sep 17 00:00:00 2001 From: Nathan Woodburn Date: Thu, 28 Nov 2024 17:13:39 +1100 Subject: [PATCH] feat: Add initial solana integration --- README.md | 5 +- chain_module.py | 21 +++- chains/solana.py | 187 +++++++++++++++++++++++++++++++-- example.env | 2 + price.py | 52 +++++++++ requirements.txt | 3 +- server.py | 19 +++- templates/assets/css/chain.css | 8 ++ templates/chain.html | 12 ++- templates/index.html | 13 +-- 10 files changed, 298 insertions(+), 24 deletions(-) create mode 100644 example.env create mode 100644 price.py diff --git a/README.md b/README.md index 6b90d6a..b8831d2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ # FireTax -Open Source Tax Calculator \ No newline at end of file +Open Source Tax Calculator + +## Notes +- Add a coingecko free demo api to speed up syncing (save it as api_keys/coingecko.txt) \ No newline at end of file diff --git a/chain_module.py b/chain_module.py index d5dc5c1..d2852c1 100644 --- a/chain_module.py +++ b/chain_module.py @@ -61,6 +61,12 @@ def getAllAddresses(): return addresses +def getAddresses(chain: str): + chain = importlib.import_module("chains."+chain) + if "listAddresses" in dir(chain): + return chain.listAddresses() + return [] + def deleteAddress(chain: str, address: str): chain = importlib.import_module("chains."+chain) if "deleteAddress" not in dir(chain): @@ -75,6 +81,13 @@ def syncChains(): if "sync" in dir(chain): chain.sync() +def syncChain(chain: str): + chain = importlib.import_module("chains."+chain) + if "sync" in dir(chain): + chain.sync() + return True + return False + def addAPIKey(chain: str, apiKey: str): chain = importlib.import_module("chains."+chain) if "addAPIKey" not in dir(chain): @@ -86,4 +99,10 @@ def getTransactions(chain: str): chain = importlib.import_module("chains."+chain) if "getTransactions" not in dir(chain): return False - return chain.getTransactions() \ No newline at end of file + return chain.getTransactions() + +def getTransactionsRender(chain:str): + chain = importlib.import_module("chains."+chain) + if "getTransactionsRender" not in dir(chain): + return False + return chain.getTransactionsRender() diff --git a/chains/solana.py b/chains/solana.py index 725f3dc..04c8cff 100644 --- a/chains/solana.py +++ b/chains/solana.py @@ -2,6 +2,13 @@ import json import requests import os import time +from datetime import datetime +from price import get_historical_fiat_price +import dotenv + +dotenv.load_dotenv() + +fiat = os.getenv("fiat") # Chain Data info = { @@ -14,7 +21,27 @@ info = { } - +known_tokens = { + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v":"usdc", + "cbbtcf3aa214zXHbiAZQwf4122FBYbraNdFqgw4iMij":"coinbase-wrapped-btc", + "Grass7B4RdKfBCjTKgSqnXkqjwiGvQyFbuSCUJr3XXjs":"grass", + "J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn":"jito-staked-sol", + "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh":"bitcoin", + "So11111111111111111111111111111111111111112":"solana", + "27G8MtK7VtTcCHkpASjSDdkWWYfoqT6ggEuKidVJidD4":"jupiter-perpetuals-liquidity-provider-token", + "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN":"jupiter" +} +other_token_names = { + "jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL":"JITO", + "9YZ2syoQHvMeksp4MYZoYMtLyFWkkyBgAsVuuJzSZwVu":"WDBRNT", + "J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn":"JitoSOL", + "8HWnGWTAXiFFtNsZwgk2AyvbUqqt8gcDVVcRVCZCfXC1":"Nathan.Woodburn/", + "cbbtcf3aa214zXHbiAZQwf4122FBYbraNdFqgw4iMij":"cbBTC", + "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh":"wBTC (Wormhole)", + "So11111111111111111111111111111111111111112":"wSOL", + "27G8MtK7VtTcCHkpASjSDdkWWYfoqT6ggEuKidVJidD4":"JLP", + "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN":"JUP" +} def validateAddress(address: str): return True @@ -74,13 +101,19 @@ def sync(): allTxs = [] for address in addresses: print("Checking address: " + address["address"]) + transactions = [] resp = requests.get("https://api.helius.xyz/v0/addresses/" + address["address"] + "/transactions/?api-key=" + apiKey) - if resp.status_code != 200: - print("Error syncing Solana chain") - print(resp.status_code) - return False - transactions = resp.json() - print(transactions) + while True: + if resp.status_code != 200: + print("Error syncing Solana chain") + print(resp.status_code) + print(resp.text) + break + if len(resp.json()) == 0: + break + transactions.extend(resp.json()) + resp = requests.get("https://api.helius.xyz/v0/addresses/" + address["address"] + "/transactions/?api-key=" + apiKey + "&before=" + resp.json()[-1]["signature"]) + print("Checking page...") allTxs.append({ @@ -91,6 +124,12 @@ def sync(): with open("chain_data/solana.json", "w") as f: json.dump(allTxs, f) + + # Parse transactions + for address in allTxs: + for tx in address["txs"]: + parseTransaction(tx,address["address"]) + return True def getTransactions(): @@ -99,7 +138,137 @@ def getTransactions(): transactions = [] for address in addresses: for tx in address["txs"]: - transactions.append(tx) + transactions.append(parseTransaction(tx,address["address"])) - #TODO Parse transactions return transactions + +def parseTransaction(tx,address): + solChange = getSOLChange(tx,address) + tokenChange = getTokenChange(tx, address) + fiatChange = getFiatChange(tx, address) + return { + "hash": tx["signature"], + "timestamp": tx["timestamp"], + "SOLChange": solChange, + "TokenChange": tokenChange, + "FiatChange": fiatChange, + "type": tx["type"], + "description": tx["description"] + } + +def getSOLChange(tx,address): + for account in tx["accountData"]: + if account["account"] == address: + return account["nativeBalanceChange"] * 0.000000001 + return 0 + +def getTokenChange(tx,address): + changes = [] + for transfer in tx["tokenTransfers"]: + if transfer["fromUserAccount"] == address: + changes.append({ + "userAccount": transfer["fromUserAccount"], + "tokenAccount": transfer["fromTokenAccount"], + "tokenAmount": transfer["tokenAmount"] * -1, + "mint": transfer["mint"], + "tokenStandard": transfer["tokenStandard"], + "toUserAccount": transfer["toUserAccount"], + "toTokenAccount": transfer["toTokenAccount"], + "fiat": get_token_fiat(transfer,address,tx["timestamp"]) + }) + if transfer["toUserAccount"] == address: + changes.append({ + "userAccount": transfer["toUserAccount"], + "tokenAccount": transfer["toTokenAccount"], + "tokenAmount": transfer["tokenAmount"], + "mint": transfer["mint"], + "tokenStandard": transfer["tokenStandard"], + "fromUserAccount": transfer["fromUserAccount"], + "fromTokenAccount": transfer["fromTokenAccount"], + "fiat": get_token_fiat(transfer,address,tx["timestamp"]) + }) + + return changes + +def get_token_fiat(transfer,address,timestamp): + date = datetime.fromtimestamp(timestamp).strftime('%d-%m-%Y') + tokenID = transfer["mint"] + if tokenID in known_tokens: + tokenID = known_tokens[tokenID] + return transfer["tokenAmount"] * get_historical_fiat_price(tokenID, date) + return "Unknown" + +def getFiatChange(tx,address): + solChange = getSOLChange(tx,address) + timestamp = tx["timestamp"] + date = datetime.fromtimestamp(timestamp).strftime('%d-%m-%Y') + + fiat_rate = get_historical_fiat_price("solana", date) + + tokenChanges = getTokenChange(tx, address) + tokenFiatChange = 0 + for tokenChange in tokenChanges: + if 'fiat' in tokenChange: + if tokenChange["fiat"] != "Unknown": + tokenFiatChange += tokenChange["fiat"] + + return (fiat_rate * solChange) + tokenFiatChange + +def getTokenName(token_ID): + name = token_ID + if token_ID in known_tokens: + name = known_tokens[token_ID].upper() + if token_ID in other_token_names: + name = other_token_names[token_ID] + return name +def convertTimestamp(timestamp): + return datetime.fromtimestamp(timestamp).strftime('%d %b %Y %I:%M %p') + +def renderSOLChange(solChange): + if solChange > 0: + if solChange < 0.001: + return f"+ <0.001 SOL" + return f"+{round(solChange,4)} SOL" + + if solChange > -0.001: + return f"- <0.001 SOL" + return f"{round(solChange,4)} SOL" + +def renderFiatChange(fiatChange): + if fiatChange == "Unknown": + return "Unknown Fiat Value" + if fiatChange > 0: + if fiatChange < 0.01: + return f"+ <0.01 {fiat.upper()}" + return f"+{round(fiatChange,2)} {fiat.upper()}" + + if fiatChange > -0.01: + return f"- <0.01 {fiat.upper()}" + return f"{round(fiatChange,2)} {fiat.upper()}" + + +def getTransactionsRender(): + transactions = getTransactions() + html = "" + + totalChange = 0 + for tx in transactions: + totalChange += tx["FiatChange"] + # If fiat change <= 0.001 then don't show + if abs(tx["FiatChange"]) <= 0.005 and len(tx["TokenChange"]) <= 1: + continue + + html += f"
" + html += f"
{tx['description'] or "Unknown TX"} ({convertTimestamp(tx['timestamp'])})
" + html += f"
SOL change: {renderSOLChange(tx['SOLChange'])}
" + html += f"
" + for tokenChange in tx["TokenChange"]: + html += f"
" + html += f"
{tokenChange['tokenAmount']} {getTokenName(tokenChange['mint'])} ({renderFiatChange(tokenChange['fiat'])})
" + html += f"
" + html += f"
" + + if tx["FiatChange"] != 0: + html += f"
{tx['FiatChange']} {fiat.upper()}
" + html += f"
" + return f"

Transactions: {len(transactions)} (P/L {renderFiatChange(totalChange)})

" + html \ No newline at end of file diff --git a/example.env b/example.env new file mode 100644 index 0000000..c0955ba --- /dev/null +++ b/example.env @@ -0,0 +1,2 @@ +fiat=aud +usd_to_fiat=1.54 diff --git a/price.py b/price.py new file mode 100644 index 0000000..4020399 --- /dev/null +++ b/price.py @@ -0,0 +1,52 @@ +from pycoingecko import CoinGeckoAPI +import json +import os +import dotenv + +dotenv.load_dotenv() + +fiat = os.getenv("fiat") +usd_to_fiat = float(os.getenv("usd_to_fiat")) +stablecoins = ["usdc", "usdt", "dai"] + +# Get api key +if os.path.exists("api_keys/coingecko.txt"): + with open("api_keys/coingecko.txt", "r") as f: + apiKey = f.read() +else: + apiKey = None + + +def get_historical_fiat_price(coin_id, date): + """ + Fetches the historical price of a cryptocurrency in fiat on a specific date. + + Args: + coin_id (str): The CoinGecko ID of the cryptocurrency (e.g., 'bitcoin', 'ethereum'). + date (str): The date in YYYY-MM-DD format. + + Returns: + float: The historical price of the cryptocurrency in specified fiat currency. + """ + + if coin_id in stablecoins: + return 1 * usd_to_fiat + + historical_prices = {} + if os.path.exists(f"chain_data/{coin_id}_price.json"): + with open(f"chain_data/{coin_id}_price.json", "r") as f: + historical_prices = json.load(f) + if date in historical_prices: + return historical_prices[date] + + + cg = CoinGeckoAPI() + historical_data = cg.get_coin_history_by_id(id=coin_id, date=date, localization='false', demo_api_key=apiKey) + price_data = historical_data['market_data']['current_price'] + fiat_price = price_data[fiat] + historical_prices[date] = fiat_price + + with open(f"chain_data/{coin_id}_price.json", "w") as f: + json.dump(historical_prices, f) + + return fiat_price diff --git a/requirements.txt b/requirements.txt index 8cf7fcb..6aec9d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ flask gunicorn requests -python-dotenv \ No newline at end of file +python-dotenv +pycoingecko \ No newline at end of file diff --git a/server.py b/server.py index 68fb3f7..1ab3dbc 100644 --- a/server.py +++ b/server.py @@ -90,8 +90,10 @@ def chains(path: str): # Get chain info chain = chain_module.getChainData(path) - transactions = chain_module.getTransactions(path) - return render_template("chain.html",chain=chain['name'],transactions=transactions) + transactions = chain_module.getTransactionsRender(path) + addresses = chain_module.getAddresses(path) + + return render_template("chain.html",chain=chain['name'],transactions=transactions,addresses=addresses) @app.route("/chains/", methods=["POST"]) def chainsPost(path: str): @@ -107,7 +109,7 @@ def chainsPost(path: str): if not chain_module.importAddress(path, address): return jsonify({"error": "Error importing address"}), 400 - return redirect('/') + return redirect('/chains/'+path) @app.route("/chains//delete/
") def chainsDelete(path: str, address: str): @@ -118,7 +120,7 @@ def chainsDelete(path: str, address: str): if not chain_module.deleteAddress(path, address): return jsonify({"error": "Error deleting address"}), 400 - return redirect('/') + return redirect('/chains/'+path) @app.route("/chains//addAPIKey", methods=["POST"]) def chainsAddAPIKey(path: str): @@ -130,7 +132,7 @@ def chainsAddAPIKey(path: str): apiKey = request.form['apiKey'] if not chain_module.addAPIKey(path, apiKey): return jsonify({"error": "Error adding API key"}), 400 - return redirect('/') + return redirect('/chains/'+path) @app.route("/chains/sync") @@ -138,6 +140,13 @@ def chainsSync(): chain_module.syncChains() return redirect('/') +@app.route("/chains/sync/") +def chainSync(path: str): + path = path.lower() + chain_module.syncChain(path) + return redirect('/chains/'+path) + + @app.route("/") def catch_all(path: str): if os.path.isfile("templates/" + path): diff --git a/templates/assets/css/chain.css b/templates/assets/css/chain.css index bd685ed..e047922 100644 --- a/templates/assets/css/chain.css +++ b/templates/assets/css/chain.css @@ -26,4 +26,12 @@ a:hover { margin: auto; margin-top: 10px; +} + +.transaction { + border: 1px solid #ffffff; + border-radius: 10px; + padding: 10px 20px; + margin: auto; + margin-top: 10px; } \ No newline at end of file diff --git a/templates/chain.html b/templates/chain.html index 5d5dc56..aa6b141 100644 --- a/templates/chain.html +++ b/templates/chain.html @@ -13,7 +13,17 @@

Nathan.Woodburn/ {{chain}}

+ Sync +
+

Imported Addresses

+
+ {% for address in addresses %} + {{address}} Delete +
+ {% endfor %} +
+

Import address

@@ -21,7 +31,7 @@
-

Add API Key

+

Set API Key

diff --git a/templates/index.html b/templates/index.html index 23ec283..a2197fe 100644 --- a/templates/index.html +++ b/templates/index.html @@ -14,6 +14,12 @@

Nathan.Woodburn/

+
+

Integrated chains

+ {% for chain in chains %} +

{{ chain.name }}

+ {% endfor %} +

Imported Addresses

{% for chain in addresses %} @@ -27,12 +33,7 @@ {% endfor %}
-
-

Add New Address

- {% for chain in chains %} -

{{ chain.name }}

- {% endfor %} -
+