9 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
9 changed files with 422 additions and 132 deletions

1
.gitignore vendored
View File

@@ -19,3 +19,4 @@ 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

@@ -129,6 +129,39 @@ 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

@@ -11,6 +11,9 @@ import subprocess
import atexit
import signal
import sys
import threading
import sqlite3
from functools import wraps
dotenv.load_dotenv()
@@ -46,26 +49,36 @@ if SHOW_EXPIRED is None:
SHOW_EXPIRED = False
HSD_PROCESS = None
SPV_MODE = None
# Get hsdconfig.json
HSD_CONFIG = {}
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'):
# Pull from the latest git
response = requests.get("https://git.woodburn.au/nathanwoodburn/firewalletbrowser/raw/branch/main/hsdconfig.json")
if response.status_code == 200:
with open('hsdconfig.json', 'w') as f:
f.write(response.text)
HSD_CONFIG = response.json()
f.write(json.dumps(HSD_CONFIG, indent=4))
else:
with open('hsdconfig.json') as f:
HSD_CONFIG = json.load(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()
@@ -82,6 +95,13 @@ 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:
@@ -209,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)
@@ -225,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)
@@ -292,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:
@@ -489,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)
@@ -521,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:
@@ -531,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:
@@ -561,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 {
@@ -748,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
@@ -1345,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:
@@ -1363,8 +1632,6 @@ def getxPub(account):
}
}
return response['accountKey']
return response
except Exception as e:
return {
"error": {
@@ -1461,10 +1728,20 @@ def generateReport(account, format="{name},{expiry},{value},{maxBid}"):
def convertHNS(value: int):
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('/'):
@@ -1482,7 +1759,19 @@ def get_wallet_api_url(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
@@ -1496,24 +1785,33 @@ def checkPreRequisites() -> dict[str, bool]:
"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"):
@@ -1571,6 +1869,7 @@ def hsdInit():
def hsdStart():
global HSD_PROCESS
global SPV_MODE
if not HSD_INTERNAL_NODE:
return
@@ -1592,15 +1891,16 @@ def hsdStart():
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={os.path.join(os.getcwd(), 'hsd-data')}",
f"--prefix={prefix}",
f"--api-key={HSD_API}",
"--agent=FireWallet",
"--http-host=127.0.0.1",
"--log-console=false"
]
@@ -1610,9 +1910,14 @@ def hsdStart():
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(
@@ -1662,7 +1967,6 @@ def hsdRestart():
hsdStart()
checkPreRequisites()
hsdInit()
hsdStart()
# endregion

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

View File

@@ -1,8 +0,0 @@
{
"version": "v8.0.0",
"chainMigrate":4,
"walletMigrate":7,
"minNodeVersion":20,
"minNpmVersion":8,
"spv": true
}

12
main.py
View File

@@ -1148,15 +1148,20 @@ 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",internal=account_module.HSD_INTERNAL_NODE)
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)
error=error,success=success,version="Error",
internal=account_module.HSD_INTERNAL_NODE,
spv=account_module.isSPV())
branch = info['refs']
if branch != "main":
@@ -1171,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,internal=account_module.HSD_INTERNAL_NODE)
error=error,success=success,version=version,internal=account_module.HSD_INTERNAL_NODE,
spv=account_module.isSPV())
@app.route('/settings/<action>')
def settings_action(action):

View File

@@ -68,7 +68,7 @@
<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>
<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>