20 Commits

Author SHA1 Message Date
1203719eac fix: Docker python version and fix reporting missing requirements
All checks were successful
Build Docker / Build Image (push) Successful in 2m58s
2025-08-29 13:28:12 +10:00
373a71f04d Merge pull request 'SPV support & add internal HSD node' (#4) from feat/internal_hsd into main
All checks were successful
Build Docker / Build Image (push) Successful in 2m13s
Reviewed-on: #4
2025-08-29 13:04:30 +10:00
b76b873036 feat: Update readme
All checks were successful
Build Docker / Build Image (push) Successful in 54s
2025-08-28 17:50:47 +10:00
23e714fad8 feat: Add additional node info to settings
All checks were successful
Build Docker / Build Image (push) Successful in 45s
2025-08-28 17:27:45 +10:00
a36c69ecfc fix: Add SPV support for getDNS()
All checks were successful
Build Docker / Build Image (push) Successful in 55s
2025-08-28 17:18:52 +10:00
1fd9987bf1 feat: Upgrade tx page caches to a sqlite db 2025-08-28 17:13:23 +10:00
f2cda461ba feat: Add SPV features to fix accoutn balances
All checks were successful
Build Docker / Build Image (push) Successful in 2m9s
2025-08-28 16:42:12 +10:00
26c5b4a4fa feat: Update configuration storage and overrides
All checks were successful
Build Docker / Build Image (push) Successful in 53s
2025-08-26 18:04:07 +10:00
7fdc4a3122 fix: SPV causes some domains to not be recognized as owned by the wallet
All checks were successful
Build Docker / Build Image (push) Successful in 50s
2025-08-26 17:31:25 +10:00
5ff8960b7b feat: Add initial internal node option
All checks were successful
Build Docker / Build Image (push) Successful in 10m29s
2025-08-26 16:44:10 +10:00
4c84bc2bbe fix: Use hsd.hns.au to get name from namehash in order to only import account once 2025-08-26 15:26:19 +10:00
49e378803d fix: Use existing hsd from accounts module to get name from hash
All checks were successful
Build Docker / Build Image (push) Successful in 2m7s
2025-08-26 12:52:14 +10:00
1c53547047 feat: Add env flag to disable WALLET DNS record lookup
All checks were successful
Build Docker / Build Image (push) Successful in 47s
2025-08-25 18:10:03 +10:00
080c4402d8 Merge pull request 'Add WALLET DNS record for sending using domain alias' (#3) from feat/WALLETDNS into main
All checks were successful
Build Docker / Build Image (push) Successful in 53s
Reviewed-on: #3
2025-08-25 13:59:15 +10:00
792688064e fix: More code cleanup
All checks were successful
Build Docker / Build Image (push) Successful in 2m9s
2025-08-25 12:43:12 +10:00
599c0df00c feat: Add more code cleanup
All checks were successful
Build Docker / Build Image (push) Successful in 51s
2025-08-25 12:36:11 +10:00
a619d78efd fix: Update types to make code more reliable 2025-08-25 12:28:26 +10:00
f090b7b71a feat: Add initial WALLET DNS record support
All checks were successful
Build Docker / Build Image (push) Successful in 2m46s
2025-08-25 11:55:15 +10:00
545a0b9475 fix: Add display for OPEN transactions in tx history
All checks were successful
Build Docker / Build Image (push) Successful in 2m44s
2025-08-18 11:33:47 +10:00
501091eeae Merge pull request 'feat/mempool-bids' (#2) from feat/mempool-bids into main
All checks were successful
Build Docker / Build Image (push) Successful in 2m19s
Reviewed-on: #2
2025-07-17 16:54:38 +10:00
14 changed files with 893 additions and 254 deletions

4
.gitignore vendored
View File

@@ -16,3 +16,7 @@ customPlugins/
cache/
build/
dist/
hsd/
hsd-data/
hsd.lock
hsdconfig.json

View File

@@ -1,7 +1,7 @@
FROM --platform=$BUILDPLATFORM python:3.10-alpine AS builder
FROM --platform=$BUILDPLATFORM python:3.13-alpine AS builder
WORKDIR /app
RUN apk add git openssl curl
COPY requirements.txt /app
RUN --mount=type=cache,target=/root/.cache/pip \
pip3 install -r requirements.txt
@@ -10,9 +10,8 @@ COPY . /app
# Add mount point for data volume
# VOLUME /data
RUN apk add git openssl curl
ENTRYPOINT ["python3"]
CMD ["server.py"]
FROM builder as dev-envs
FROM builder AS dev-envs

Binary file not shown.

View File

@@ -124,9 +124,44 @@ SHOW_EXPIRED: Show expired domains (true/false)
EXCLUDE: Comma separated list of wallets to exclude from the wallet list (default primary)
EXPLORER_TX: URL for exploring transactions (default https://shakeshift.com/transaction/)
HSD_NETWORK: Network to connect to (main, regtest, simnet)
DISABLE_WALLETDNS: Disable Wallet DNS records when sending HNS to domains (true/false)
INTERNAL_HSD: Use internal HSD node (true/false)
```
# Internal HSD
If you set INTERNAL_HSD=true in the .env file the wallet will start and manage its own HSD node. If you want to override the default HSD config create a file called hsdconfig.json in the same directory as main.py and change the values you want to override. For example to disable SPV and use an existing bob wallet sync (on linux) and set the agent to "SuperCoolDev" you could use the following:
```json
{
"spv": false,
"prefix":"~/.config/Bob/hsd_data",
"flags":[
"--agent=SuperCoolDev"
]
}
```
Supported config options are:
```yaml
spv: true/false
prefix: path to hsd data directory
flags: list of additional flags to pass to hsd
version: version of hsd to use (used when installing HSD from source)
chainMigrate: <int> (for users migrating from older versions of HSD)
walletMigrate: <int> (for users migrating from older versions of HSD)
```
## Support the Project
If you find FireWallet useful and would like to support its continued development, please consider making a donation. Your contributions help maintain the project and develop new features.
HNS donations can be sent to: `hs1qh7uzytf2ftwkd9dmjjs7az9qfver5m7dd7x4ej`
Other donation options can be found at [my website](https://nathan.woodburn.au/donate)
Thank you for your support!
## Warnings
- This is a work in progress and is not guaranteed to work

View File

@@ -7,22 +7,25 @@ import re
import domainLookup
import json
import time
import subprocess
import atexit
import signal
import sys
import threading
import sqlite3
from functools import wraps
dotenv.load_dotenv()
HSD_API = os.getenv("HSD_API")
HSD_IP = os.getenv("HSD_IP")
if HSD_IP is None:
HSD_IP = "localhost"
HSD_API = os.getenv("HSD_API","")
HSD_IP = os.getenv("HSD_IP","localhost")
HSD_NETWORK = os.getenv("HSD_NETWORK")
HSD_NETWORK = os.getenv("HSD_NETWORK", "main")
HSD_WALLET_PORT = 12039
HSD_NODE_PORT = 12037
if not HSD_NETWORK:
HSD_NETWORK = "main"
else:
HSD_NETWORK = HSD_NETWORK.lower()
HSD_NETWORK = HSD_NETWORK.lower()
if HSD_NETWORK == "simnet":
HSD_WALLET_PORT = 15039
@@ -34,22 +37,52 @@ elif HSD_NETWORK == "regtest":
HSD_WALLET_PORT = 14039
HSD_NODE_PORT = 14037
HSD_INTERNAL_NODE = os.getenv("INTERNAL_HSD","false").lower() in ["1","true","yes"]
if HSD_INTERNAL_NODE:
if HSD_API == "":
# Use a random API KEY
HSD_API = "firewallet-" + str(int(time.time()))
HSD_IP = "localhost"
SHOW_EXPIRED = os.getenv("SHOW_EXPIRED")
if SHOW_EXPIRED is None:
SHOW_EXPIRED = False
HSD_PROCESS = None
SPV_MODE = None
# Get hsdconfig.json
HSD_CONFIG = {
"version": "v8.0.0",
"chainMigrate": 4,
"walletMigrate": 7,
"minNodeVersion": 20,
"minNpmVersion": 8,
"spv": False,
"flags": [
"--agent=FireWallet"
]
}
TX_CACHE_TTL = 3600
DOMAIN_CACHE_TTL = int(os.getenv("CACHE_TTL",90))
if not os.path.exists('hsdconfig.json'):
with open('hsdconfig.json', 'w') as f:
f.write(json.dumps(HSD_CONFIG, indent=4))
else:
with open('hsdconfig.json') as f:
hsdConfigTMP = json.load(f)
for key in hsdConfigTMP:
HSD_CONFIG[key] = hsdConfigTMP[key]
hsd = api.hsd(HSD_API, HSD_IP, HSD_NODE_PORT)
hsw = api.hsw(HSD_API, HSD_IP, HSD_WALLET_PORT)
cacheTime = 3600
# Verify the connection
response = hsd.getInfo()
EXCLUDE = ["primary"]
if os.getenv("EXCLUDE") is not None:
EXCLUDE = os.getenv("EXCLUDE").split(",")
EXCLUDE = os.getenv("EXCLUDE","primary").split(",")
def hsdConnected():
@@ -62,13 +95,20 @@ def hsdVersion(format=True):
info = hsd.getInfo()
if 'error' in info:
return -1
# Check if SPV mode is enabled
global SPV_MODE
if info.get('chain',{}).get('options',{}).get('spv',False):
SPV_MODE = True
else:
SPV_MODE = False
if format:
return float('.'.join(info['version'].split(".")[:2]))
else:
return info['version']
def check_account(cookie: str):
def check_account(cookie: str | None):
if cookie is None:
return False
@@ -84,7 +124,12 @@ def check_account(cookie: str):
return account
def check_password(cookie: str, password: str):
def check_password(cookie: str|None, password: str|None):
if cookie is None:
return False
if password is None:
password = ""
account = check_account(cookie)
if account == False:
return False
@@ -184,6 +229,124 @@ def selectWallet(account: str):
}
}
def init_domain_db():
"""Initialize the SQLite database for domain cache."""
os.makedirs('cache', exist_ok=True)
db_path = os.path.join('cache', 'domains.db')
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Create the domains table if it doesn't exist
cursor.execute('''
CREATE TABLE IF NOT EXISTS domains (
name TEXT PRIMARY KEY,
info TEXT,
last_updated INTEGER
)
''')
conn.commit()
conn.close()
def getCachedDomains():
"""Get cached domain information from SQLite database."""
init_domain_db() # Ensure DB exists
db_path = os.path.join('cache', 'domains.db')
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row # This allows accessing columns by name
cursor = conn.cursor()
# Get all domains from the database
cursor.execute('SELECT name, info, last_updated FROM domains')
rows = cursor.fetchall()
# Convert to dictionary format
domain_cache = {}
for row in rows:
try:
domain_cache[row['name']] = json.loads(row['info'])
domain_cache[row['name']]['last_updated'] = row['last_updated']
except json.JSONDecodeError:
print(f"Error parsing cached data for domain {row['name']}")
conn.close()
return domain_cache
ACTIVE_DOMAIN_UPDATES = set() # Track domains being updated
DOMAIN_UPDATE_LOCK = threading.Lock() # For thread-safe access to ACTIVE_DOMAIN_UPDATES
def update_domain_cache(domain_names: list):
"""Fetch domain info and update the SQLite cache."""
if not domain_names:
return
# Filter out domains that are already being updated
domains_to_update = []
with DOMAIN_UPDATE_LOCK:
for domain in domain_names:
if domain not in ACTIVE_DOMAIN_UPDATES:
ACTIVE_DOMAIN_UPDATES.add(domain)
domains_to_update.append(domain)
if not domains_to_update:
# All requested domains are already being updated
return
try:
# Initialize database
init_domain_db()
db_path = os.path.join('cache', 'domains.db')
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
for domain_name in domains_to_update:
try:
# Get domain info from node
domain_info = getDomain(domain_name)
if 'error' in domain_info or not domain_info.get('info'):
print(f"Failed to get info for domain {domain_name}: {domain_info.get('error', 'Unknown error')}", flush=True)
continue
# Update or insert into database
now = int(time.time())
serialized_info = json.dumps(domain_info)
cursor.execute(
'INSERT OR REPLACE INTO domains (name, info, last_updated) VALUES (?, ?, ?)',
(domain_name, serialized_info, now)
)
print(f"Updated cache for domain {domain_name}")
except Exception as e:
print(f"Error updating cache for domain {domain_name}: {str(e)}")
finally:
# Always remove from active set, even if there was an error
with DOMAIN_UPDATE_LOCK:
if domain_name in ACTIVE_DOMAIN_UPDATES:
ACTIVE_DOMAIN_UPDATES.remove(domain_name)
# Commit all changes at once
conn.commit()
conn.close()
except Exception as e:
print(f"Error updating domain cache: {str(e)}", flush=True)
# Make sure to clean up the active set on any exception
with DOMAIN_UPDATE_LOCK:
for domain in domains_to_update:
if domain in ACTIVE_DOMAIN_UPDATES:
ACTIVE_DOMAIN_UPDATES.remove(domain)
print("Updated cache for domains")
def getBalance(account: str):
# Get the total balance
info = hsw.getBalance('default', account)
@@ -200,9 +363,66 @@ def getBalance(account: str):
domains = getDomains(account)
domainValue = 0
domains_to_update = [] # Track domains that need cache updates
if isSPV():
# Initialize database if needed
init_domain_db()
# Connect to the database directly for efficient querying
db_path = os.path.join('cache', 'domains.db')
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
now = int(time.time())
cache_cutoff = now - (DOMAIN_CACHE_TTL * 86400) # Cache TTL in days
for domain in domains:
domain_name = domain['name']
# Check if domain is in cache and still fresh
cursor.execute(
'SELECT info, last_updated FROM domains WHERE name = ?',
(domain_name,)
)
row = cursor.fetchone()
# Only add domain for update if:
# 1. Not in cache or stale
# 2. Not currently being updated by another thread
with DOMAIN_UPDATE_LOCK:
if (not row or row['last_updated'] < cache_cutoff) and domain_name not in ACTIVE_DOMAIN_UPDATES:
domains_to_update.append(domain_name)
continue
# Use the cached info
try:
if row: # Make sure we have data
domain_info = json.loads(row['info'])
if domain_info.get('info', {}).get('state', "") == "CLOSED":
domainValue += domain_info.get('info', {}).get('value', 0)
except json.JSONDecodeError:
# Only add for update if not already being updated
with DOMAIN_UPDATE_LOCK:
if domain_name not in ACTIVE_DOMAIN_UPDATES:
domains_to_update.append(domain_name)
conn.close()
else:
for domain in domains:
if domain['state'] == "CLOSED":
domainValue += domain['value']
# Start background thread to update cache for missing domains
if domains_to_update:
thread = threading.Thread(
target=update_domain_cache,
args=(domains_to_update,),
daemon=True
)
thread.start()
total = total - (domainValue/1000000)
locked = locked - (domainValue/1000000)
@@ -267,40 +487,74 @@ def getDomains(account, own=True):
return domains
def init_tx_page_db():
"""Initialize the SQLite database for transaction page cache."""
os.makedirs('cache', exist_ok=True)
db_path = os.path.join('cache', 'tx_pages.db')
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Create the tx_pages table if it doesn't exist
cursor.execute('''
CREATE TABLE IF NOT EXISTS tx_pages (
account TEXT,
page_key TEXT,
txid TEXT,
timestamp INTEGER,
PRIMARY KEY (account, page_key)
)
''')
conn.commit()
conn.close()
def getPageTXCache(account, page, size=100):
page = f"{page}-{size}"
if not os.path.exists(f'cache'):
os.mkdir(f'cache')
"""Get cached transaction ID from SQLite database."""
account = getxPub(account)
page_key = f"{page}-{size}"
if not os.path.exists(f'cache/{account}_page.json'):
with open(f'cache/{account}_page.json', 'w') as f:
f.write('{}')
with open(f'cache/{account}_page.json') as f:
pageCache = json.load(f)
# Initialize database if needed
init_tx_page_db()
if page in pageCache and pageCache[page]['time'] > int(time.time()) - cacheTime:
return pageCache[page]['txid']
db_path = os.path.join('cache', 'tx_pages.db')
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Query for the cached transaction ID
cursor.execute(
'SELECT txid, timestamp FROM tx_pages WHERE account = ? AND page_key = ?',
(account, page_key)
)
row = cursor.fetchone()
conn.close()
if row and row[1] > int(time.time()) - TX_CACHE_TTL:
return row[0] # Return the cached txid
return None
def pushPageTXCache(account, page, txid, size=100):
page = f"{page}-{size}"
if not os.path.exists(f'cache/{account}_page.json'):
with open(f'cache/{account}_page.json', 'w') as f:
f.write('{}')
with open(f'cache/{account}_page.json') as f:
pageCache = json.load(f)
"""Store transaction ID in SQLite database."""
account = getxPub(account)
page_key = f"{page}-{size}"
pageCache[page] = {
'time': int(time.time()),
'txid': txid
}
with open(f'cache/{account}_page.json', 'w') as f:
json.dump(pageCache, f, indent=4)
# Initialize database if needed
init_tx_page_db()
return pageCache[page]['txid']
db_path = os.path.join('cache', 'tx_pages.db')
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Insert or replace the transaction ID
cursor.execute(
'INSERT OR REPLACE INTO tx_pages (account, page_key, txid, timestamp) VALUES (?, ?, ?, ?)',
(account, page_key, txid, int(time.time()))
)
conn.commit()
conn.close()
return txid
def getTXFromPage(account, page, size=100):
if page == 1:
@@ -403,17 +657,34 @@ def check_hip2(domain: str):
return 'Invalid domain'
address = domainLookup.hip2(domain)
if address.startswith("Hip2: "):
return address
if not address.startswith("Hip2: "):
if not check_address(address, False, True):
return 'Hip2: Lookup succeeded but address is invalid'
return address
# Check if DISABLE_WALLETDNS is set
if os.getenv("DISABLE_WALLETDNS","").lower() in ["1","true","yes"]:
return "No HIP2 record found for this domain"
# Try using WALLET TXT record
address = domainLookup.wallet_txt(domain)
if not address.startswith("hs1"):
return "No HIP2 or WALLET record found for this domain"
if not check_address(address, False, True):
return 'WALLET DNS record found but address is invalid'
return address
def send(account, address, amount):
account_name = check_account(account)
password = ":".join(account.split(":")[1:])
if not account_name:
return {
"error": {
"message": "Invalid account"
}
}
response = hsw.rpc_selectWallet(account_name)
if response['error'] is not None:
return {
@@ -447,7 +718,12 @@ def send(account, address, amount):
def isOwnDomain(account, name: str):
# Get domain
domain_info = getDomain(name)
owner = getAddressFromCoin(domain_info['info']['owner']['hash'],domain_info['info']['owner']['index'])
if 'info' not in domain_info or domain_info['info'] is None:
return False
if 'owner' not in domain_info['info']:
return False
owner = getAddressFromCoin(domain_info['info']['owner'].get("hash"),domain_info['info']['owner'].get("index"))
# Select the account
hsw.rpc_selectWallet(account)
account = hsw.rpc_getAccount(owner)
@@ -479,6 +755,16 @@ def isOwnPrevout(account, prevout: dict):
def getDomain(domain: str):
if isSPV():
response = requests.get(f"https://hsd.hns.au/api/v1/name/{domain}").json()
if 'error' in response:
return {
"error": {
"message": response['error']
}
}
return response
# Get the domain
response = hsd.rpc_getNameInfo(domain)
if response['error'] is not None:
@@ -489,11 +775,21 @@ def getDomain(domain: str):
}
return response['result']
def isKnownDomain(domain: str) -> bool:
# Get the domain
response = hsd.rpc_getNameInfo(domain)
if response['error'] is not None:
return False
if response['result'] is None or response['result'].get('info') is None:
return False
return True
def getAddressFromCoin(coinhash: str, coinindex = 0):
# Get the address from the hash
response = requests.get(get_node_api_url(f"coin/{coinhash}/{coinindex}"))
if response.status_code != 200:
print(f"Error getting address from coin: {response.text}")
print(f"Error getting address from coin")
return "No Owner"
data = response.json()
if 'address' not in data:
@@ -519,6 +815,17 @@ def renewDomain(account, domain):
def getDNS(domain: str):
# Get the DNS
if isSPV():
response = requests.get(f"https://hsd.hns.au/api/v1/nameresource/{domain}")
if response.status_code != 200:
return {
"error": f"Error fetching DNS records: {response.status_code}"
}
response = response.json()
return response.get('records', [])
response = hsd.rpc_getNameResource(domain)
if response['error'] is not None:
return {
@@ -706,6 +1013,8 @@ def getPendingRegisters(account):
for bid in bids:
if bid['name'] == domain['name']:
if bid['value'] == domain['highest']:
# Double check the domain is actually in the node
if isKnownDomain(domain['name']):
pending.append(bid)
return pending
@@ -718,9 +1027,13 @@ def getPendingFinalizes(account, password):
pending = []
try:
for output in tx['outputs']:
if output['covenant']['type'] != 10:
if type(output) != dict:
continue
if output['covenant']['action'] != "FINALIZE":
if not 'covenant' in output:
continue
if output['covenant'].get("type") != 10:
continue
if output['covenant'].get('action') != "FINALIZE":
continue
nameHash = output['covenant']['items'][0]
# Try to get the name from hash
@@ -1299,6 +1612,8 @@ def zapTXs(account):
def getxPub(account):
account_name = account
if account.count(":") > 0:
account_name = check_account(account)
if account_name == False:
@@ -1317,8 +1632,6 @@ def getxPub(account):
}
}
return response['accountKey']
return response
except Exception as e:
return {
"error": {
@@ -1414,12 +1727,21 @@ def generateReport(account, format="{name},{expiry},{value},{maxBid}"):
def convertHNS(value: int):
return value/1000000
return value/1000000
SPV_EXTERNAL_ROUTES = [
"name",
"coin",
"tx",
"block"
]
def get_node_api_url(path=''):
"""Construct a URL for the HSD node API."""
base_url = f"http://x:{HSD_API}@{HSD_IP}:{HSD_NODE_PORT}"
if isSPV() and any(path.startswith(route) for route in SPV_EXTERNAL_ROUTES):
# If in SPV mode and the path is one of the external routes, use the external API
base_url = f"https://hsd.hns.au/api/v1"
if path:
# Ensure path starts with a slash if it's not empty
if not path.startswith('/'):
@@ -1436,3 +1758,215 @@ def get_wallet_api_url(path=''):
path = f'/{path}'
return f"{base_url}{path}"
return base_url
def isSPV() -> bool:
global SPV_MODE
if SPV_MODE is None:
info = hsd.getInfo()
if 'error' in info:
return False
# Check if SPV mode is enabled
if info.get('chain',{}).get('options',{}).get('spv',False):
SPV_MODE = True
else:
SPV_MODE = False
return SPV_MODE
# region HSD Internal Node
def checkPreRequisites() -> dict[str, bool]:
prerequisites = {
"node": False,
"npm": False,
"git": False,
"hsd": False
}
try:
# Check if node is installed and get version
nodeSubprocess = subprocess.run(["node", "-v"], capture_output=True, text=True)
if nodeSubprocess.returncode == 0:
major_version = int(nodeSubprocess.stdout.strip().lstrip('v').split('.')[0])
if major_version >= HSD_CONFIG.get("minNodeVersion", 20):
prerequisites["node"] = True
except FileNotFoundError:
pass
try:
# Check if npm is installed
npmSubprocess = subprocess.run(["npm", "-v"], capture_output=True, text=True)
if npmSubprocess.returncode == 0:
major_version = int(npmSubprocess.stdout.strip().split('.')[0])
if major_version >= HSD_CONFIG.get("minNPMVersion", 8):
prerequisites["npm"] = True
except FileNotFoundError:
pass
try:
# Check if git is installed
gitSubprocess = subprocess.run(["git", "-v"], capture_output=True, text=True)
if gitSubprocess.returncode == 0:
prerequisites["git"] = True
except FileNotFoundError:
pass
# Check if hsd is installed
if os.path.exists("./hsd/bin/hsd"):
prerequisites["hsd"] = True
return prerequisites
def hsdInit():
if not HSD_INTERNAL_NODE:
return
prerequisites = checkPreRequisites()
PREREQ_MESSAGES = {
"node": "Install Node.js from https://nodejs.org/en/download (Version >= {minNodeVersion})",
"npm": "Install npm (version >= {minNPMVersion}) - usually comes with Node.js",
"git": "Install Git from https://git-scm.com/downloads"}
# Check if all prerequisites are met (except hsd)
if not all(prerequisites[key] for key in prerequisites if key != "hsd"):
print("HSD Internal Node prerequisites not met:")
for key, value in prerequisites.items():
if not value:
print(f" - {key} is missing or does not meet the version requirement.")
exit(1)
return
# Check if hsd is installed
if not prerequisites["hsd"]:
print("HSD not found, installing...")
# If hsd folder exists, remove it
if os.path.exists("hsd"):
os.rmdir("hsd")
# Clone hsd repo
gitClone = subprocess.run(["git", "clone", "--depth", "1", "--branch", HSD_CONFIG.get("version", "latest"), "https://github.com/handshake-org/hsd.git", "hsd"], capture_output=True, text=True)
if gitClone.returncode != 0:
print("Failed to clone hsd repository:")
print(gitClone.stderr)
exit(1)
print("Cloned hsd repository.")
# Install hsd dependencies
print("Installing hsd dependencies...")
npmInstall = subprocess.run(["npm", "install"], cwd="hsd", capture_output=True, text=True)
if npmInstall.returncode != 0:
print("Failed to install hsd dependencies:")
print(npmInstall.stderr)
exit(1)
print("Installed hsd dependencies.")
def hsdStart():
global HSD_PROCESS
global SPV_MODE
if not HSD_INTERNAL_NODE:
return
# Check if hsd was started in the last 30 seconds
if os.path.exists("hsd.lock"):
lock_time = os.path.getmtime("hsd.lock")
if time.time() - lock_time < 30:
print("HSD was started recently, skipping start.")
return
else:
os.remove("hsd.lock")
print("Starting HSD...")
# Create a lock file
with open("hsd.lock", "w") as f:
f.write(str(time.time()))
# Config lookups with defaults
chain_migrate = HSD_CONFIG.get("chainMigrate", False)
wallet_migrate = HSD_CONFIG.get("walletMigrate", False)
spv = HSD_CONFIG.get("spv", False)
prefix = HSD_CONFIG.get("prefix", os.path.join(os.getcwd(), "hsd-data"))
# Base command
cmd = [
"node",
"./hsd/bin/hsd",
f"--network={HSD_NETWORK}",
f"--prefix={prefix}",
f"--api-key={HSD_API}",
"--http-host=127.0.0.1",
"--log-console=false"
]
# Conditionally add migration flags
if chain_migrate:
cmd.append(f"--chain-migrate={chain_migrate}")
if wallet_migrate:
cmd.append(f"--wallet-migrate={wallet_migrate}")
SPV_MODE = spv
if spv:
cmd.append("--spv")
# Add flags
if len(HSD_CONFIG.get("flags",[])) > 0:
for flag in HSD_CONFIG.get("flags",[]):
cmd.append(flag)
# Launch process
HSD_PROCESS = subprocess.Popen(
cmd,
cwd=os.getcwd(),
text=True
)
print(f"HSD started with PID {HSD_PROCESS.pid}")
atexit.register(hsdStop)
# Handle Ctrl+C
try:
signal.signal(signal.SIGINT, lambda s, f: (hsdStop(), sys.exit(0)))
signal.signal(signal.SIGTERM, lambda s, f: (hsdStop(), sys.exit(0)))
except:
pass
def hsdStop():
global HSD_PROCESS
if HSD_PROCESS is None:
return
print("Stopping HSD...")
# Send SIGINT (like Ctrl+C)
HSD_PROCESS.send_signal(signal.SIGINT)
try:
HSD_PROCESS.wait(timeout=10) # wait for graceful exit
print("HSD shut down cleanly.")
except subprocess.TimeoutExpired:
print("HSD did not exit yet, is it alright???")
# Clean up lock file
if os.path.exists("hsd.lock"):
os.remove("hsd.lock")
HSD_PROCESS = None
def hsdRestart():
hsdStop()
time.sleep(2)
hsdStart()
hsdInit()
hsdStart()
# endregion

View File

@@ -6,10 +6,14 @@ import subprocess
import binascii
import datetime
import dns.asyncresolver
import dns.message
import dns.query
import dns.rdatatype
import httpx
from requests_doh import DNSOverHTTPSSession, add_dns_provider
import requests
import urllib3
from cryptography.x509.oid import ExtensionOID
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # Disable insecure request warnings (since we are manually verifying the certificate)
@@ -56,7 +60,7 @@ def hip2(domain: str):
domains = []
for ext in cert_obj.extensions:
if ext.oid == x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME:
if ext.oid == ExtensionOID.SUBJECT_ALTERNATIVE_NAME:
san_list = ext.value.get_values_for_type(x509.DNSName)
domains.extend(san_list)
@@ -120,13 +124,39 @@ def hip2(domain: str):
print(f"Hip2: Lookup failed with error: {e}",flush=True)
return "Hip2: Lookup failed."
def wallet_txt(domain: str, doh_url="https://hnsdoh.com/dns-query"):
with httpx.Client() as client:
q = dns.message.make_query(domain, dns.rdatatype.from_text("TYPE262"))
r = dns.query.https(q, doh_url, session=client)
if not r.answer:
return "No wallet address found for this domain"
wallet_record = "No WALLET record found"
for ans in r.answer:
raw = ans[0].to_wire() # type: ignore
try:
data = raw[1:].decode("utf-8", errors="ignore")
except UnicodeDecodeError:
return f"Unknown WALLET record format: {raw.hex()}"
if data.startswith("HNS:"):
wallet_record = data[4:]
break
elif data.startswith("HNS "):
wallet_record = data[4:]
break
elif data.startswith('"HNS" '):
wallet_record = data[6:].strip('"')
break
return wallet_record
def resolve_with_doh(query_name, doh_url="https://hnsdoh.com/dns-query"):
with httpx.Client() as client:
q = dns.message.make_query(query_name, dns.rdatatype.A)
r = dns.query.https(q, doh_url, session=client)
ip = r.answer[0][0].address
ip = r.answer[0][0].address # type: ignore
return ip
def resolve_TLSA_with_doh(query_name, doh_url="https://hnsdoh.com/dns-query"):

View File

@@ -3,3 +3,5 @@ HSD_IP=localhost
THEME=black
SHOW_EXPIRED=false
EXPLORER_TX=https://shakeshift.com/transaction/
DISABLE_WALLETDNS=false
INTERNAL_HSD=false

View File

@@ -1,45 +0,0 @@
What have you built previously?
- [HNSHosting](https://hnshosting.au)
- [ShakeCities](https://shakecities.com)
- [FireWallet](https://firewallet.au)
- [Git Profile](https://github.com/nathanwoodburn)
Project summary
A Handshake wallet web ui. This will be a HSD wallet web ui that will allow users to manage their Handshake domains via a web interface. This will allow users to easily manage their domains without having to use the command line or bob. One benefit of this is that it will allow users to easily manage their domains from their mobile devices that don't have access to any HNS wallet. This could be done in a secure way by only allowing connections on local network devices (in addition to requiring the wallet credentials).
Features:
- Login with HSD wallet name + password (by default don't show a list of wallets to login to as this could be a security risk)
- View account information in a dashboard
- Available balance
- Total balance
- Pending Transactions
- List of domains
- List of transactions
- Manage domains
- Transfer domains
- Finalize domains
- Edit domains
- Revoke domains (with a warning and requiring the account password)
- Manage wallet
- Send HNS
- Receive HNS
- Auctions
- View bids on domain
- Open auction
- Bid on auction
- Reveal bid
- Redeem bid
- Register domain
Completion requirements:
- Basic functionality including
- View info
- Send/Receive HNS
- Manage domains
After the initial version is completed I will be looking to add more features including the above mentioned features.
The initial version will be completed in 2-3 weeks with a fully fledged version released later as the features are developed and tested.
You can contact me at handshake @ nathan.woodburn.au

210
main.py
View File

@@ -57,16 +57,11 @@ def blocks_to_time(blocks: int) -> str:
return f"{days} days"
return f"{days} days {hours} hrs"
@app.route('/')
def index():
# Check if the user is logged in
if request.cookies.get("account") is None:
return redirect("/login")
account = account_module.check_account(request.cookies.get("account"))
if not account:
return redirect("/logout")
@@ -83,6 +78,8 @@ def index():
return render_template("index.html", account=account, plugins=plugins)
info = gitinfo.get_git_info()
if info is None:
return render_template("index.html", account=account, plugins=plugins)
branch = info['refs']
commit = info['commit']
if commit != latestVersion(branch):
@@ -114,7 +111,7 @@ def transactions():
return redirect("/logout")
# Get the page parameter
page = request.args.get('page')
page = request.args.get('page', 1)
try:
page = int(page)
except:
@@ -135,6 +132,8 @@ def send_page():
return redirect("/login")
account = account_module.check_account(request.cookies.get("account"))
if not account:
return redirect("/logout")
max = account_module.getBalance(account)['available']
# Subtract approx fee
max = max - fees
@@ -170,28 +169,28 @@ def send():
amount = request.form.get("amount")
if address is None or amount is None:
return redirect("/send?message=Invalid address or amount&address=" + address + "&amount=" + amount)
return redirect(f"/send?message=Invalid address or amount&address={address}&amount={amount}")
address_check = account_module.check_address(address.strip(),True,True)
if not address_check:
return redirect("/send?message=Invalid address&address=" + address + "&amount=" + amount)
return redirect(f"/send?message=Invalid address&address={address}&amount={amount}")
address = address_check
# Check if the amount is valid
if re.match(r"^\d+(\.\d+)?$", amount) is None:
return redirect("/send?message=Invalid amount&address=" + address + "&amount=" + amount)
return redirect(f"/send?message=Invalid amount&address={address}&amount={amount}")
# Check if the amount is valid
amount = float(amount)
if amount <= 0:
return redirect("/send?message=Invalid amount&address=" + address + "&amount=" + str(amount))
return redirect(f"/send?message=Invalid amount&address={address}&amount={amount}")
if amount > account_module.getBalance(account)['available'] - fees:
return redirect("/send?message=Not enough funds to transfer&address=" + address + "&amount=" + str(amount))
return redirect(f"/send?message=Not enough funds to transfer&address={address}&amount={amount}")
toAddress = address
if request.form.get('address') != address:
toAddress = request.form.get('address') + "<br>" + address
toAddress = f"{request.form.get('address')}<br>{address}"
action = f"Send HNS to {request.form.get('address')}"
content = f"Are you sure you want to send {amount} HNS to {toAddress}<br><br>"
@@ -202,7 +201,6 @@ def send():
return render_template("confirm.html", account=account_module.check_account(request.cookies.get("account")),
action=action,
content=content,cancel=cancel,confirm=confirm)
@@ -211,20 +209,20 @@ def send():
def sendConfirmed():
address = request.args.get("address")
amount = float(request.args.get("amount"))
amount = float(request.args.get("amount","0"))
response = account_module.send(request.cookies.get("account"),address,amount)
if 'error' in response and response['error'] != None:
# If error is a dict get the message
if isinstance(response['error'], dict):
if 'message' in response['error']:
return redirect("/send?message=" + response['error']['message'] + "&address=" + address + "&amount=" + str(amount))
return redirect(f"/send?message={response['error']['message']}&address={address}&amount={amount}")
else:
return redirect("/send?message=" + str(response['error']) + "&address=" + address + "&amount=" + str(amount))
return redirect(f"/send?message={response['error']}&address={address}&amount={amount}")
# If error is a string
return redirect("/send?message=" + response['error'] + "&address=" + address + "&amount=" + str(amount))
return redirect(f"/send?message={response['error']}&address={address}&amount={amount}")
return redirect("/success?tx=" + response['tx'])
return redirect(f"/success?tx={response['tx']}")
@@ -363,6 +361,9 @@ def revealAllBids():
return redirect("/logout")
response = account_module.revealAll(request.cookies.get("account"))
if not response:
return redirect("/auctions?message=Failed to reveal bids")
if 'error' in response:
if response['error'] != None:
if response['error']['message'] == "Nothing to do.":
@@ -383,6 +384,9 @@ def redeemAllBids():
return redirect("/logout")
response = account_module.redeemAll(request.cookies.get("account"))
if not response:
return redirect("/auctions?message=Failed to redeem bids")
if 'error' in response:
if response['error'] != None:
if response['error']['message'] == "Nothing to do.":
@@ -402,13 +406,16 @@ def registerAllDomains():
return redirect("/logout")
response = account_module.registerAll(request.cookies.get("account"))
if not response:
return redirect("/auctions?message=Failed to register domains")
if 'error' in response:
if response['error'] != None:
if response['error']['message'] == "Nothing to do.":
return redirect("/auctions?message=No domains to register")
return redirect("/auctions?message=" + response['error']['message'])
return redirect("/success?tx=" + response['hash'])
return redirect(f"/success?tx={response['hash']}")
@app.route('/all/finalize')
def finalizeAllBids():
@@ -427,7 +434,7 @@ def finalizeAllBids():
return redirect("/dashboard?message=No domains to finalize")
return redirect("/dashboard?message=" + response['error']['message'])
return redirect("/success?tx=" + response['hash'])
return redirect(f"/success?tx={response['hash']}")
#endregion
@app.route('/search')
@@ -441,6 +448,8 @@ def search():
return redirect("/logout")
search_term = request.args.get("q")
if search_term is None:
return redirect("/")
search_term = search_term.lower().strip()
# Replace spaces with hyphens
@@ -457,7 +466,7 @@ def search():
# Execute domain plugins
searchFunctions = plugins_module.getSearchFunctions()
for function in searchFunctions:
functionOutput = plugins_module.runPluginFunction(function["plugin"],function["function"],{"domain":search_term},account_module.check_account(request.cookies.get("account")))
functionOutput = plugins_module.runPluginFunction(function["plugin"],function["function"],{"domain":search_term},account)
plugins += render.plugin_output(functionOutput,plugins_module.getPluginFunctionReturns(function["plugin"],function["function"]))
plugins += "</div>"
@@ -475,6 +484,7 @@ def search():
state = domain['info']['state']
stats = domain['info']['stats']
next = ""
if state == 'CLOSED':
if domain['info']['registered']:
state = 'REGISTERED'
@@ -571,7 +581,7 @@ def manage(domain: str):
# Execute domain plugins
domainFunctions = plugins_module.getDomainFunctions()
for function in domainFunctions:
functionOutput = plugins_module.runPluginFunction(function["plugin"],function["function"],{"domain":domain},account_module.check_account(request.cookies.get("account")))
functionOutput = plugins_module.runPluginFunction(function["plugin"],function["function"],{"domain":domain},account)
plugins += render.plugin_output(functionOutput,plugins_module.getPluginFunctionReturns(function["plugin"],function["function"]))
plugins += "</div>"
@@ -676,7 +686,7 @@ def revokeConfirm(domain: str):
print(response)
return redirect("/manage/" + domain + "?error=" + response['error']['message'])
return redirect("/success?tx=" + response['hash'])
return redirect(f"/success?tx={response['hash']}")
@app.route('/manage/<domain>/renew')
def renew(domain: str):
@@ -690,7 +700,7 @@ def renew(domain: str):
domain = domain.lower()
response = account_module.renewDomain(request.cookies.get("account"),domain)
return redirect("/success?tx=" + response['hash'])
return redirect(f"/success?tx={response['hash']}")
@app.route('/manage/<domain>/edit')
def editPage(domain: str):
@@ -717,7 +727,10 @@ def editPage(domain: str):
else:
dns = account_module.getDNS(domain)
if dns and isinstance(dns, str):
dns = json.loads(dns)
else:
dns = []
# Check if new records have been added
dnsType = request.args.get("type")
@@ -733,14 +746,14 @@ def editPage(domain: str):
return redirect("/manage/" + domain + "/edit?dns=" + urllib.parse.quote(str(raw_dns)) + "&error=Invalid DS record")
try:
ds[0] = int(ds[0])
ds[1] = int(ds[1])
ds[2] = int(ds[2])
key_tag = int(ds[0])
algorithm = int(ds[1])
digest_type = int(ds[2])
except:
raw_dns = str(dns).replace("'",'"')
return redirect("/manage/" + domain + "/edit?dns=" + urllib.parse.quote(str(raw_dns)) + "&error=Invalid DS record")
finally:
dns.append({"type": dnsType, "keyTag": ds[0], "algorithm": ds[1], "digestType": ds[2], "digest": ds[3]})
dns.append({"type": dnsType, "keyTag": key_tag, "algorithm": algorithm, "digestType": digest_type, "digest": ds[3]})
dns = json.dumps(dns).replace("'",'"')
return redirect("/manage/" + domain + "/edit?dns=" + urllib.parse.quote(dns))
@@ -770,13 +783,15 @@ def editSave(domain: str):
domain = domain.lower()
dns = request.args.get("dns")
if dns is None:
return redirect(f"/manage/{domain}/edit?error=No DNS records provided")
raw_dns = dns
dns = urllib.parse.unquote(dns)
response = account_module.setDNS(request.cookies.get("account"),domain,dns)
if 'error' in response:
print(response)
return redirect("/manage/" + domain + "/edit?dns="+raw_dns+"&error=" + str(response['error']))
return redirect("/success?tx=" + response['hash'])
return redirect(f"/manage/{domain}/edit?dns={raw_dns}&error={response['error']}")
return redirect(f"/success?tx={response['hash']}")
@app.route('/manage/<domain>/transfer')
def transfer(domain):
@@ -801,7 +816,7 @@ def transfer(domain):
toAddress = address
if request.form.get('address') != address:
toAddress = request.args.get('address') + "<br>" + address
toAddress = f"{request.args.get('address')}<br>{address}"
action = f"Send {domain}/ to {request.form.get('address')}"
content = f"Are you sure you want to send {domain}/ to {toAddress}<br><br>"
@@ -811,9 +826,7 @@ def transfer(domain):
confirm = f"/manage/{domain}/transfer/confirm?address={address}"
return render_template("confirm.html", account=account_module.check_account(request.cookies.get("account")),
action=action,
return render_template("confirm.html", account=account,action=action,
content=content,cancel=cancel,confirm=confirm)
@app.route('/manage/<domain>/sign')
@@ -836,7 +849,7 @@ def signMessage(domain):
signedMessage = account_module.signMessage(request.cookies.get("account"),domain,message)
if signedMessage["error"] != None:
return redirect("/manage/" + domain + "?error=" + signedMessage["error"])
content += "Signature:<br><code>" + signedMessage["result"] + "</code><br><br>"
content += f"Signature:<br><code>{signedMessage["result"]}</code><br><br>"
data = {
"domain": domain,
@@ -854,7 +867,6 @@ def signMessage(domain):
return render_template("message.html", account=account,
title="Sign Message",content=content)
@@ -873,7 +885,7 @@ def transferConfirm(domain):
if 'error' in response:
return redirect("/manage/" + domain + "?error=" + response['error'])
return redirect("/success?tx=" + response['hash'])
return redirect(f"/success?tx={response['hash']}")
@app.route('/auction/<domain>')
@@ -917,20 +929,9 @@ def auction(domain):
state = domainInfo['info']['state']
next_action = ''
next = ""
# bids = account_module.getBids(account,search_term)
bids = []
# if bids == []:
# bids = "No bids found"
# next_action = f'<a href="/auction/{domain}/scan">Rescan Auction</a>'
# else:
# reveals = account_module.getReveals(account,search_term)
# for reveal in reveals:
# # Get TX
# revealInfo = account_module.getRevealTX(reveal)
# reveal['bid'] = revealInfo
# bids = render.bids(bids,reveals)
stats = domainInfo['info']['stats'] if 'stats' in domainInfo['info'] else {}
if state == 'CLOSED':
if not domainInfo['info']['registered']:
@@ -1010,8 +1011,8 @@ def bid(domain):
return redirect("/logout")
domain = domain.lower()
bid = request.args.get("bid")
blind = request.args.get("blind")
bid = request.args.get("bid","")
blind = request.args.get("blind","")
if bid == "":
bid = 0
@@ -1056,8 +1057,8 @@ def bid_confirm(domain):
return redirect("/logout")
domain = domain.lower()
bid = request.args.get("bid")
blind = request.args.get("blind")
bid = request.args.get("bid","")
blind = request.args.get("blind","")
if bid == "":
bid = 0
@@ -1076,7 +1077,7 @@ def bid_confirm(domain):
if 'error' in response:
return redirect("/auction/" + domain + "?error=" + response['error']['message'])
return redirect("/success?tx=" + response['hash'])
return redirect(f"/success?tx={response['hash']}")
@app.route('/auction/<domain>/open')
def open_auction(domain):
@@ -1095,7 +1096,7 @@ def open_auction(domain):
if response['error'] != None:
return redirect("/auction/" + domain + "?error=" + response['error']['message'])
return redirect("/success?tx=" + response['hash'])
return redirect(f"/success?tx={response['hash']}")
@app.route('/auction/<domain>/reveal')
def reveal_auction(domain):
@@ -1110,8 +1111,8 @@ def reveal_auction(domain):
domain = domain.lower()
response = account_module.revealAuction(request.cookies.get("account"),domain)
if 'error' in response:
return redirect("/auction/" + domain + "?message=" + response['error']['message'])
return redirect("/success?tx=" + response['hash'])
return redirect(f"/auction/{domain}?message={response['error']}")
return redirect(f"/success?tx={response['hash']}")
@app.route('/auction/<domain>/register')
def registerdomain(domain):
@@ -1126,7 +1127,7 @@ def registerdomain(domain):
response = account_module.register(request.cookies.get("account"),domain)
if 'error' in response:
return redirect("/auction/" + domain + "?message=" + response['error']['message'])
return redirect("/success?tx=" + response['hash'])
return redirect(f"/success?tx={response['hash']}")
#endregion
#region Settings
@@ -1147,12 +1148,21 @@ def settings():
if success == None:
success = ""
if not os.path.exists(".git"):
return render_template("settings.html", account=account,
hsd_version=account_module.hsdVersion(False),
error=error,success=success,version="Error")
error=error,success=success,version="Error",
internal=account_module.HSD_INTERNAL_NODE,
spv=account_module.isSPV())
info = gitinfo.get_git_info()
if not info:
return render_template("settings.html", account=account,
hsd_version=account_module.hsdVersion(False),
error=error,success=success,version="Error",
internal=account_module.HSD_INTERNAL_NODE,
spv=account_module.isSPV())
branch = info['refs']
if branch != "main":
branch = f"({branch})"
@@ -1166,7 +1176,8 @@ def settings():
version += ' (New version available)'
return render_template("settings.html", account=account,
hsd_version=account_module.hsdVersion(False),
error=error,success=success,version=version)
error=error,success=success,version=version,internal=account_module.HSD_INTERNAL_NODE,
spv=account_module.isSPV())
@app.route('/settings/<action>')
def settings_action(action):
@@ -1183,29 +1194,36 @@ def settings_action(action):
if 'error' in resp:
return redirect("/settings?error=" + str(resp['error']))
return redirect("/settings?success=Rescan started")
elif action == "resend":
if action == "resend":
resp = account_module.resendTXs()
if 'error' in resp:
return redirect("/settings?error=" + str(resp['error']))
return redirect("/settings?success=Resent transactions")
elif action == "zap":
if action == "zap":
resp = account_module.zapTXs(request.cookies.get("account"))
if 'error' in resp:
if type(resp) == dict and 'error' in resp:
return redirect("/settings?error=" + str(resp['error']))
return redirect("/settings?success=Zapped transactions")
elif action == "xpub":
if action == "xpub":
xpub = account_module.getxPub(request.cookies.get("account"))
content = "<br><br>"
content += "<textarea style='display: none;' id='data' rows='4' cols='50'>"+xpub+"</textarea>"
content += f"<textarea style='display: none;' id='data' rows='4' cols='50'>{xpub}</textarea>"
content += "<script>function copyToClipboard() {var copyText = document.getElementById('data');copyText.style.display = 'block';copyText.select();copyText.setSelectionRange(0, 99999);document.execCommand('copy');copyText.style.display = 'none';var copyButton = document.getElementById('copyButton');copyButton.innerHTML='Copied';}</script>"
content += "<button id='copyButton' onclick='copyToClipboard()' class='btn btn-secondary'>Copy to clipboard</button>"
return render_template("message.html", account=account,
title="xPub Key",
content="<code>"+xpub+"</code>" + content)
content=f"<code>{xpub}</code>{content}")
if action == "restart":
resp = account_module.hsdRestart()
return render_template("message.html", account=account,
title="Restarting",
content="The node is restarting. This may take a minute or two. You can close this window.")
return redirect("/settings?error=Invalid action")
@@ -1215,6 +1233,9 @@ def upload_image():
return redirect("/login?message=Not logged in")
account = request.cookies.get("account")
account = account_module.check_account(account)
if not account:
return redirect("/logout")
if not os.path.exists('user_data/images'):
os.mkdir('user_data/images')
@@ -1224,11 +1245,12 @@ def upload_image():
file = request.files['image']
if file.filename == '':
return redirect("/settings?error=No file selected")
if file:
filepath = os.path.join(f'user_data/images/{account.split(":")[0]}.{file.filename.split(".")[-1]}')
if file and file.filename:
filepath = os.path.join(f'user_data/images/{account}.{file.filename.split(".")[-1]}')
file.save(filepath)
return redirect("/settings?success=File uploaded successfully")
return redirect("/settings?error=An error occurred")
def latestVersion(branch):
result = requests.get(f"https://git.woodburn.au/api/v1/repos/nathanwoodburn/firewalletbrowser/branches")
@@ -1250,6 +1272,9 @@ def login():
wallets = account_module.listWallets()
wallets = render.wallets(wallets)
# If there are no wallets redirect to either register or import
if len(wallets) == 0:
return redirect("/welcome")
if 'message' in request.args:
return render_template("login.html",
@@ -1265,6 +1290,12 @@ def login_post():
account = request.form.get("account")
password = request.form.get("password")
if account == None or password == None:
wallets = account_module.listWallets()
wallets = render.wallets(wallets)
return render_template("login.html",
error="Invalid account or password",wallets=wallets)
# Check if the account is valid
if account.count(":") > 0:
wallets = account_module.listWallets()
@@ -1280,8 +1311,6 @@ def login_post():
wallets = render.wallets(wallets)
return render_template("login.html",
error="Invalid account or password",wallets=wallets)
# Set the cookie
response = make_response(redirect("/"))
response.set_cookie("account", account)
@@ -1300,6 +1329,11 @@ def register():
password = request.form.get("password")
repeatPassword = request.form.get("password_repeat")
if account == None or password == None or repeatPassword == None:
return render_template("register.html",
error="Invalid account or password",
name=account,password=password,password_repeat=repeatPassword)
# Check if the passwords match
if password != repeatPassword:
return render_template("register.html",
@@ -1329,10 +1363,8 @@ def register():
# Set the cookie
response = make_response(render_template("message.html",
title="Account Created",
content="Your account has been created. Here is your seed phrase. Please write it down and keep it safe as it will not be shown again<br><br>" + response['seed']))
response = make_response(render_template("message.html",title="Account Created",
content=f"Your account has been created. Here is your seed phrase. Please write it down and keep it safe as it will not be shown again<br><br>{response['seed']}"))
response.set_cookie("account", account+":"+password)
return response
@@ -1344,6 +1376,12 @@ def import_wallet():
repeatPassword = request.form.get("password_repeat")
seed = request.form.get("seed")
if account == None or password == None or repeatPassword == None or seed == None:
return render_template("import-wallet.html",
error="Invalid account, password or seed",
name=account,password=password,password_repeat=repeatPassword,
seed=seed)
# Check if the passwords match
if password != repeatPassword:
return render_template("import-wallet.html",
@@ -1560,6 +1598,7 @@ def api_hsd(function):
stats = domainInfo['info']['stats'] if 'stats' in domainInfo['info'] else {}
state = domainInfo['info']['state']
next_action = ""
next = ""
if state == 'CLOSED':
if not domainInfo['info']['registered']:
if account_module.isOwnDomain(account,domain):
@@ -1638,7 +1677,10 @@ def api_wallet(function):
return jsonify({"error": "Not logged in"})
account = account_module.check_account(request.cookies.get("account"))
password = request.cookies.get("account").split(":")[1]
if not account:
return jsonify({"error": "Invalid account"})
password = request.cookies.get("account","").split(":")[1]
if not account:
return jsonify({"error": "Invalid account"})
@@ -1672,7 +1714,7 @@ def api_wallet(function):
if function == "domains":
domains = account_module.getDomains(account)
if 'error' in domains:
if type(domains) == dict and 'error' in domains:
return jsonify({"result": [], "error": domains['error']})
# Add nameRender to each domain
@@ -1683,7 +1725,7 @@ def api_wallet(function):
if function == "transactions":
# Get the page parameter
page = request.args.get('page')
page = request.args.get('page', 1)
try:
page = int(page)
except:
@@ -1759,7 +1801,7 @@ def api_wallet_mobile(function):
return jsonify({"error": "Not logged in"})
account = account_module.check_account(request.cookies.get("account"))
password = request.cookies.get("account").split(":")[1]
password = request.cookies.get("account","").split(":")[1]
if not account:
return jsonify({"error": "Invalid account"})
@@ -1821,7 +1863,11 @@ def renderDomain(name: str) -> str:
#region Assets and default pages
@app.route('/qr/<data>')
def qr(data):
return send_file(qrcode(data, mode="raw"), mimetype="image/png")
output = qrcode(data, mode="raw")
if output is None:
return jsonify({"error": "Invalid data"}), 400
return send_file(output, mimetype="image/png")
# Theme
@app.route('/assets/css/styles.min.css')

View File

@@ -148,11 +148,14 @@ def getPluginData(pluginStr: str):
def getPluginFunctions(plugin: str):
plugin = import_module(plugin.replace("/","."))
return plugin.functions
imported_plugin = import_module(plugin.replace("/","."))
return imported_plugin.functions
def runPluginFunction(plugin: str, function: str, params: dict, authentication: str):
def runPluginFunction(plugin: str, function: str, params: dict, authentication: (str|None)):
if not authentication:
return {"error": "Authentication required"}
plugin_module = import_module(plugin.replace("/","."))
if function not in plugin_module.functions:
return {"error": "Function not found"}
@@ -189,13 +192,13 @@ def runPluginFunction(plugin: str, function: str, params: dict, authentication:
def getPluginFunctionInputs(plugin: str, function: str):
plugin = import_module(plugin.replace("/","."))
return plugin.functions[function]["params"]
imported_plugin = import_module(plugin.replace("/","."))
return imported_plugin.functions[function]["params"]
def getPluginFunctionReturns(plugin: str, function: str):
plugin = import_module(plugin.replace("/","."))
return plugin.functions[function]["returns"]
imported_plugin = import_module(plugin.replace("/","."))
return imported_plugin.functions[function]["returns"]
def getDomainFunctions():

View File

@@ -6,32 +6,7 @@ from domainLookup import punycode_to_emoji
import os
from handywrapper import api
import threading
HSD_API = os.getenv("HSD_API")
HSD_IP = os.getenv("HSD_IP")
if HSD_IP is None:
HSD_IP = "localhost"
HSD_NETWORK = os.getenv("HSD_NETWORK")
HSD_WALLET_PORT = 12039
HSD_NODE_PORT = 12037
if not HSD_NETWORK:
HSD_NETWORK = "main"
else:
HSD_NETWORK = HSD_NETWORK.lower()
if HSD_NETWORK == "simnet":
HSD_WALLET_PORT = 15039
HSD_NODE_PORT = 15037
elif HSD_NETWORK == "testnet":
HSD_WALLET_PORT = 13039
HSD_NODE_PORT = 13037
elif HSD_NETWORK == "regtest":
HSD_WALLET_PORT = 14039
HSD_NODE_PORT = 14037
hsd = api.hsd(HSD_API, HSD_IP, HSD_NODE_PORT)
import requests
# Get Explorer URL
TX_EXPLORER_URL = os.getenv("EXPLORER_TX")
@@ -96,6 +71,7 @@ actionMap = {
"UPDATE": "Updated ",
"REGISTER": "Registered ",
"RENEW": "Renewed ",
"OPEN": "Opened ",
"BID": "Bid on ",
"REVEAL": "Revealed bid for ",
"REDEEM": "Redeemed bid for ",
@@ -107,6 +83,7 @@ actionMapPlural = {
"UPDATE": "Updated multiple domains' records",
"REGISTER": "Registered multiple domains",
"RENEW": "Renewed multiple domains",
"OPEN": "Opened multiple domains",
"BID": "Bid on multiple domains",
"REVEAL": "Revealed multiple bids",
"REDEEM": "Redeemed multiple bids",
@@ -558,9 +535,10 @@ def renderDomainAsync(namehash: str) -> None:
if namehash in cache:
return
# Fetch the name outside the lock (network call) using hsd.hns.au
# name = account.hsd.rpc_getNameByHash(namehash)
name = requests.get(f"https://hsd.hns.au/api/v1/namehash/{namehash}").json()
# Fetch the name outside the lock (network call)
name = hsd.rpc_getNameByHash(namehash)
if name["error"] is None:
name = name["result"]
rendered = renderDomain(name)
@@ -574,7 +552,7 @@ def renderDomainAsync(namehash: str) -> None:
with open(NAMEHASH_CACHE, 'w') as f:
json.dump(cache, f)
return rendered
return
else:
print(f"Error fetching name for hash {namehash}: {name['error']}", flush=True)

View File

@@ -17,8 +17,8 @@ def gunicornServer():
def load_config(self):
for key, value in self.options.items():
if key in self.cfg.settings and value is not None:
self.cfg.set(key.lower(), value)
if key in self.cfg.settings and value is not None: # type: ignore
self.cfg.set(key.lower(), value) # type: ignore
def load(self):
return self.application

View File

@@ -68,9 +68,8 @@
<h3 class="mb-1" style="text-align: center;color: rgb(0,255,0);">{{success}}</h3>
<div class="card">
<div class="card-body">
<h4 class="card-title">Node Settings</h4><small>HSD Version: v{{hsd_version}}</small>
<h6 class="text-muted mb-2 card-subtitle">Settings that affect all wallets</h6>
<ul class="list-group">
<h4 class="card-title">Node Settings</h4><small>HSD Version: v{{hsd_version}}&nbsp; Type: {% if internal %} Internal {% else %} Remote {% endif %} ({% if spv %}SPV{% else %}Full Node{% endif %})</small>
<h6 class="text-muted mb-2 card-subtitle">Settings that affect all wallets</h6><ul class="list-group">
<li class="list-group-item">
<div><a class="btn btn-primary stick-right" role="button" href="/settings/rescan">Rescan</a>
<h3>Rescan</h3><span>Rescan the blockchain for transactions</span>
@@ -78,7 +77,7 @@
</li>
<li class="list-group-item">
<div><a class="btn btn-primary stick-right" role="button" href="/settings/resend">Resend</a>
<h3>Resend&nbsp;unconfirmed transactions</h3><span>Resend any transactions that haven't been mined yet.</span>
<h3>Resend unconfirmed transactions</h3><span>Resend any transactions that haven&#39;t been mined yet.</span>
</div>
</li>
<li class="list-group-item">
@@ -86,7 +85,14 @@
<h3>Delete unconfirmed transactions</h3><span>This will only remove pending tx from the wallet older than 20 minutes (~ 2 blocks)</span>
</div>
</li>
</ul>
{% if internal %}
<li class="list-group-item">
<div><a class="btn btn-primary stick-right" role="button" href="/settings/restart">Restart Node</a>
<h3>Restart Internal Node</h3><span>This will attempt to restart the HSD node</span>
</div>
</li>
{% endif %}
</ul>
</div>
</div>
</div>

47
templates/welcome.html Normal file
View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html data-bs-theme="dark" lang="en-au" style="height: 100%;">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Welcome to FireWallet</title>
<link rel="icon" type="image/png" sizes="900x768" href="/assets/img/favicon.png">
<link rel="icon" type="image/png" sizes="900x768" href="/assets/img/favicon.png">
<link rel="icon" type="image/png" sizes="900x768" href="/assets/img/favicon.png">
<link rel="icon" type="image/png" sizes="900x768" href="/assets/img/favicon.png">
<link rel="icon" type="image/png" sizes="900x768" href="/assets/img/favicon.png">
<link rel="stylesheet" href="/assets/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Nunito:200,200i,300,300i,400,400i,600,600i,700,700i,800,800i,900,900i&amp;display=swap">
<link rel="stylesheet" href="/assets/css/styles.min.css">
</head>
<body class="d-flex align-items-center bg-gradient-primary" style="height: 100%;">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-9 col-lg-12 col-xl-10">
<h1 class="text-center" style="color: var(--bs-danger);background: var(--bs-primary);">{{error}}</h1>
<div class="card shadow-lg my-5 o-hidden border-0" style="padding-top: 50px;padding-bottom: 50px;">
<div class="card-body p-0">
<div class="row">
<div class="col-lg-6 d-none d-lg-flex">
<div class="flex-grow-1 bg-login-image" style="background: url(&quot;/assets/img/favicon.png&quot;) center / contain no-repeat;"></div>
</div>
<div class="col-lg-6">
<div class="text-center p-5">
<div class="text-center">
<h4 class="mb-4">Welcome to FireWallet!</h4>
</div>
<div class="btn-group-vertical btn-group-lg gap-1" role="group"><a class="btn btn-primary" role="button" href="/register">Create a new wallet</a><a class="btn btn-primary" role="button" href="/import-wallet">Import an existing wallet</a></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/assets/bootstrap/js/bootstrap.min.js"></script>
<script src="/assets/js/script.min.js"></script>
</body>
</html>