16 Commits

Author SHA1 Message Date
6911e3663c feat: Add mempool bids and dynamic loading
All checks were successful
Build Docker / Build Image (push) Successful in 50s
2025-07-16 18:44:18 +10:00
eda690544d feat: Add js to pull bids and auction state 2025-07-16 18:32:32 +10:00
e67c178ea7 feat: Add initial mempool bids function
All checks were successful
Build Docker / Build Image (push) Successful in 55s
2025-07-16 17:41:32 +10:00
631d558377 tmp: Add debugging for bids
All checks were successful
Build Docker / Build Image (push) Successful in 1m1s
2025-07-16 16:53:14 +10:00
1d5ed059b3 fix: Allow rescan while not in bidding
All checks were successful
Build Docker / Build Image (push) Successful in 3m35s
2025-07-16 16:30:08 +10:00
747ac575fa fix: Auto reset cache if incorrect format
All checks were successful
Build Docker / Build Image (push) Successful in 1m53s
2025-07-14 16:28:50 +10:00
e574933302 feat: Add link on names in tx history
All checks were successful
Build Docker / Build Image (push) Successful in 1m11s
2025-07-12 21:47:14 +10:00
c0f0dc5010 fix: Open tx for bid in new tab 2025-07-12 21:35:04 +10:00
060132bfec Merge pull request 'Update Auctions page to include more info and be easier to read' (#1) from dev into main
All checks were successful
Build Docker / Build Image (push) Successful in 1m10s
Reviewed-on: #1
2025-07-12 15:10:56 +10:00
7bc1fad280 feat: Add bid sorting to auction page and add tx links
All checks were successful
Build Docker / Build Image (push) Successful in 1m16s
2025-07-12 14:13:40 +10:00
63e0b1c2f2 fix: Add new better method of validating domain owner
All checks were successful
Build Docker / Build Image (push) Successful in 1m16s
2025-07-12 13:50:30 +10:00
2fab7b3bc0 feat: Add info on when bidding closes
All checks were successful
Build Docker / Build Image (push) Successful in 1m14s
2025-07-12 13:28:21 +10:00
3fa57cc617 feat: Add time estimates to block times
All checks were successful
Build Docker / Build Image (push) Successful in 1m10s
2025-07-12 12:44:31 +10:00
4c3a738e43 feat: Cleanup urls in account module
All checks were successful
Build Docker / Build Image (push) Successful in 1m12s
2025-07-12 12:25:52 +10:00
988d03b48c fix: Update unknown owner message
All checks were successful
Build Docker / Build Image (push) Successful in 1m10s
Remove logging for DNS rendering
2025-07-12 12:03:36 +10:00
21043fc124 fix: Reveal from auction page crash
All checks were successful
Build Docker / Build Image (push) Successful in 2m41s
Reveal from the auction page had a missing function call
2025-07-12 11:56:22 +10:00
6 changed files with 460 additions and 200 deletions

Binary file not shown.

View File

@@ -109,8 +109,7 @@ def createWallet(account: str, password: str):
} }
# Create the account # Create the account
# Python wrapper doesn't support this yet # Python wrapper doesn't support this yet
response = requests.put( response = requests.put(get_wallet_api_url(f"wallet/{account}"))
f"http://x:{HSD_API}@{HSD_IP}:{HSD_WALLET_PORT}/wallet/{account}")
if response.status_code != 200: if response.status_code != 200:
return { return {
"error": { "error": {
@@ -123,7 +122,7 @@ def createWallet(account: str, password: str):
seed = seed['mnemonic']['phrase'] seed = seed['mnemonic']['phrase']
# Encrypt the wallet (python wrapper doesn't support this yet) # Encrypt the wallet (python wrapper doesn't support this yet)
response = requests.post(f"http://x:{HSD_API}@{HSD_IP}:{HSD_WALLET_PORT}/wallet/{account}/passphrase", response = requests.post(get_wallet_api_url(f"/wallet/{account}/passphrase"),
json={"passphrase": password}) json={"passphrase": password})
return { return {
@@ -147,8 +146,7 @@ def importWallet(account: str, password: str, seed: str):
"mnemonic": seed, "mnemonic": seed,
} }
response = requests.put( response = requests.put(get_wallet_api_url(f"/wallet/{account}"), json=data)
f"http://x:{HSD_API}@{HSD_IP}:{HSD_WALLET_PORT}/wallet/{account}", json=data)
if response.status_code != 200: if response.status_code != 200:
return { return {
"error": { "error": {
@@ -186,7 +184,6 @@ def selectWallet(account: str):
} }
} }
def getBalance(account: str): def getBalance(account: str):
# Get the total balance # Get the total balance
info = hsw.getBalance('default', account) info = hsw.getBalance('default', account)
@@ -251,11 +248,9 @@ def getPendingTX(account: str):
def getDomains(account, own=True): def getDomains(account, own=True):
if own: if own:
response = requests.get( response = requests.get(get_wallet_api_url(f"/wallet/{account}/name?own=true"))
f"http://x:{HSD_API}@{HSD_IP}:{HSD_WALLET_PORT}/wallet/{account}/name?own=true")
else: else:
response = requests.get( response = requests.get(get_wallet_api_url(f"/wallet/{account}/name"))
f"http://x:{HSD_API}@{HSD_IP}:{HSD_WALLET_PORT}/wallet/{account}/name")
info = response.json() info = response.json()
if SHOW_EXPIRED: if SHOW_EXPIRED:
@@ -339,11 +334,9 @@ def getTransactions(account, page=1, limit=100):
lastTX = getTXFromPage(account, page-1, limit) lastTX = getTXFromPage(account, page-1, limit)
if lastTX: if lastTX:
response = requests.get( response = requests.get(get_wallet_api_url(f"/wallet/{account}/tx/history?reverse=true&limit={limit}&after={lastTX}"))
f'http://x:{HSD_API}@{HSD_IP}:{HSD_WALLET_PORT}/wallet/{account}/tx/history?reverse=true&limit={limit}&after={lastTX}')
elif page == 1: elif page == 1:
response = requests.get( response = requests.get(get_wallet_api_url(f"/wallet/{account}/tx/history?reverse=true&limit={limit}"))
f'http://x:{HSD_API}@{HSD_IP}:{HSD_WALLET_PORT}/wallet/{account}/tx/history?reverse=true&limit={limit}')
else: else:
return [] return []
@@ -383,7 +376,7 @@ def check_address(address: str, allow_name: bool = True, return_address: bool =
return check_hip2(address[1:]) return check_hip2(address[1:])
# Check if the address is a valid HNS address # Check if the address is a valid HNS address
response = requests.post(f"http://x:{HSD_API}@{HSD_IP}:{HSD_NODE_PORT}", json={ response = requests.post(get_node_api_url(), json={
"method": "validateaddress", "method": "validateaddress",
"params": [address] "params": [address]
}).json() }).json()
@@ -431,8 +424,6 @@ def send(account, address, amount):
response = hsw.rpc_walletPassphrase(password, 10) response = hsw.rpc_walletPassphrase(password, 10)
# Unlock the account # Unlock the account
# response = requests.post(f"http://x:{APIKEY}@{ip}:{HSD_WALLET_PORT}/wallet/{account_name}/unlock",
# json={"passphrase": password,"timeout": 10})
if response['error'] is not None: if response['error'] is not None:
if response['error']['message'] != "Wallet is not encrypted.": if response['error']['message'] != "Wallet is not encrypted.":
return { return {
@@ -454,12 +445,38 @@ def send(account, address, amount):
def isOwnDomain(account, name: str): def isOwnDomain(account, name: str):
domains = getDomains(account) # Get domain
for domain in domains: domain_info = getDomain(name)
if domain['name'] == name: owner = getAddressFromCoin(domain_info['info']['owner']['hash'],domain_info['info']['owner']['index'])
return True # Select the account
hsw.rpc_selectWallet(account)
account = hsw.rpc_getAccount(owner)
if 'error' in account and account['error'] is not None:
return False
if 'result' not in account:
return False
if account['result'] == 'default':
return True
return False return False
def isOwnPrevout(account, prevout: dict):
if 'hash' not in prevout or 'index' not in prevout:
return False
# Get the address from the prevout
address = getAddressFromCoin(prevout['hash'], prevout['index'])
# Select the account
hsw.rpc_selectWallet(account)
account = hsw.rpc_getAccount(address)
if 'error' in account and account['error'] is not None:
return False
if 'result' not in account:
return False
if account['result'] == 'default':
return True
return False
def getDomain(domain: str): def getDomain(domain: str):
# Get the domain # Get the domain
@@ -474,20 +491,14 @@ def getDomain(domain: str):
def getAddressFromCoin(coinhash: str, coinindex = 0): def getAddressFromCoin(coinhash: str, coinindex = 0):
# Get the address from the hash # Get the address from the hash
response = requests.get(f"http://x:{HSD_API}@{HSD_IP}:{HSD_NODE_PORT}/coin/{coinhash}/{coinindex}") response = requests.get(get_node_api_url(f"coin/{coinhash}/{coinindex}"))
if response.status_code != 200: if response.status_code != 200:
return { print(f"Error getting address from coin: {response.text}")
"error": { return "No Owner"
"message": "Error getting address from coin"
}
}
data = response.json() data = response.json()
if 'address' not in data: if 'address' not in data:
return { print(json.dumps(data, indent=4))
"error": { return "No Owner"
"message": "Error getting address from coin"
}
}
return data['address'] return data['address']
@@ -617,24 +628,11 @@ def getWalletStatus():
return "Error wallet ahead of node" return "Error wallet ahead of node"
# Add a simple cache for bid data
_bid_cache = {}
_bid_cache_time = {}
_cache_duration = 60 # Cache duration in seconds
def getBids(account, domain="NONE"): def getBids(account, domain="NONE"):
cache_key = f"{account}:{domain}"
current_time = time.time()
# Return cached data if available and fresh
if cache_key in _bid_cache and current_time - _bid_cache_time.get(cache_key, 0) < _cache_duration:
return _bid_cache[cache_key]
if domain == "NONE": if domain == "NONE":
response = hsw.getWalletBids(account) response = hsw.getWalletBids(account)
else: else:
response = hsw.getWalletBidsByName(domain, account) response = hsw.getWalletBidsByName(domain, account)
# Add backup for bids with no value # Add backup for bids with no value
bids = [] bids = []
for bid in response: for bid in response:
@@ -645,91 +643,30 @@ def getBids(account, domain="NONE"):
if 'height' not in bid: if 'height' not in bid:
bid['height'] = 0 bid['height'] = 0
bids.append(bid) bids.append(bid)
# Cache the results
_bid_cache[cache_key] = bids
_bid_cache_time[cache_key] = current_time
return bids return bids
def getPossibleOutbids(account):
# Get all bids
bids = getBids(account)
if 'error' in bids:
return []
# Get current height
current_height = getBlockHeight()
# Sort out bids older than 720 blocks
bids = [bid for bid in bids if (current_height - bid['height']) <= 720]
possible_outbids = []
processed_domains = set() # Track domains we've already processed
# Pre-fetch domain info for all domains in a single batch
domains_to_check = {bid['name'] for bid in bids}
domain_info_map = {}
for domain in domains_to_check:
domain_info = getDomain(domain)
if ('info' in domain_info and 'state' in domain_info['info'] and
domain_info['info']['state'] == "BIDDING"):
domain_info_map[domain] = domain_info
for bid in bids:
domain = bid['name']
# Skip if we've already processed this domain or it's not in bidding state
if domain in processed_domains or domain not in domain_info_map:
continue
processed_domains.add(domain)
# Get all bids for this domain in one call
domain_bids = getBids(account, domain)
# Find the highest bid we've made
current_highest_bid = bid['value']
for own_bid in domain_bids:
if own_bid["own"]:
current_highest_bid = max(current_highest_bid, own_bid['value'])
# Check if any unrevealed bids could outbid us
for domain_bid in domain_bids:
if domain_bid["own"] or domain_bid['value'] != -1000000:
continue # Skip our own bids or revealed bids
if current_highest_bid < domain_bid["lockup"]:
possible_outbids.append(domain)
break
return possible_outbids
def getReveals(account, domain): def getReveals(account, domain):
return hsw.getWalletRevealsByName(domain, account) return hsw.getWalletRevealsByName(domain, account)
def getPendingReveals(account): def getPendingReveals(account):
bids = getBids(account) bids = getBids(account)
# Only get domains in REVEAL state to reduce API calls domains = getDomains(account, False)
domains = [d for d in getDomains(account, False) if d['state'] == "REVEAL"]
pending = [] pending = []
for domain in domains:
# Process domains in REVEAL state if domain['state'] == "REVEAL":
domain_names = {domain['name']: domain for domain in domains} reveals = getReveals(account, domain['name'])
for bid in bids:
for bid in bids: if bid['name'] == domain['name']:
if bid['name'] in domain_names: state_found = False
reveals = getReveals(account, bid['name']) for reveal in reveals:
if reveal['own'] == True:
# Check if this bid has been revealed if bid['value'] == reveal['value']:
bid_revealed = any( state_found = True
reveal['own'] == True and bid['value'] == reveal['value']
for reveal in reveals if not state_found:
) pending.append(bid)
if not bid_revealed:
pending.append(bid)
return pending return pending
@@ -857,7 +794,7 @@ def revealAll(account):
} }
} }
return requests.post(f"http://x:{HSD_API}@{HSD_IP}:{HSD_WALLET_PORT}", json={"method": "sendbatch", "params": [[["REVEAL"]]]}).json() return requests.post(get_wallet_api_url(), json={"method": "sendbatch", "params": [[["REVEAL"]]]}).json()
except Exception as e: except Exception as e:
return { return {
"error": { "error": {
@@ -891,7 +828,7 @@ def redeemAll(account):
} }
} }
return requests.post(f"http://x:{HSD_API}@{HSD_IP}:{HSD_WALLET_PORT}", json={"method": "sendbatch", "params": [[["REDEEM"]]]}).json() return requests.post(get_wallet_api_url(), json={"method": "sendbatch", "params": [[["REDEEM"]]]}).json()
except Exception as e: except Exception as e:
return { return {
"error": { "error": {
@@ -947,10 +884,12 @@ def rescan_auction(account, domain):
return { return {
"error": "Invalid domain" "error": "Invalid domain"
} }
if 'bidPeriodStart' not in response['result']['info']['stats']: if 'height' not in response['result']['info']:
return { return {
"error": "Not in auction" "error": "Can't find start"
} }
height = response['result']['info']['height']-1 height = response['result']['info']['height']-1
response = hsw.rpc_importName(domain, height) response = hsw.rpc_importName(domain, height)
return response return response
@@ -1163,7 +1102,7 @@ def sendBatch(account, batch):
"message": response['error']['message'] "message": response['error']['message']
} }
} }
response = requests.post(f"http://x:{HSD_API}@{HSD_IP}:{HSD_WALLET_PORT}", json={ response = requests.post(get_wallet_api_url(), json={
"method": "sendbatch", "method": "sendbatch",
"params": [batch] "params": [batch]
}).json() }).json()
@@ -1212,7 +1151,7 @@ def createBatch(account, batch):
"message": response['error']['message'] "message": response['error']['message']
} }
} }
response = requests.post(f"http://x:{HSD_API}@{HSD_IP}:{HSD_WALLET_PORT}", json={ response = requests.post(get_wallet_api_url(), json={
"method": "createbatch", "method": "createbatch",
"params": [batch] "params": [batch]
}).json() }).json()
@@ -1233,6 +1172,80 @@ def createBatch(account, batch):
} }
} }
# region Mempool
def getMempoolTxs():
# hsd-cli rpc getrawmempool
response = hsd.rpc_getRawMemPool()
if 'error' in response and response['error'] is not None:
return []
return response['result'] if 'result' in response else []
def getMempoolBids():
mempoolTxs = getMempoolTxs()
bids = {}
for txid in mempoolTxs:
tx = hsd.getTxByHash(txid)
if 'error' in tx and tx['error'] is not None:
print(f"Error getting tx {txid}: {tx['error']}")
continue
if 'outputs' not in tx:
print(f"Error getting outputs for tx {txid}")
continue
for output in tx['outputs']:
if output['covenant']['action'] not in ["BID", "REVEAL"]:
continue
if output['covenant']['action'] == "REVEAL":
# Try to find bid tx from inputs
namehash = output['covenant']['items'][0]
for txInput in tx['inputs']:
if txInput['coin']['covenant']['action'] != "BID":
continue
if txInput['coin']['covenant']['items'][0] != namehash:
continue
name = txInput['coin']['covenant']['items'][2]
# Convert name from hex to ascii
name = bytes.fromhex(name).decode('ascii')
bid = {
'txid': txid,
'lockup': txInput['coin']['value'],
'revealed': True,
'height': -1,
'value': output['value'],
'sort_value': txInput['coin']['value'],
'owner': "Unknown"
}
if name not in bids:
bids[name] = []
bids[name].append(bid)
continue
name = output['covenant']['items'][2]
# Convert name from hex to ascii
name = bytes.fromhex(name).decode('ascii')
if name not in bids:
bids[name] = []
bid = {
'txid': txid,
'value': -1000000, # Default value if not found
'lockup': output['value'],
'revealed': False,
'height': -1,
'sort_value': output['value'],
'owner': "Unknown"
}
bids[name].append(bid)
return bids
# endregion
# region settingsAPIs # region settingsAPIs
def rescan(): def rescan():
@@ -1272,7 +1285,7 @@ def zapTXs(account):
} }
try: try:
response = requests.post(f"http://x:{HSD_API}@{HSD_IP}:{HSD_WALLET_PORT}/wallet/{account_name}/zap", response = requests.post(get_wallet_api_url(f"/wallet/{account_name}/zap"),
json={"age": age, json={"age": age,
"account": "default" "account": "default"
}) })
@@ -1401,3 +1414,25 @@ def generateReport(account, format="{name},{expiry},{value},{maxBid}"):
def convertHNS(value: int): def convertHNS(value: int):
return value/1000000 return value/1000000
return value/1000000
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 path:
# Ensure path starts with a slash if it's not empty
if not path.startswith('/'):
path = f'/{path}'
return f"{base_url}{path}"
return base_url
def get_wallet_api_url(path=''):
"""Construct a URL for the HSD wallet API."""
base_url = f"http://x:{HSD_API}@{HSD_IP}:{HSD_WALLET_PORT}"
if path:
# Ensure path starts with a slash if it's not empty
if not path.startswith('/'):
path = f'/{path}'
return f"{base_url}{path}"
return base_url

