6 Commits

8 changed files with 288 additions and 85 deletions

Binary file not shown.

View File

@@ -35,13 +35,13 @@ Also available as a docker image:
To run using a HSD running directly on the host:
```bash
sudo docker run --network=host -e hsd_api=yourapikeyhere git.woodburn.au/nathanwoodburn/firewallet:latest
sudo docker run --network=host -e HSD_API=yourapikeyhere git.woodburn.au/nathanwoodburn/firewallet:latest
```
If you have HSD running on a different IP/container
```bash
sudo docker run -p 5000:5000 -e hsd_api=yourapikeyhere -e hsd_ip=hsdcontainer git.woodburn.au/nathanwoodburn/firewallet:latest
sudo docker run -p 5000:5000 -e HSD_API=yourapikeyhere -e HSD_IP=hsdcontainer git.woodburn.au/nathanwoodburn/firewallet:latest
```
For Docker you can mount a volume to persist the user data (/app/user_data)
@@ -115,11 +115,11 @@ Auction page
## Environment variables
```yaml
hsd_api: HSD API key
hsd_ip: HSD IP address
theme: Theme to use (dark-purple, black)
show_expired: Show expired domains (true/false)
exclude: Comma separated list of wallets to exclude from the wallet list
HSD_API: HSD API key
HSD_IP: HSD IP address
THEME: Theme to use (dark-purple, black)
SHOW_EXPIRED: Show expired domains (true/false)
EXCLUDE: Comma separated list of wallets to exclude from the wallet list
```

View File

