From f9af1b26061717f7e678788e01cad6643b85eb15 Mon Sep 17 00:00:00 2001 From: Nathan Woodburn Date: Mon, 3 Jun 2024 12:22:28 +1000 Subject: [PATCH] feat: Add initial watchonly server files --- .gitea/workflows/build.yml | 41 +++++ .gitignore | 4 + Dockerfile | 17 ++ hns.py | 5 + hsd.py | 346 +++++++++++++++++++++++++++++++++++++ main.py | 101 +++++++++++ requirements.txt | 2 + 7 files changed, 516 insertions(+) create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 hns.py create mode 100644 hsd.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..fde08bc --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,41 @@ +name: Build Docker +run-name: Build Docker Images +on: + push: + +jobs: + BuildImage: + runs-on: [ubuntu-latest, amd] + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Install Docker + run : | + apt-get install ca-certificates curl gnupg + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + echo "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + apt-get update + apt-get install docker-ce-cli -y + - name: Build Docker image + run : | + echo "${{ secrets.DOCKERGIT_TOKEN }}" | docker login git.woodburn.au -u nathanwoodburn --password-stdin + echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" + tag=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}} + tag=${tag//\//-} + tag_num=${GITHUB_RUN_NUMBER} + echo "tag_num=$tag_num" + + if [[ "$tag" == "main" ]]; then + tag="latest" + else + tag_num="${tag}-${tag_num}" + fi + + + docker build -t firewallet-api:$tag_num . + docker tag firewallet-api:$tag_num git.woodburn.au/nathanwoodburn/firewallet-api:$tag_num + docker push git.woodburn.au/nathanwoodburn/firewallet-api:$tag_num + docker tag firewallet-api:$tag_num git.woodburn.au/nathanwoodburn/firewallet-api:$tag + docker push git.woodburn.au/nathanwoodburn/firewallet-api:$tag \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e658bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +__pycache__/ + +.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aeddcb4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM --platform=$BUILDPLATFORM python:3.10-alpine AS builder + +WORKDIR /app + +COPY requirements.txt /app +RUN --mount=type=cache,target=/root/.cache/pip \ + pip3 install -r requirements.txt + +COPY . /app + +# Add mount point for data volume +VOLUME /data + +ENTRYPOINT ["python3"] +CMD ["main.py"] + +FROM builder as dev-envs \ No newline at end of file diff --git a/hns.py b/hns.py new file mode 100644 index 0000000..c7a4cee --- /dev/null +++ b/hns.py @@ -0,0 +1,5 @@ +def from_small(amount:int) -> float: + return amount/10**6 + +def to_small(amount:float) -> int: + return int(amount*(10**6)) \ No newline at end of file diff --git a/hsd.py b/hsd.py new file mode 100644 index 0000000..1d39ebb --- /dev/null +++ b/hsd.py @@ -0,0 +1,346 @@ +from functools import cache +import requests +import dotenv +import os +import json +import uuid +import hns + +dotenv.load_dotenv() + +HSD_API_KEY = os.getenv('HSD_API_KEY') +HSD_API_IP = os.getenv('HSD_API_IP') +if not HSD_API_IP: + HSD_API_IP = '127.0.0.1' + +HSD_API_NETWORK = os.getenv('HSD_API_NETWORK') +if not HSD_API_NETWORK: + HSD_API_NETWORK = 'main' + +network_port_int = { + 'main': 1203, + 'testnet': 1303, + 'regtest': 1403, + 'simnet': 1503 +} + + +account_file = 'accounts.json' +wallet_file = 'wallets.json' + +if not os.path.exists('.local'): + account_file = '/data/accounts.json' + wallet_file = '/data/wallets.json' + +# Create any missing files +if not os.path.exists('accounts.json'): + with open('accounts.json', 'w') as f: + json.dump({}, f, indent=4) + +if not os.path.exists('wallets.json'): + with open('wallets.json', 'w') as f: + json.dump([], f, indent=4) + + +def url(walletURL:bool) -> str: + if walletURL: + return f'http://x:{HSD_API_KEY}@{HSD_API_IP}:{network_port_int[HSD_API_NETWORK]}9/' + else: + return f'http://x:{HSD_API_KEY}@{HSD_API_IP}:{network_port_int[HSD_API_NETWORK]}7/' + + +def rescan() -> dict: + return requests.post(url(True)+'rescan', json={'height':0}).json() + +def get_master() -> dict: + return requests.get(url(True)+'a61ba47a-54de-43b9-9d22-fcf6827cd5c2/master').json() + +def get_status() -> dict: + return requests.get(url(False)).json() + +def get_wallets() -> dict: + return requests.get(url(True)+'wallet').json() + +def create_account() -> dict: + # Generate a UUID + userID = str(uuid.uuid4()) + + with open('accounts.json', 'r') as f: + accounts = json.load(f) + + # Check if the user already exists + if userID in accounts: + return { + 'error': 'Please try again' + } + + accounts[userID] = { + 'wallets': [] + } + + with open('accounts.json', 'w') as f: + json.dump(accounts, f, indent=4) + + return { + 'userID': userID + } + +def get_account(userID:str) -> dict: + accounts = get_accounts() + if userID in accounts: + return accounts[userID] + else: + return { + 'error': 'User not found' + } + +def get_accounts() -> dict: + # Read from accounts + with open('accounts.json', 'r') as f: + accounts = json.load(f) + return accounts + + +def import_wallet(name:str,userID:str,xpub:str) -> dict: + accounts = get_accounts() + + if userID not in accounts: + return { + 'error': 'User not found' + } + + # Check if the wallet already exists + for wallet in accounts[userID]['wallets']: + if wallet['name'] == name: + return { + 'error': 'Wallet already exists' + } + + # Create the wallet using a UUID + walletID = str(uuid.uuid4()) + with open('wallets.json', 'r') as f: + wallets = json.load(f) + + if walletID in wallets: + return { + 'error': 'Please try again' + } + + wallet = { + 'name': name, + 'xpub': xpub, + 'walletID': walletID + } + + wallet_data = { + "watchOnly": True, + "accountKey": xpub, + } + response = requests.put(url(True)+'wallet/'+walletID, json=wallet_data) + if response.status_code != 200: + print(response.text) + return { + 'error': 'Error creating wallet' + } + + accounts[userID]['wallets'].append(wallet) + with open('accounts.json', 'w') as f: + json.dump(accounts, f, indent=4) + + wallets.append(walletID) + with open('wallets.json', 'w') as f: + json.dump(wallets, f, indent=4) + + # Rescan the wallet to get the balance + rescan() + + return { + 'walletID': walletID + } + + +@cache +def get_wallet_UUID(userID:str, name:str) -> str|None: + accounts = get_accounts() + + if userID not in accounts: + return None + + walletID = None + for wallet in accounts[userID]['wallets']: + if wallet['name'] == name: + walletID = wallet['walletID'] + break + + return walletID + +def get_wallet(userID:str, name:str) -> dict: + walletID = get_wallet_UUID(userID, name) + if not walletID: + return { + 'error': 'Wallet not found' + } + + return requests.get(url(True)+'wallet/'+walletID).json() + +def get_address(userID:str, name:str) -> dict: + walletID = get_wallet_UUID(userID, name) + if not walletID: + return { + 'error': 'Wallet not found' + } + + request = requests.get(url(True)+'wallet/'+walletID+'/account/default').json() + return { + 'address': request['receiveAddress'] + } + +def get_balance(userID:str, name:str) -> dict: + walletID = get_wallet_UUID(userID, name) + if not walletID: + return { + 'error': 'Wallet not found' + } + + balance = requests.get(url(True)+'wallet/'+walletID+'/balance?account=default').json() + + domains_value = get_domains_value(userID, name) + + # Convert to human readable + return_json = { + 'available': balance['confirmed']/10**6, + 'available_small': balance['confirmed'], + 'locked': (balance['lockedConfirmed']-domains_value)/10**6, + 'locked_small': balance['lockedConfirmed']-domains_value, + } + + return return_json + + +@cache +def get_domains(userID:str, name:str) -> dict: + walletID = get_wallet_UUID(userID, name) + if not walletID: + return { + 'error': 'Wallet not found' + } + + request = requests.get(url(True)+'wallet/'+walletID+'/name?own=true').json() + + names = [] + names_value = 0 + for name in request: + names_value += name['value'] + names.append({ + 'name': name['name'], + 'state': name['state'], + 'highest': name['highest']/10**6, + 'value': name['value']/10**6, + 'height': name['height'], + 'stats': name['stats'] + }) + + # Save name value to accounts + with open('accounts.json', 'r') as f: + accounts = json.load(f) + + + for account in accounts: + if account == userID: + wallets = accounts[account]['wallets'] + for wallet in wallets: + if wallet['walletID'] == walletID: + wallet['names_value'] = names_value + break + + with open('accounts.json', 'w') as f: + json.dump(accounts, f, indent=4) + + return names + + +def get_domains_value(userID:str, name:str) -> int: + walletID = get_wallet_UUID(userID, name) + if not walletID: + return { + 'error': 'Wallet not found' + } + + with open('accounts.json', 'r') as f: + accounts = json.load(f) + + for account in accounts: + if account == userID: + wallets = accounts[account]['wallets'] + for wallet in wallets: + if wallet['walletID'] == walletID: + if 'names_value' in wallet: + return wallet['names_value'] + + + names = get_domains(userID, name) + names_value = 0 + for name in names: + names_value += name['value'] + + return names_value + + +def send_to_address(userID:str, name:str, address:str, amount:str) -> dict: + walletID = get_wallet_UUID(userID, name) + if not walletID: + return { + 'error': 'Wallet not found' + } + + # Verify amount + amount = float(amount) + if amount <= 0: + return { + 'error': 'Amount must be greater than 0' + } + + balance = get_balance(userID, name) + if balance['available'] < amount: + return { + 'error': 'Insufficient funds' + } + + + data = { + 'outputs': [ + { + 'address': address, + 'value': hns.to_small(amount) + } + ], + 'sign': False + } + + response = requests.post(url(True)+'wallet/'+walletID+'/create', json=data) + if response.status_code != 200: + print(response.text) + return { + 'error': 'Error sending' + } + + + return response.json() + + + +# region Auctions + +@cache +def get_auctions(userID:str, name:str) -> dict: + walletID = get_wallet_UUID(userID, name) + if not walletID: + return { + 'error': 'Wallet not found' + } + + request = requests.get(url(True)+'wallet/'+walletID+'/auction') + return request.json() + + +# endregion \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..3fc71b3 --- /dev/null +++ b/main.py @@ -0,0 +1,101 @@ +from flask import Flask, request, jsonify +import requests +import json +import hsd +import hns + + +app = Flask(__name__) + + +@app.route("/status", methods=["GET"]) +def status(): + status_json = {"status": "ok", "hsd": hsd.get_status()} + return jsonify(status_json) + + +@app.route("/rescan", methods=["POST"]) +def rescan(): + # Only allow rescan if app is in debug mode + if app.debug == False: + return jsonify({"error": "Cannot rescan manually in production mode"}) + + return jsonify(hsd.rescan()) + + +@app.route("/account", methods=["POST"]) +def account(): + account_json = hsd.create_account() + return jsonify(account_json) + + +@app.route("/account", methods=["GET"]) +def accounts(): + userID = request.args.get("uuid") + account_json = hsd.get_account(userID) + return jsonify(account_json) + + +@app.route("/wallet", methods=["POST"]) +def create_wallet(): + userID = request.args.get("uuid") + name = request.args.get("name") + xpub = request.args.get("xpub") + wallet_json = hsd.import_wallet(name, userID, xpub) + return jsonify(wallet_json) + + +@app.route("/wallet", methods=["GET"]) +def get_wallet(): + userID = request.args.get("uuid") + name = request.args.get("name") + wallets_json = hsd.get_wallet(userID, name) + return jsonify(wallets_json) + + +@app.route("/wallet/address", methods=["GET"]) +def get_address(): + userID = request.args.get("uuid") + name = request.args.get("name") + address_json = hsd.get_address(userID, name) + return jsonify(address_json) + + +@app.route("/wallet/balance", methods=["GET"]) +def get_balance(): + userID = request.args.get("uuid") + name = request.args.get("name") + balance_json = hsd.get_balance(userID, name) + return jsonify(balance_json) + +@app.route('/wallet/domains', methods=['GET']) +def get_domains(): + userID = request.args.get('uuid') + name = request.args.get('name') + domain_json = hsd.get_domains(userID, name) + return jsonify(domain_json) + +@app.route('/wallet/send', methods=['POST']) +def send(): + userID = request.args.get('uuid') + name = request.args.get('name') + address = request.args.get('address') + amount = request.args.get('amount') + send_json = hsd.send_to_address(userID, name, address, amount) + return jsonify(send_json) + + + + +@app.route('/wallet/auctions', methods=['GET']) +def get_auctions(): + userID = request.args.get('uuid') + name = request.args.get('name') + auction_json = hsd.get_auctions(userID, name) + return jsonify(auction_json) + + + + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=8080, debug=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5a4a2b9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask +python-dotenv \ No newline at end of file