190
main.py
View File

@@ -32,6 +32,35 @@ revokeCheck = random.randint(100000,999999)
THEME = os.getenv("THEME") THEME = os.getenv("THEME")
def blocks_to_time(blocks: int) -> str:
"""
Convert blocks to time in a human-readable format.
Blocks are mined approximately every 10 minutes.
"""
if blocks < 0:
return "Invalid time"
if blocks < 6:
return f"{blocks * 10} mins"
elif blocks < 144:
hours = blocks // 6
minutes = (blocks % 6) * 10
if minutes == 0:
return f"{hours} hrs"
return f"{hours} hrs {minutes} mins"
else:
days = blocks // 144
hours = (blocks % 144) // 6
if hours == 0:
return f"{days} days"
return f"{days} days {hours} hrs"
@app.route('/') @app.route('/')
def index(): def index():
# Check if the user is logged in # Check if the user is logged in
@@ -306,15 +335,7 @@ def auctions():
sort_domain = direction sort_domain = direction
sort_domain_next = reverseDirection(direction) sort_domain_next = reverseDirection(direction)
# Check if outbids set to true bidsHtml = render.bidDomains(bids,domains,sortbyDomain)
outbids = request.args.get("outbids")
if outbids is not None and outbids.lower() == "true":
# Get outbid domains
outbids = account_module.getPossibleOutbids(account)
else:
outbids = []
bidsHtml = render.bidDomains(bids,domains,sortbyDomain,outbids)
plugins = "" plugins = ""
message = '' message = ''
if 'message' in request.args: if 'message' in request.args:
@@ -453,10 +474,11 @@ def search():
state="AVAILABLE", next="Available Now",plugins=plugins) state="AVAILABLE", next="Available Now",plugins=plugins)
state = domain['info']['state'] state = domain['info']['state']
stats = domain['info']['stats']
if state == 'CLOSED': if state == 'CLOSED':
if domain['info']['registered']: if domain['info']['registered']:
state = 'REGISTERED' state = 'REGISTERED'
expires = domain['info']['stats']['daysUntilExpire'] expires = stats['daysUntilExpire']
next = f"Expires in ~{expires} days" next = f"Expires in ~{expires} days"
else: else:
state = 'AVAILABLE' state = 'AVAILABLE'
@@ -464,11 +486,11 @@ def search():
elif state == "REVOKED": elif state == "REVOKED":
next = "Available Now" next = "Available Now"
elif state == 'OPENING': elif state == 'OPENING':
next = "Bidding opens in ~" + str(domain['info']['stats']['blocksUntilBidding']) + " blocks" next = f"Bidding opens in {str(stats['blocksUntilBidding'])} blocks (~{blocks_to_time(stats['blocksUntilBidding'])})"
elif state == 'BIDDING': elif state == 'BIDDING':
next = "Reveal in ~" + str(domain['info']['stats']['blocksUntilReveal']) + " blocks" next = f"Reveal in {str(stats['blocksUntilReveal'])} blocks (~{blocks_to_time(stats['blocksUntilReveal'])})"
elif state == 'REVEAL': elif state == 'REVEAL':
next = "Reveal ends in ~" + str(domain['info']['stats']['blocksUntilClose']) + " blocks" next = f"Reveal ends in {str(stats['blocksUntilClose'])} blocks (~{blocks_to_time(stats['blocksUntilClose'])})"
@@ -509,11 +531,8 @@ def manage(domain: str):
return redirect("/logout") return redirect("/logout")
domain = domain.lower() domain = domain.lower()
own_domains = account_module.getDomains(account) if not account_module.isOwnDomain(account, domain):
own_domains = [x['name'] for x in own_domains]
own_domains = [x.lower() for x in own_domains]
if domain not in own_domains:
return redirect("/search?q=" + domain) return redirect("/search?q=" + domain)
domain_info = account_module.getDomain(domain) domain_info = account_module.getDomain(domain)
@@ -522,7 +541,10 @@ def manage(domain: str):
rendered=renderDomain(domain), rendered=renderDomain(domain),
domain=domain, error=domain_info['error']) domain=domain, error=domain_info['error'])
expiry = domain_info['info']['stats']['daysUntilExpire'] if domain_info['info'] is not None and 'stats' in domain_info['info'] and 'daysUntilExpire' in domain_info['info']['stats']:
expiry = domain_info['info']['stats']['daysUntilExpire']
else:
expiry = "Unknown"
dns = account_module.getDNS(domain) dns = account_module.getDNS(domain)
raw_dns = str(dns).replace("'",'"') raw_dns = str(dns).replace("'",'"')
dns = render.dns(dns) dns = render.dns(dns)
@@ -896,19 +918,20 @@ def auction(domain):
state = domainInfo['info']['state'] state = domainInfo['info']['state']
next_action = '' next_action = ''
bids = account_module.getBids(account,search_term) # bids = account_module.getBids(account,search_term)
if bids == []: bids = []
bids = "No bids found" # if bids == []:
next_action = f'<a href="/auction/{domain}/scan">Rescan Auction</a>' # bids = "No bids found"
else: # next_action = f'<a href="/auction/{domain}/scan">Rescan Auction</a>'
reveals = account_module.getReveals(account,search_term) # else:
for reveal in reveals: # reveals = account_module.getReveals(account,search_term)
# Get TX # for reveal in reveals:
revealInfo = account_module.getRevealTX(reveal) # # Get TX
reveal['bid'] = revealInfo # revealInfo = account_module.getRevealTX(reveal)
bids = render.bids(bids,reveals) # reveal['bid'] = revealInfo
# bids = render.bids(bids,reveals)
stats = domainInfo['info']['stats'] if 'stats' in domainInfo['info'] else {}
if state == 'CLOSED': if state == 'CLOSED':
if not domainInfo['info']['registered']: if not domainInfo['info']['registered']:
if account_module.isOwnDomain(account,domain): if account_module.isOwnDomain(account,domain):
@@ -927,20 +950,27 @@ def auction(domain):
expires = domainInfo['info']['stats']['daysUntilExpire'] expires = domainInfo['info']['stats']['daysUntilExpire']
next = f"Expires in ~{expires} days" next = f"Expires in ~{expires} days"
own_domains = account_module.getDomains(account) if account_module.isOwnDomain(account,domain):
own_domains = [x['name'] for x in own_domains]
own_domains = [x.lower() for x in own_domains]
if search_term in own_domains:
next_action = f'<a href="/manage/{domain}">Manage</a>' next_action = f'<a href="/manage/{domain}">Manage</a>'
elif state == "REVOKED": elif state == "REVOKED":
next = "Available Now" next = "Available Now"
next_action = f'<a href="/auction/{domain}/open">Open Auction</a>' next_action = f'<a href="/auction/{domain}/open">Open Auction</a>'
elif state == 'OPENING': elif state == 'OPENING':
next = "Bidding opens in ~" + str(domainInfo['info']['stats']['blocksUntilBidding']) + " blocks" next = f"Bidding opens in {str(stats['blocksUntilBidding'])} blocks (~{blocks_to_time(stats['blocksUntilBidding'])})"
elif state == 'BIDDING': elif state == 'BIDDING':
next = "Reveal in ~" + str(domainInfo['info']['stats']['blocksUntilReveal']) + " blocks" next = f"Reveal in {stats['blocksUntilReveal']} blocks (~{blocks_to_time(stats['blocksUntilReveal'])})"
if stats['blocksUntilReveal'] == 1:
next += "<br>Bidding no longer possible"
elif stats['blocksUntilReveal'] == 2:
next += "<br>LAST CHANCE TO BID"
elif stats['blocksUntilReveal'] == 3:
next += f"<br>Next block is last chance to bid"
elif stats['blocksUntilReveal'] < 6:
next += f"<br>Last chance to bid in {stats['blocksUntilReveal']-2} blocks"
elif state == 'REVEAL': elif state == 'REVEAL':
next = "Reveal ends in ~" + str(domainInfo['info']['stats']['blocksUntilClose']) + " blocks" next = f"Reveal ends in {str(stats['blocksUntilClose'])} blocks (~{blocks_to_time(stats['blocksUntilClose'])})"
next_action = f'<a href="/auction/{domain}/reveal">Reveal All</a>' next_action = f'<a href="/auction/{domain}/reveal">Reveal All</a>'
message = '' message = ''
@@ -1078,7 +1108,7 @@ def reveal_auction(domain):
return redirect("/logout") return redirect("/logout")
domain = domain.lower() domain = domain.lower()
response = account_module(request.cookies.get("account"),domain) response = account_module.revealAuction(request.cookies.get("account"),domain)
if 'error' in response: if 'error' in response:
return redirect("/auction/" + domain + "?message=" + response['error']['message']) return redirect("/auction/" + domain + "?message=" + response['error']['message'])
return redirect("/success?tx=" + response['hash']) return redirect("/success?tx=" + response['hash'])
@@ -1515,7 +1545,66 @@ def api_hsd(function):
return jsonify({"result": account_module.hsdVersion(False)}) return jsonify({"result": account_module.hsdVersion(False)})
if function == "height": if function == "height":
return jsonify({"result": account_module.getBlockHeight()}) return jsonify({"result": account_module.getBlockHeight()})
if function == "mempool":
return jsonify({"result": account_module.getMempoolTxs()})
if function == "mempoolBids":
return jsonify({"result": account_module.getMempoolBids()})
if function == "nextAuctionState":
# Get the domain from the query parameters
domain = request.args.get('domain')
if not domain:
return jsonify({"error": "No domain specified"}), 400
domainInfo = account_module.getDomain(domain)
if 'error' in domainInfo and domainInfo['error'] != None:
return jsonify({"error": domainInfo['error']}), 400
stats = domainInfo['info']['stats'] if 'stats' in domainInfo['info'] else {}
state = domainInfo['info']['state']
next_action = ""
if state == 'CLOSED':
if not domainInfo['info']['registered']:
if account_module.isOwnDomain(account,domain):
print("Waiting to be registered")
state = 'PENDING REGISTER'
next = "Pending Register"
next_action = f'<a href="/auction/{domain}/register">Register Domain</a>'
else:
print("Not registered")
state = 'AVAILABLE'
next = "Available Now"
next_action = f'<a href="/auction/{domain}/open">Open Auction</a>'
else:
state = 'REGISTERED'
expires = domainInfo['info']['stats']['daysUntilExpire']
next = f"Expires in ~{expires} days"
elif state == "REVOKED":
next = "Available Now"
next_action = f'<a href="/auction/{domain}/open">Open Auction</a>'
elif state == 'OPENING':
next = f"Bidding opens in {str(stats['blocksUntilBidding'])} blocks (~{blocks_to_time(stats['blocksUntilBidding'])})"
elif state == 'BIDDING':
next = f"Reveal in {stats['blocksUntilReveal']} blocks (~{blocks_to_time(stats['blocksUntilReveal'])})"
if stats['blocksUntilReveal'] == 1:
next += "<br>Bidding no longer possible"
elif stats['blocksUntilReveal'] == 2:
next += "<br>LAST CHANCE TO BID"
elif stats['blocksUntilReveal'] == 3:
next += f"<br>Next block is last chance to bid"
elif stats['blocksUntilReveal'] < 6:
next += f"<br>Last chance to bid in {stats['blocksUntilReveal']-2} blocks"
elif state == 'REVEAL':
next = f"Reveal ends in {str(stats['blocksUntilClose'])} blocks (~{blocks_to_time(stats['blocksUntilClose'])})"
next_action = f'<a href="/auction/{domain}/reveal">Reveal All</a>'
return jsonify({
"state": state,
"next": next,
"next_action": next_action
})
return jsonify({"error": "Invalid function", "result": "Invalid function"}), 400 return jsonify({"error": "Invalid function", "result": "Invalid function"}), 400
@@ -1634,6 +1723,21 @@ def api_wallet(function):
"page": page "page": page
}) })
if function == "domainBids":
domain = request.args.get('domain')
if not domain:
return jsonify({"error": "No domain specified"}), 400
bids = account_module.getBids(account,domain)
if bids == []:
return jsonify({"result": [], "error": "No bids found"}), 404
else:
reveals = account_module.getReveals(account,domain)
for reveal in reveals:
# Get TX
revealInfo = account_module.getRevealTX(reveal)
reveal['bid'] = revealInfo
bids = render.bids(bids,reveals)
return jsonify({"result": bids})
if function == "icon": if function == "icon":
# Check if there is an icon # Check if there is an icon
@@ -1646,12 +1750,6 @@ def api_wallet(function):
return send_file('templates/assets/img/HNS.png') return send_file('templates/assets/img/HNS.png')
if function == "possibleOutbids":
return jsonify({"result": account_module.getPossibleOutbids(account)})
return jsonify({"error": "Invalid function", "result": "Invalid function"}), 400 return jsonify({"error": "Invalid function", "result": "Invalid function"}), 400
@app.route('/api/v1/wallet/<function>/mobile', methods=["GET"]) @app.route('/api/v1/wallet/<function>/mobile', methods=["GET"])