@@ -10,12 +10,12 @@ import time
dotenv.load_dotenv()
HSD_API = os.getenv("hsd_api")
HSD_IP = os.getenv("hsd_ip")
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_NETWORK = os.getenv("HSD_NETWORK")
HSD_WALLET_PORT = 12039
HSD_NODE_PORT = 12037
@@ -35,9 +35,9 @@ elif HSD_NETWORK == "regtest":
HSD_NODE_PORT = 14037
show_expired = os.getenv("show_expired")
if show_expired is None:
show_expired = False
SHOW_EXPIRED = os.getenv("SHOW_EXPIRED")
if SHOW_EXPIRED is None:
SHOW_EXPIRED = False
hsd = api.hsd(HSD_API,HSD_IP,HSD_NODE_PORT)
hsw = api.hsw(HSD_API,HSD_IP,HSD_WALLET_PORT)
@@ -47,9 +47,9 @@ cacheTime = 3600
# Verify the connection
response = hsd.getInfo()
exclude = ["primary"]
if os.getenv("exclude") is not None:
exclude = os.getenv("exclude").split(",")
EXCLUDE = ["primary"]
if os.getenv("EXCLUDE") is not None:
EXCLUDE = os.getenv("EXCLUDE").split(",")
def hsdConnected():
if hsdVersion() == -1:
@@ -163,7 +163,7 @@ def listWallets():
# Check if response is json or an array
if isinstance(response, list):
# Remove excluded wallets
response = [wallet for wallet in response if wallet not in exclude]
response = [wallet for wallet in response if wallet not in EXCLUDE]
return response
return ['Wallet not connected']
@@ -244,7 +244,7 @@ def getDomains(account,own=True):
response = requests.get(f"http://x:{HSD_API}@{HSD_IP}:{HSD_WALLET_PORT}/wallet/{account}/name")
info = response.json()
if show_expired:
if SHOW_EXPIRED:
return info
# Remove any expired domains
@@ -433,6 +433,15 @@ def send(account,address,amount):
"tx": response['result']
}
def isOwnDomain(account,name: str):
domains = getDomains(account)
for domain in domains:
print(domain)
if domain['name'] == name:
return True
return False
def getDomain(domain: str):
# Get the domain
response = hsd.rpc_getNameInfo(domain)
@@ -476,7 +485,6 @@ def getDNS(domain: str):
return []
return response['result']['records']
def setDNS(account,domain,records):
account_name = check_account(account)
password = ":".join(account.split(":")[1:])
@@ -521,6 +529,9 @@ def setDNS(account,domain,records):
response = hsw.sendUPDATE(account_name,password,domain,data)
return response
def register(account,domain):
# Maybe add default dns records?
return setDNS(account,domain,'[]')
def getNodeSync():
response = hsd.getInfo()
@@ -566,6 +577,43 @@ def getBids(account, domain="NONE"):
def getReveals(account,domain):
return hsw.getWalletRevealsByName(domain,account)
def getPendingReveals(account):
bids = getBids(account)
domains = getDomains(account,False)
pending = []
for domain in domains:
if domain['state'] == "REVEAL":
reveals = getReveals(account,domain['name'])
for bid in bids:
if bid['name'] == domain['name']:
state_found = False
for reveal in reveals:
if reveal['own'] == True:
if bid['value'] == reveal['value']:
state_found = True
if not state_found:
pending.append(bid)
return pending
#! TODO
def getPendingRedeems(account):
bids = getBids(account)
domains = getDomains(account,False)
pending = []
return pending
def getPendingRegisters(account):
bids = getBids(account)
domains = getDomains(account,False)
pending = []
for domain in domains:
if domain['state'] == "CLOSED" and domain['registered'] == False:
for bid in bids:
if bid['name'] == domain['name']:
if bid['value'] == domain['highest']:
pending.append(bid)
return pending
def getRevealTX(reveal):
prevout = reveal['prevout']
@@ -573,7 +621,11 @@ def getRevealTX(reveal):
index = prevout['index']
tx = hsd.getTxByHash(hash)
if 'inputs' not in tx:
# Check if registered
print(f'Something is up with this tx: {hash}')
print(tx)
print('---')
# No idea what happened here
# Check if registered?
return None
return tx['inputs'][index]['prevout']['hash']
@@ -616,7 +668,6 @@ def revealAll(account):
response = hsw.rpc_walletPassphrase(password,10)
if response['error'] is not None:
return
# Try to send the batch of all renew, reveal and redeem actions
return requests.post(f"http://x:{HSD_API}@{HSD_IP}:{HSD_WALLET_PORT}",json={"method": "sendbatch","params": [[["REVEAL"]]]}).json()
except Exception as e:
@@ -626,6 +677,58 @@ def revealAll(account):
}
}
def redeemAll(account):
account_name = check_account(account)
password = ":".join(account.split(":")[1:])
if account_name == False:
return {
"error": {
"message": "Invalid account"
}
}
try:
# Try to select and login to the wallet
response = hsw.rpc_selectWallet(account_name)
if response['error'] is not None:
return
response = hsw.rpc_walletPassphrase(password,10)
if response['error'] is not None:
return
return requests.post(f"http://x:{HSD_API}@{HSD_IP}:{HSD_WALLET_PORT}",json={"method": "sendbatch","params": [[["REDEEM"]]]}).json()
except Exception as e:
return {
"error": {
"message": str(e)
}
}
def registerAll(account):
account_name = check_account(account)
password = ":".join(account.split(":")[1:])
if account_name == False:
return {
"error": {
"message": "Invalid account"
}
}
# try:
domains = getPendingRegisters(account_name)
if len(domains) == 0:
return {
"error": {
"message": "Nothing to do."
}
}
batch = []
for domain in domains:
batch.append(["UPDATE",domain['name'],{"records":[]}])
return sendBatch(account,batch)
def rescan_auction(account,domain):
# Get height of the start of the auction
response = hsw.rpc_selectWallet(account)

View File

@@ -1,4 +1,5 @@
hsd_api=123480615465636893475aCwyaae6s45
hsd_ip=localhost
theme=black
show_expired=false
HSD_API=123480615465636893475aCwyaae6s45
HSD_IP=localhost
THEME=black
SHOW_EXPIRED=false
EXPLORER_TX=https://niami.io/tx/

102
main.py
View File