View File

@@ -40,6 +40,24 @@ if TX_EXPLORER_URL is None:
NAMEHASH_CACHE = 'user_data/namehash_cache.json' NAMEHASH_CACHE = 'user_data/namehash_cache.json'
# Validate cache version
if os.path.exists(NAMEHASH_CACHE):
with open(NAMEHASH_CACHE, 'r') as f:
cache = json.load(f)
if not isinstance(cache, dict):
print("Invalid namehash cache format. Resetting cache.")
with open(NAMEHASH_CACHE, 'w') as f:
json.dump({}, f)
# Check if cache entries are valid
for key in cache:
if not cache[key].startswith("<a href='/manage/"):
print(f"Invalid cache entry for {key}. Resetting cache.")
with open(NAMEHASH_CACHE, 'w') as f:
json.dump({}, f)
break
CACHE_LOCK = threading.Lock() CACHE_LOCK = threading.Lock()
@@ -210,7 +228,6 @@ def dns(data, edit=False):
html_output = "" html_output = ""
index = 0 index = 0
for entry in data: for entry in data:
print(entry, flush=True)
html_output += f"<tr><td>{entry['type']}</td>\n" html_output += f"<tr><td>{entry['type']}</td>\n"
if entry['type'] != 'DS' and not entry['type'].startswith("GLUE") and not entry['type'].startswith("SYNTH"): if entry['type'] != 'DS' and not entry['type'].startswith("GLUE") and not entry['type'].startswith("SYNTH"):
@@ -279,35 +296,66 @@ def timestamp_to_readable_time(timestamp):
return readable_time return readable_time
def bids(bids,reveals): def bids(bids,reveals):
html = '' # Create a list to hold bid data for sorting
bid_data = []
# Prepare data for sorting
for bid in bids: for bid in bids:
lockup = bid['lockup'] lockup = bid['lockup'] / 1000000
lockup = lockup / 1000000
html += "<tr>"
html += f"<td>{lockup:,.2f} HNS</td>"
revealed = False revealed = False
value = 0
# Check if this bid has been revealed
for reveal in reveals: for reveal in reveals:
if reveal['bid'] == bid['prevout']['hash']: if reveal['bid'] == bid['prevout']['hash']:
revealed = True revealed = True
value = reveal['value'] value = reveal['value'] / 1000000
value = value / 1000000
html += f"<td>{value:,.2f} HNS</td>"
bidValue = lockup - value
html += f"<td>{bidValue:,.2f} HNS</td>"
break break
if not revealed:
# Store all relevant information for sorting and display
bid_data.append({
'bid': bid,
'lockup': lockup,
'revealed': revealed,
'value': value,
'sort_value': value if revealed else lockup # Use value for sorting if revealed, otherwise lockup
})
# Sort by the sort_value in descending order (highest first)
bid_data.sort(key=lambda x: x['sort_value'], reverse=True)
# Generate HTML from sorted data
html = ''
for data in bid_data:
bid = data['bid']
lockup = data['lockup']
revealed = data['revealed']
value = data['value']
html += "<tr>"
html += f"<td>{lockup:,.2f} HNS</td>"
if revealed:
bidValue = lockup - value
html += f"<td>{value:,.2f} HNS</td>"
html += f"<td>{bidValue:,.2f} HNS</td>"
else:
html += f"<td>Hidden until reveal</td>" html += f"<td>Hidden until reveal</td>"
html += f"<td>Hidden until reveal</td>" html += f"<td>Hidden until reveal</td>"
if bid['own']: if bid['own']:
html += "<td>You</td>" html += "<td>You</td>"
else: else:
html += "<td>Unknown</td>" html += f"<td>Unknown</td>"
html += f"<td><a class='text-decoration-none' style='color: var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color)));' target='_blank' href='{TX_EXPLORER_URL}{bid['prevout']['hash']}'>Bid TX 🔗</a></td>"
html += "</tr>" html += "</tr>"
return html return html
def bidDomains(bids,domains, sortbyDomains=False, outbids=[]): def bidDomains(bids,domains, sortbyDomains=False):
html = '' html = ''
if not sortbyDomains: if not sortbyDomains:
for bid in bids: for bid in bids:
for domain in domains: for domain in domains:
@@ -321,15 +369,13 @@ def bidDomains(bids,domains, sortbyDomains=False, outbids=[]):
bidDisplay = f'<b>{bidValue:,.2f}</b> (+{blind:,.2f}) HNS' bidDisplay = f'<b>{bidValue:,.2f}</b> (+{blind:,.2f}) HNS'
else: else:
bidDisplay = f'<b>{bidValue:,.2f}</b> HNS' bidDisplay = f'<b>{bidValue:,.2f}</b> HNS'
html += "<tr>" html += "<tr>"
if domain['name'] in outbids: html += f"<td><a class='text-decoration-none' style='color: var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color)));' href='/auction/{domain['name']}'>{renderDomain(domain['name'])}</a></td>"
html += f"<td style='background-color: red;'><a class='text-decoration-none' style='color: var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color)));' href='/auction/{domain['name']}'>{renderDomain(domain['name'])}</a></td>"
else:
html += f"<td><a class='text-decoration-none' style='color: var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color)));' href='/auction/{domain['name']}'>{renderDomain(domain['name'])}</a></td>"
html += f"<td>{domain['state']}</td>" html += f"<td>{domain['state']}</td>"
html += f"<td style='white-space: nowrap;'>{bidDisplay}</td>" html += f"<td style='white-space: nowrap;'>{bidDisplay}</td>"
html += f"<td class='hide-mobile'>{bid['height']:,}</td>" html += f"<td class='hide-mobile'>{domain['height']:,}</td>"
html += "</tr>" html += "</tr>"
else: else:
for domain in domains: for domain in domains:
@@ -345,7 +391,7 @@ def bidDomains(bids,domains, sortbyDomains=False, outbids=[]):
html += f"<td><a class='text-decoration-none' style='color: var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color)));' href='/auction/{domain['name']}'>{renderDomain(domain['name'])}</a></td>" html += f"<td><a class='text-decoration-none' style='color: var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color)));' href='/auction/{domain['name']}'>{renderDomain(domain['name'])}</a></td>"
html += f"<td>{domain['state']}</td>" html += f"<td>{domain['state']}</td>"
html += f"<td>{bidDisplay}</td>" html += f"<td>{bidDisplay}</td>"
html += f"<td class='hide-mobile'>{bid['height']:,}</td>" html += f"<td class='hide-mobile'>{domain['height']:,}</td>"
html += "</tr>" html += "</tr>"
return html return html
@@ -518,6 +564,8 @@ def renderDomainAsync(namehash: str) -> None:
if name["error"] is None: if name["error"] is None:
name = name["result"] name = name["result"]
rendered = renderDomain(name) rendered = renderDomain(name)
rendered = f"<a href='/manage/{name}' target='_blank' style='color: var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color)));'>{rendered}</a>"
with CACHE_LOCK: with CACHE_LOCK:
with open(NAMEHASH_CACHE, 'r') as f: with open(NAMEHASH_CACHE, 'r') as f:

View File

@@ -1 +1 @@
function createCard(e,n,t){if(document.getElementById(t)&&document.getElementById(t).remove(),n<=0)return;const s=document.createElement("div");s.classList.add("col-md-6","col-xl-3","mb-4"),s.id=t,html=`\n <div class="card shadow border-start-warning py-2">\n <div class="card-body">\n <div class="row align-items-center no-gutters">\n <div class="col me-2">\n <div class="text-uppercase text-warning fw-bold text-xs mb-1"><span>${e}</span></div>\n <div class="text-dark fw-bold h5 mb-0"><span id="${e}">${n}</span></div>\n </div>\n <div class="col"><a class="btn btn-primary" role="button" href="/all/${t.toLowerCase()}">${t} All</a></div>\n <div class="col-auto"><svg class="fa-2x text-gray-300" xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="1em" viewBox="0 0 24 24" width="1em" fill="currentColor">\n <g>\n <rect fill="none" height="24" width="24"></rect>\n </g>\n <g>\n <path d="M12,2C6.48,2,2,6.48,2,12c0,5.52,4.48,10,10,10s10-4.48,10-10C22,6.48,17.52,2,12,2z M7,13.5c-0.83,0-1.5-0.67-1.5-1.5 c0-0.83,0.67-1.5,1.5-1.5s1.5,0.67,1.5,1.5C8.5,12.83,7.83,13.5,7,13.5z M12,13.5c-0.83,0-1.5-0.67-1.5-1.5 c0-0.83,0.67-1.5,1.5-1.5s1.5,0.67,1.5,1.5C13.5,12.83,12.83,13.5,12,13.5z M17,13.5c-0.83,0-1.5-0.67-1.5-1.5 c0-0.83,0.67-1.5,1.5-1.5s1.5,0.67,1.5,1.5C18.5,12.83,17.83,13.5,17,13.5z"></path>\n </g>\n </svg></div>\n </div>\n </div>`,s.innerHTML=html,document.getElementById("actions-row").appendChild(s)}async function updateActions(){const e={Finalize:"Pending Finalizes",Register:"Pending Register",Redeem:"Pending Redeem",Reveal:"Pending Reveal"},n=Object.keys(e).map((e=>request(`wallet/pending${e}`).then((n=>({id:e,result:n}))))),t=await Promise.all(n);for(const{id:n,result:s}of t)"Error"!==s&&createCard(e[n],s.length,n);const s=await request("wallet/possibleOutbids");if("Error"===s)return;const d=document.getElementById("outbids");if(d&&d.remove(),s.length<=0)return;const i=document.createElement("div");i.classList.add("col-md-6","col-xl-3","mb-4"),i.id="outbids",i.innerHTML=`\n <div class="card shadow border-start-warning py-2">\n <div class="card-body">\n <div class="row align-items-center no-gutters">\n <div class="col me-2">\n <div class="text-uppercase text-warning fw-bold text-xs mb-1"><span>Names with possible outbids</span></div>\n <div class="text-dark fw-bold h5 mb-0"><span id="outbids-count">${s.length}</span></div>\n </div>\n <div class="col"><a class="btn btn-primary" role="button" href="/auctions?outbids=true">Show All</a></div>\n <div class="col-auto">\n <svg class="fa-2x text-gray-300" xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="1em" viewBox="0 0 24 24" width="1em" fill="currentColor">\n <g><rect fill="none" height="24" width="24"></rect></g>\n <g>\n <path d="M12,2C6.48,2,2,6.48,2,12c0,5.52,4.48,10,10,10s10-4.48,10-10C22,6.48,17.52,2,12,2z M7,13.5c-0.83,0-1.5-0.67-1.5-1.5 c0-0.83,0.67-1.5,1.5-1.5s1.5,0.67,1.5,1.5C8.5,12.83,7.83,13.5,7,13.5z M12,13.5c-0.83,0-1.5-0.67-1.5-1.5 c0-0.83,0.67-1.5,1.5-1.5s1.5,0.67,1.5,1.5C13.5,12.83,12.83,13.5,12,13.5z M17,13.5c-0.83,0-1.5-0.67-1.5-1.5 c0-0.83,0.67-1.5,1.5-1.5s1.5,0.67,1.5,1.5C18.5,12.83,17.83,13.5,17,13.5z"></path>\n </g>\n </svg>\n </div>\n </div>\n </div>\n </div>\n `,document.getElementById("actions-row").appendChild(i)}window.addEventListener("load",(async()=>{updateActions()})),setInterval((async function(){updateActions()}),2e4); function createCard(e,n,t){if(document.getElementById(t)&&document.getElementById(t).remove(),n<=0)return;const a=document.createElement("div");a.classList.add("col-md-6","col-xl-3","mb-4"),a.id=t,html=`\n <div class="card shadow border-start-warning py-2">\n <div class="card-body">\n <div class="row align-items-center no-gutters">\n <div class="col me-2">\n <div class="text-uppercase text-warning fw-bold text-xs mb-1"><span>${e}</span></div>\n <div class="text-dark fw-bold h5 mb-0"><span id="${e}">${n}</span></div>\n </div>\n <div class="col"><a class="btn btn-primary" role="button" href="/all/${t.toLowerCase()}">${t} All</a></div>\n <div class="col-auto"><svg class="fa-2x text-gray-300" xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="1em" viewBox="0 0 24 24" width="1em" fill="currentColor">\n <g>\n <rect fill="none" height="24" width="24"></rect>\n </g>\n <g>\n <path d="M12,2C6.48,2,2,6.48,2,12c0,5.52,4.48,10,10,10s10-4.48,10-10C22,6.48,17.52,2,12,2z M7,13.5c-0.83,0-1.5-0.67-1.5-1.5 c0-0.83,0.67-1.5,1.5-1.5s1.5,0.67,1.5,1.5C8.5,12.83,7.83,13.5,7,13.5z M12,13.5c-0.83,0-1.5-0.67-1.5-1.5 c0-0.83,0.67-1.5,1.5-1.5s1.5,0.67,1.5,1.5C13.5,12.83,12.83,13.5,12,13.5z M17,13.5c-0.83,0-1.5-0.67-1.5-1.5 c0-0.83,0.67-1.5,1.5-1.5s1.5,0.67,1.5,1.5C18.5,12.83,17.83,13.5,17,13.5z"></path>\n </g>\n </svg></div>\n </div>\n </div>`,a.innerHTML=html,document.getElementById("actions-row").appendChild(a)}async function updateActions(){const e={Finalize:"Pending Finalizes",Register:"Pending Register",Redeem:"Pending Redeem",Reveal:"Pending Reveal"};for(const n in e){const t=await request(`wallet/pending${n}`);"Error"!=t&&createCard(e[n],t.length,n)}}window.addEventListener("load",(async()=>{updateActions()})),setInterval((async function(){updateActions()}),2e4);