@@ -26,7 +26,7 @@ fees = 0.02
revokeCheck = random.randint(100000,999999)
theme = os.getenv("theme")
THEME = os.getenv("THEME")
@app.route('/')
def index():
@@ -322,19 +322,7 @@ def auctions():
bidsHtml = render.bidDomains(bids,domains)
pending_reveals = 0
for domain in domains:
if domain['state'] == "REVEAL":
for bid in bids:
if bid['name'] == domain['name']:
bid_found = False
reveals = account_module.getReveals(account,domain['name'])
for reveal in reveals:
if reveal['own'] == True:
if bid['value'] == reveal['value']:
bid_found = True
if not bid_found:
pending_reveals += 1
plugins = ""
@@ -347,10 +335,11 @@ def auctions():
sort_state=sort_state,sort_domain=sort_domain,
sort_price_next=sort_price_next,
sort_state_next=sort_state_next,sort_domain_next=sort_domain_next,
bids=len(bids),reveal=pending_reveals,message=message,
bids=len(bids),message=message,
sort_time=sort_time,sort_time_next=sort_time_next)
@app.route('/reveal')
@app.route('/all/reveal')
def revealAllBids():
# Check if the user is logged in
if request.cookies.get("account") is None:
@@ -371,6 +360,46 @@ def revealAllBids():
return redirect("/success?tx=" + response['result']['hash'])
@app.route('/all/redeem')
def redeemAllBids():
# 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")
response = account_module.redeemAll(request.cookies.get("account"))
if 'error' in response:
print(response)
if response['error'] != None:
if response['error']['message'] == "Nothing to do.":
return redirect("/auctions?message=No redeems pending")
return redirect("/auctions?message=" + response['error']['message'])
return redirect("/success?tx=" + response['result']['hash'])
@app.route('/all/register')
def registerAllDomains():
# 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")
response = account_module.registerAll(request.cookies.get("account"))
if 'error' in response:
print(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'])
@app.route('/search')
def search():
# Check if the user is logged in
@@ -845,6 +874,10 @@ def auction(domain):
error=error)
if domainInfo['info'] is None:
if domainInfo['registered'] == False and domainInfo['expired'] == False:
# Needs to be registered
next_action = f'ERROR GETTING NEXT STATE'
else:
next_action = f'<a href="/auction/{domain}/open">Open Auction</a>'
return render_template("auction.html", account=account,
@@ -871,6 +904,14 @@ def auction(domain):
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>'
@@ -1030,7 +1071,22 @@ def reveal_auction(domain):
return redirect("/logout")
domain = domain.lower()
response = account_module.revealAuction(request.cookies.get("account"),domain)
response = account_module(request.cookies.get("account"),domain)
if 'error' in response:
return redirect("/auction/" + domain + "?message=" + response['error']['message'])
return redirect("/success?tx=" + response['hash'])
@app.route('/auction/<domain>/register')
def registerdomain(domain):
# Check if the user is logged in
if request.cookies.get("account") is None:
return redirect("/login")
if not account_module.check_account(request.cookies.get("account")):
return redirect("/logout")
domain = domain.lower()
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'])
@@ -1450,6 +1506,16 @@ def api_wallet(function):
if function == "domainCount":
return jsonify({"result": len(account_module.getDomains(account))})
if function == "bidCount":
return jsonify({"result": len(account_module.getBids(account))})
if function == "pendingReveal":
return jsonify({"result": len(account_module.getPendingReveals(account))})
if function == "pendingRegister":
return jsonify({"result": len(account_module.getPendingRegisters(account))})
if function == "pendingRedeem":
return jsonify({"result": len(account_module.getPendingRedeems(account))})
return jsonify({"error": "Invalid function", "result": "Invalid function"}), 400
@@ -1466,9 +1532,9 @@ def qr(data):
# Theme
@app.route('/assets/css/styles.min.css')
def send_css():
if theme == "live":
if THEME == "live":
return send_from_directory('templates/assets/css', 'styles.min.css')
return send_from_directory('themes', f'{theme}.css')
return send_from_directory('themes', f'{THEME}.css')
@app.route('/assets/<path:path>')
def send_assets(path):

View File

@@ -3,6 +3,14 @@ import json
import urllib.parse
from flask import render_template
from domainLookup import punycode_to_emoji
import os
# Get Explorer URL
TX_EXPLORER_URL = os.getenv("EXPLORER_TX")
if TX_EXPLORER_URL is None:
TX_EXPLORER_URL = "https://niami.io/tx/"
def domains(domains, mobile=False):
html = ''
@@ -21,10 +29,17 @@ def domains(domains, mobile=False):
if emoji != name:
name = f'{emoji} ({name})'
link = f'/manage/{domain["name"]}'
link_action = "Manage"
if domain['registered'] == False:
link_action = "Register"
link = f'/auction/{domain["name"]}/register'
if not mobile:
html += f'<tr><td>{name}</td><td>{expires} days</td><td>{paid} HNS</td><td><a href="/manage/{domain["name"]}">Manage</a></td></tr>'
html += f'<tr><td>{name}</td><td>{expires} days</td><td>{paid:,.2f} HNS</td><td><a href="{link}">{link_action}</a></td></tr>'
else:
html += f'<tr><td><a href="/manage/{domain["name"]}">{name}</a></td><td>{expires} days</td></tr>'
html += f'<tr><td><a href="{link}">{name}</a></td><td>{expires} days</td></tr>'
return html
@@ -58,17 +73,15 @@ def transactions(txs):
amount += output["value"]
amount = amount / 1000000
amount = round(amount, 2)
amount = "{:,}".format(amount)
hash = "<a target='_blank' href='https://niami.io/tx/" + hash + "'>" + hash[:8] + "...</a>"
hash = f"<a target='_blank' href='{TX_EXPLORER_URL}{hash}'>{hash[:8]}...</a>"
if confirmations < 5:
confirmations = "<td style='background-color: red;'>" + str(confirmations) + "</td>"
confirmations = f"<td style='background-color: red;'>{confirmations}</td>"
else:
confirmations = "<td>" + str(confirmations) + "</td>"
confirmations = f"<td>{confirmations:,}</td>"
html += f'<tr><td>{action}</td><td>{address}</td><td>{hash}</td>{confirmations}<td>{amount} HNS</td></tr>'
html += f'<tr><td>{action}</td><td>{address}</td><td>{hash}</td>{confirmations}<td>{amount:,.2f} HNS</td></tr>'
return html
@@ -92,14 +105,14 @@ def dns(data, edit=False):
elif entry['type'] == 'DS':
ds = str(entry['keyTag']) + " " + str(entry['algorithm']) + " " + str(entry['digestType']) + " " + entry['digest']
ds = f'{entry['keyTag']} {entry['algorithm']} {entry['digestType']} {entry['digest']}'
html_output += f"<td>{ds}</td>\n"
else:
value = ""
for key, val in entry.items():
if key != 'type':
value += str(val) + " "
value += f'{val} '
html_output += f"<td>{value}</td>\n"
if edit:
@@ -119,18 +132,16 @@ def txs(data):
for entry in data:
html_output += f"<tr><td>{entry['action']}</td>\n"
html_output += f"<td><a target='_blank' href='https://niami.io/tx/{entry['txid']}'>{entry['txid'][:8]}...</a></td>\n"
html_output += f"<td><a target='_blank' href='{TX_EXPLORER_URL}{entry['txid']}'>{entry['txid'][:8]}...</a></td>\n"
amount = entry['amount']
amount = amount / 1000000
amount = round(amount, 2)
if entry['blind'] == None:
html_output += f"<td>{amount} HNS</td>\n"
html_output += f"<td>{amount:,.2f} HNS</td>\n"
else:
blind = entry['blind']
blind = blind / 1000000
blind = round(blind, 2)
html_output += f"<td>{amount} + {blind} HNS</td>\n"
html_output += f"<td>{amount:,.2f} + {blind:,.2f} HNS</td>\n"
html_output += f"<td>{timestamp_to_readable_time(entry['time'])}</td>\n"
html_output += f"</tr>\n"
@@ -149,20 +160,17 @@ def bids(bids,reveals):
for bid in bids:
lockup = bid['lockup']
lockup = lockup / 1000000
lockup = round(lockup, 2)
html += "<tr>"
html += f"<td>{lockup} HNS</td>"
html += f"<td>{lockup:,.2f} HNS</td>"
revealed = False
for reveal in reveals:
if reveal['bid'] == bid['prevout']['hash']:
revealed = True
value = reveal['value']
value = value / 1000000
value = round(value, 2)
html += f"<td>{value} HNS</td>"
html += f"<td>{value:,.2f} HNS</td>"
bidValue = lockup - value
bidValue = round(bidValue, 2)
html += f"<td>{bidValue} HNS</td>"
html += f"<td>{bidValue:,.2f} HNS</td>"
break
if not revealed:
html += f"<td>Hidden until reveal</td>"
@@ -183,22 +191,17 @@ def bidDomains(bids,domains, sortState=False):
if bid['name'] == domain['name']:
lockup = bid['lockup']
lockup = lockup / 1000000
lockup = round(lockup, 2)
bidValue = bid['value'] / 1000000
bidValue = round(bidValue, 2)
blind = lockup - bidValue
bidValue = "{:,}".format(bidValue)
blind = round(blind, 2)
blind = "{:,}".format(blind)
bidDisplay = f'<b>{bidValue} HNS</b> + {blind} HNS blind'
bidDisplay = f'<b>{bidValue:,.2f} HNS</b> + {blind:,.2f} HNS blind'
html += "<tr>"
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']}'>{domain['name']}</a></td>"
html += f"<td>{domain['state']}</td>"
html += f"<td>{bidDisplay}</td>"
html += f"<td>{bid['height']}</td>"
html += f"<td>{bid['height']:,}</td>"
html += "</tr>"
else:
for domain in domains:
@@ -206,19 +209,15 @@ def bidDomains(bids,domains, sortState=False):
if bid['name'] == domain['name']:
lockup = bid['lockup']
lockup = lockup / 1000000
lockup = round(lockup, 2)
bidValue = bid['value'] / 1000000
bidValue = round(bidValue, 2)
blind = lockup - bidValue
bidValue = "{:,}".format(bidValue)
blind = "{:,}".format(blind)
bidDisplay = f'<b>{bidValue} HNS</b> + {blind} HNS blind'
bidDisplay = f'<b>{bidValue:,.2f} HNS</b> + {blind:,.2f} HNS blind'
html += "<tr>"
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']}'>{domain['name']}</a></td>"
html += f"<td>{domain['state']}</td>"
html += f"<td>{bidDisplay}</td>"
html += f"<td>{domain['height']}</td>"
html += f"<td>{domain['height']:,}</td>"
html += "</tr>"
return html

View File

@@ -1 +1 @@
async function request(e){try{const t=await fetch(`/api/v1/${e}`);if(!t.ok)throw new Error(`HTTP error! Status: ${t.status}`);const o=await t.json();return void 0!==o.error?`Error: ${o.error}`:o.result}catch(e){return console.error("Request failed:",e),"Error"}}window.addEventListener("load",(async()=>{const e=["hsd-sync","hsd-version","hsd-height","wallet-sync","wallet-available","wallet-total","wallet-locked","wallet-pending","wallet-domainCount"],t=["wallet-available","wallet-total","wallet-locked","wallet-pending"];for(const o of e){const e=document.getElementById(o);if(e){const l=o.replace(/-/g,"/");let n=await request(l);t.includes(o)&&(n=Number(n).toFixed(2),n=n.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")),e.innerHTML=n}}})),function(){"use strict";var e=document.querySelector(".sidebar"),t=document.querySelectorAll("#sidebarToggle, #sidebarToggleTop");if(e){e.querySelector(".collapse");var o=[].slice.call(document.querySelectorAll(".sidebar .collapse")).map((function(e){return new bootstrap.Collapse(e,{toggle:!1})}));for(var l of t)l.addEventListener("click",(function(t){if(document.body.classList.toggle("sidebar-toggled"),e.classList.toggle("toggled"),e.classList.contains("toggled"))for(var l of o)l.hide()}));window.addEventListener("resize",(function(){if(Math.max(document.documentElement.clientWidth||0,window.innerWidth||0)<768)for(var e of o)e.hide()}))}var n=document.querySelector("body.fixed-nav .sidebar");n&&n.on("mousewheel DOMMouseScroll wheel",(function(e){if(Math.max(document.documentElement.clientWidth||0,window.innerWidth||0)>768){var t=e.originalEvent,o=t.wheelDelta||-t.detail;this.scrollTop+=30*(o<0?1:-1),e.preventDefault()}}));var r=document.querySelector(".scroll-to-top");r&&window.addEventListener("scroll",(function(){var e=window.pageYOffset;r.style.display=e>100?"block":"none"}))}();
async function request(e){try{const t=await fetch(`/api/v1/${e}`);if(!t.ok)throw new Error(`HTTP error! Status: ${t.status}`);const l=await t.json();return void 0!==l.error?`Error: ${l.error}`:l.result}catch(e){return console.error("Request failed:",e),"Error"}}window.addEventListener("load",(async()=>{const e=["hsd-sync","hsd-version","hsd-height","wallet-sync","wallet-available","wallet-total","wallet-locked","wallet-pending","wallet-domainCount","wallet-bidCount","wallet-pendingReveal","wallet-pendingRegister","wallet-pendingRedeem"],t=["wallet-available","wallet-total","wallet-locked","wallet-pending"];for(const l of e){const e=document.getElementById(l);if(e){const o=l.replace(/-/g,"/");let n=await request(o);t.includes(l)&&(n=Number(n).toFixed(2),n=n.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")),e.innerHTML=n}}})),function(){"use strict";var e=document.querySelector(".sidebar"),t=document.querySelectorAll("#sidebarToggle, #sidebarToggleTop");if(e){e.querySelector(".collapse");var l=[].slice.call(document.querySelectorAll(".sidebar .collapse")).map((function(e){return new bootstrap.Collapse(e,{toggle:!1})}));for(var o of t)o.addEventListener("click",(function(t){if(document.body.classList.toggle("sidebar-toggled"),e.classList.toggle("toggled"),e.classList.contains("toggled"))for(var o of l)o.hide()}));window.addEventListener("resize",(function(){if(Math.max(document.documentElement.clientWidth||0,window.innerWidth||0)<768)for(var e of l)e.hide()}))}var n=document.querySelector("body.fixed-nav .sidebar");n&&n.on("mousewheel DOMMouseScroll wheel",(function(e){if(Math.max(document.documentElement.clientWidth||0,window.innerWidth||0)>768){var t=e.originalEvent,l=t.wheelDelta||-t.detail;this.scrollTop+=30*(l<0?1:-1),e.preventDefault()}}));var r=document.querySelector(".scroll-to-top");r&&window.addEventListener("scroll",(function(){var e=window.pageYOffset;r.style.display=e>100?"block":"none"}))}();

View File

@@ -87,7 +87,7 @@
<div class="row align-items-center no-gutters">
<div class="col me-2">
<div class="text-uppercase text-success fw-bold text-xs mb-1"><span>Total Bids</span></div>
<div class="text-dark fw-bold h5 mb-0"><span>{{bids}}</span></div>
<div class="text-dark fw-bold h5 mb-0"><span id="wallet-bidCount">0</span></div>
</div>
</div>
</div>
@@ -101,9 +101,43 @@
<div class="text-uppercase text-info fw-bold text-xs mb-1"><span>Pending Reveal</span></div>
<div class="row g-0 align-items-center">
<div class="col-auto">
<div class="text-dark fw-bold h5 mb-0 me-3"><span>{{reveal}}</span></div>
<div class="text-dark fw-bold h5 mb-0 me-3"><span id="wallet-pendingReveal">0</span></div>
</div>
<div class="col"><a class="btn btn-primary" role="button" href="/reveal">Reveal All</a></div>
<div class="col"><a class="btn btn-primary" role="button" href="/all/reveal">Reveal All</a></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-xl-3 mb-4">
<div class="card shadow border-start-info py-2">
<div class="card-body">
<div class="row align-items-center no-gutters">
<div class="col me-2">
<div class="text-uppercase text-info fw-bold text-xs mb-1"><span>Pending Redeem</span></div>
<div class="row g-0 align-items-center">
<div class="col-auto">
<div class="text-dark fw-bold h5 mb-0 me-3"><span id="wallet-pendingRedeem">0</span></div>
</div>
<div class="col"><a class="btn btn-primary" role="button" href="/all/redeem">Redeem All</a></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-xl-3 mb-4">
<div class="card shadow border-start-info py-2">
<div class="card-body">
<div class="row align-items-center no-gutters">
<div class="col me-2">
<div class="text-uppercase text-info fw-bold text-xs mb-1"><span>Pending Register</span></div>
<div class="row g-0 align-items-center">
<div class="col-auto">
<div class="text-dark fw-bold h5 mb-0 me-3"><span id="wallet-pendingRegister">0</span></div>
</div>
<div class="col"><a class="btn btn-primary" role="button" href="/all/register">Register All</a></div>
</div>
</div>
</div>