View File

@@ -66,9 +66,9 @@
<div class="container-fluid"> <div class="container-fluid">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<div class="stick-right">{{next_action|safe}}</div> <div id="next-action" class="stick-right">{{next_action|safe}}</div>
<h4 class="card-title">{{rendered}}</h4> <h4 class="card-title">{{rendered}}</h4>
<h6 class="text-muted mb-2 card-subtitle">{{next}}</h6> <h6 class="text-muted mb-2 card-subtitle" id="next">{{next | safe}}</h6>
</div> </div>
</div> </div>
</div> </div>
@@ -93,13 +93,92 @@
<th>Bid</th> <th>Bid</th>
<th>Blind</th> <th>Blind</th>
<th>Owner</th> <th>Owner</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="bids-tbody">
{{bids | safe}} <tr id="loading-row">
<td colspan="5" class="text-center">
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Loading...</span>
</div>
Loading bids...
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<script>
async function loadBids(initial = false) {
const tbody = document.getElementById('bids-tbody');
try {
// Fetch all required data
const response = await fetch(`/api/v1/wallet/domainBids?domain={{search_term}}`);
const data = await response.json();
if (initial) {
if (response.ok && data.result) {
tbody.innerHTML = data.result;
} else {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No bids found. <a href="/auction/{{search_term}}/scan">Rescan Auction</a></td></tr>';
}
}
const mempoolResponse = await fetch('/api/v1/hsd/mempoolBids');
const nextStateResponse = await fetch(`/api/v1/hsd/nextAuctionState?domain={{search_term}}`);
if (!initial) {
if (response.ok && data.result) {
tbody.innerHTML = data.result;
} else {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No bids found. <a href="/auction/{{search_term}}/scan">Rescan Auction</a></td></tr>';
}
}
const nextStateData = await nextStateResponse.json();
if (nextStateResponse.ok && nextStateData.state) {
document.getElementById('next').innerHTML = nextStateData.next;
document.getElementById('next-action').innerHTML = nextStateData.next_action;
} else {
document.getElementById('next').innerHTML = 'Unknown';
document.getElementById('next-action').innerHTML = '';
}
const mempoolData = await mempoolResponse.json();
if (mempoolResponse.ok && mempoolData.result) {
const domainBids = mempoolData.result['{{search_term}}'];
if (domainBids && domainBids.length > 0) {
let mempoolRows = '';
domainBids.forEach(bid => {
const bidValue = bid.revealed ? `${(bid.value / 1000000).toFixed(2)} HNS` : 'Hidden until reveal';
const lockupValue = (bid.lockup / 1000000).toFixed(2);
const blindValue = bid.revealed ? `${((bid.lockup - bid.value) / 1000000).toFixed(2)} HNS` : 'Hidden until reveal';
const type = bid.revealed ? 'Reveal' : 'Bid';
mempoolRows += `<tr class="table-warning">
<td>${lockupValue} HNS</td>
<td>${bidValue}</td>
<td>${blindValue}</td>
<td>${bid.owner}</td>
<td><a class='text-decoration-none' style='color: var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color)));' target='_blank' href='https://shakeshift.com/transaction/${bid.txid}'>Mempool ${type} 🔗</a></td>
</tr>`;
});
tbody.innerHTML += mempoolRows;
}
}
} catch (error) {
console.error('Error loading bids:', error);
}
}
// Load bids when page loads
document.addEventListener('DOMContentLoaded', () => loadBids(true));
// Auto-refresh bids every 20 seconds
setInterval(() => loadBids(false), 20000);
</script>
</div> </div>
</div> </div>
</div> </div>