Установка ноды Dash, собственное API и миксер криптовалюты

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0

Введение​

В данной статье будет рассмотрено, как реализовать собственное API для криптовалюты Dash, используя свою ноду для этого. Также будет реализована простая веб-часть для регистрации и авторизации в API-сервисе с хранением данных в базе данных и миксер криптовалюты.

Используемый стек​

  1. API будет реализовано на фреймворке Python под названием FastAPI.
  2. Нода будет установлена в Docker-контейнере.
  3. В качестве базы данных выбран Postgres, который также будет установлен внутри Docker-контейнера.

Почему именно монета Dash?​

У данной монеты есть несколько интересных решений. Она считается такой же анонимной, как и Monero (хотя ничего по-настоящему не анонимно).

Это связано со встроенным на уровне протокола миксером монет CoinJoin. Мастерноды Dash обеспечивают работу миксера. Суть этой миксации, как ни странно, заключается в смешивании монет с монетами других кошельков, тем самым усложняя их отслеживание (данная возможность будет реализована в этой статье). Подробнее об этом можно прочитать в документации:
Также у данной монеты есть уникальная функция — мастерноды. Мастерноды были придуманы именно в Dash. Благодаря им транзакции проходят мгновенно, так как при их создании входы проверяются сразу на множестве мастернод, чтобы убедиться, что они не были уже использованы. Если каждая мастернода проверила и подтвердила транзакцию, мастерноды голосуют, и транзакция сразу же считается проверенной.

В дополнение к этому за хостинг мастернод платят, поэтому множество людей их устанавливают, благодаря чему мастернод достаточно много.

Еще одной особенностью Dash является DAO (Decentralized Autonomous Organization). Владельцы мастернод могут голосовать за решения, связанные с развитием монеты. Таким образом, будущее Dash зависит от сообщества.

И, разумеется, главным плюсом данной монеты для меня является вес ноды — примерно 50 ГБ. Благодаря этому её легко установить на любой дешевый хостинг или даже у себя на ПК.

Установка ноды​

Как было сказано ранее, вес ноды составляет около 50 ГБ. Устанавливать я её буду в Docker. Сама настройка и установка для вас не будет сложной, а вот мне пришлось повозиться, чтобы корректно составить YAML-файл для Docker, Dockerfile и конфиг для DashCore.

Первое, что будет сделано, — это создана папка для ноды, и в ней будут созданы файлы: docker-compose.yml, Dockerfile (без расширения), dash.conf.

docker-compose.yml​

Код: Скопировать в буфер обмена
Код:
version: "3.8"

services:
  dash_node:
    build: .
    container_name: dash_node
    ports:
      - "9998:9998"
      - "9999:9999"
    volumes:
      - dash_data:/root/.dashcore
    restart: unless-stopped

volumes:
  dash_data:
Понять, что в этом файле, несложно. Лишь уточню, что первый порт нужен для работы с RPC, а второй — для работы с P2P-соединением.
build: . указывает, что Dockerfile находится в той же директории, что и YAML-файл.

P.S. RPC — это протокол для выполнения команд удалённо, грубо говоря, API для отправки команд в DashCore.

dockerfile​

Код: Скопировать в буфер обмена
Код:
FROM ubuntu:22.04

# Устанавливаем зависимости
RUN apt-get update && apt-get install -y \
    wget \
    tar \
    dirmngr \
    screen

# Указываем актуальную версию Dash Core
ARG DASH_VERSION=21.1.1
RUN wget https://github.com/dashpay/dash/releases/download/v${DASH_VERSION}/dashcore-${DASH_VERSION}-x86_64-linux-gnu.tar.gz --no-check-certificate \
    && tar -zvxf dashcore-${DASH_VERSION}-x86_64-linux-gnu.tar.gz \
    && mv dashcore-${DASH_VERSION}/bin/* /usr/bin/ \
    && rm -rf dashcore-${DASH_VERSION} dashcore-${DASH_VERSION}-x86_64-linux-gnu.tar.gz

# Создаем директорию конфигурации и добавляем конфиг
RUN mkdir -p /root/.dashcore
COPY dash.conf /root/.dashcore/dash.conf

# Указываем команду для запуска Dash
CMD ["dashd"]
Как видно, в нём указано, что будет использована Ubuntu, необходимые зависимости, сам DashCore прямо с GitHub, а также прописано, откуда брать конфиг для DashCore и куда его копировать внутри контейнера.

Конфиг DashCore.​

Код: Скопировать в буфер обмена
Код:
printtoconsole=1
txindex=1
addressindex=1
rpcallowip=0.0.0.0/0
rpcbind=0.0.0.0
rpcuser=rpcuser
rpcpassword=rpcpassword
Вот именно с этими настройками у меня и возникли небольшие проблемы.
  • printtoconsole=1 позволяет выводить логи в консоль.
  • txindex=1 индексирует все транзакции по их хэшу (это обязательно, иначе нельзя будет выполнять RPC для большинства команд, связанных с транзакциями).
  • addressindex=1 индексирует все кошельки. Это также обязательный параметр для того, чтобы искать и выполнять запросы на конкретные кошельки. Если это не указать на старте, придётся переиндексировать всю ноду, что достаточно долго (именно это мне и пришлось делать).
  • rpcallowip=0.0.0.0/0 указывает, с каких адресов можно подключаться к серверу.
  • rpcbind=0.0.0.0 указывает, на каком адресе будет работать нода.
  • rpcuser и rpcpassword нужны для того, чтобы подключаться по RPC.

В чем была проблема?​

Изначально я ставил адрес 127.0.0.1 и долгое время не понимал, почему у меня не удается отправить запросы, хотя если обращаться через Docker, все работало. Дело в том, что 127.0.0.1 означает, что можно соединяться только с текущего хоста. Если DashCore запускается внутри Docker, то это по умолчанию считается уже другим хостом.

P.S. Чтобы избежать этого, можно передавать --network="host" в Docker; тогда можно использовать 127.0.0.1. А 0.0.0.0 разрешает соединения "снаружи".

С файлами Docker закончено, чтобы запустить ноду, потребуется внутри папки открыть cmd и ввести команду docker-compose up -d, после этого начнется подкачка DashCore и самой ноды.

P.S. Чтобы отключить контейнер, нужно ввести команду docker-compose down.

После установки всего необходимого начнется синхронизация заголовков блоков, а затем и сама подкачка блоков.
1732414066912.png


1732414087887.png



Чтобы узнать, на сколько процентов загрузились все блоки в вашу ноду, нужно ввести команду docker exec -it dash_node dash-cli getblockchaininfo и найти в результате строку verificationprogress. 1.0 будет означать, что все блоки были скачаны, и нода полностью синхронизирована. (В моем случае 1.0 не было, скорее всего, из-за недостаточной скорости интернета, так как новые блоки появлялись быстрее, но это не критично, так как задержка всего в 30 секунд.
1732414111285.png


Установка Postgres​

После загрузки ноды также будет создан Docker-контейнер с Postgres. Перед установкой нужно создать папку и внутри неё создать docker-compose.yaml, указав в нём этот код:
Код: Скопировать в буфер обмена
Код:
version: '3.8'

services:
  db:
    container_name: db
    image: postgres:latest
    restart: always
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: postgres
    ports:
      - '5432:5432'
    volumes:
      - db-data:/var/lib/postgresql/data # Определение тома для PostgreSQL данных
  adminer:
    image: adminer
    ports:
      - "8080:8080"
    environment:
      ADMINER_DESIGN: "hydra"

volumes:
  db-data:
В данном коде указано скачивание самого Postgres из DockerHub — https://hub.docker.com/_/postgres.

Для взаимодействия с Postgres будет использоваться панель управления Adminer. Adminer также будет скачан из DockerHub. Также для него будет установлен нестандартный дизайн панели — кастомный под названием Hydra, так как стандартный интерфейс лично мне резал глаза.
1732414163842.png


1732414178206.png


1732414192572.png


Почему был выбран Adminer?​

Adminer был выбран только из-за того, что эту панель можно использовать не только для Postgres, но и для множества других баз данных.

Почему был выбран Postgres?​

Раньше я пользовался SQLite, но у неё есть как плюс, так и минус: с ней можно было работать только через одно подключение, соответственно, нельзя было подключить одну базу данных к нескольким проектам одновременно. А Postgres можно использовать в множестве проектов, и он работает намного быстрее с большим объёмом данных.

Работа с Python​

С подготовкой необходимых инструментов закончено, теперь нужно подготовить сам Python-проект. Для начала будет создана основная папка проекта, а в ней — папки routers и templates.
В основной папке создадим Python-файл main.py и папку models. Внутри папки routers будут созданы два файла: panel.py и dash_api.py. Внутри папки templates будут созданы HTML-файлы: login.html, profile.html, register.html.
1732414338718.png


1732414350796.png


1732414360118.png


Что будет находится в этих файлах?
  • В main.py будет находиться только инициализация и запуск FastAPI, а также инклюдирование маршрутов (объяснение будет ниже).
  • В dash_api будут прописаны маршруты для работы с API.
  • В panel будут маршруты до веб-страниц.
  • В models будет логика для настройки соединения с базой данных, а также создание таблиц.
  • В папке templates будет веб-часть проекта.

С подготовкой проекта также закончено, и можно приступать к написанию логики, которая будет реализована на FastAPI.

Почему был выбран FastAPI?​

FastAPI был выбран из-за того, что он намного быстрее работает в отличие от того же Flask, так как FastAPI работает в асинхронном режиме и имеет на борту встроенную и полностью настраиваемую документацию для вашего API. То есть будет страница, на которой будут отображены все ваши маршруты и то, как отправлять на них запросы. Также на этой странице можно будет сразу же отправить запрос с кастомными данными, что очень полезно при тестировании. К тому же работа с FastAPI очень схожа с Flask, поэтому переход с Flask на FastAPI пройдет легко.

main.py​

Теперь первым делом в файле main.py будет прописана логика для инициализации и запуска FastAPI.

Python: Скопировать в буфер обмена
Код:
import uvicorn
from starlette.middleware.sessions import SessionMiddleware
from routers import dash_api, panel
from fastapi import FastAPI

app = FastAPI(
   title="Hooli Crypto API",
)

app.add_middleware(SessionMiddleware, secret_key="your-secret-key")

# Запуск приложения
if __name__ == "__main__":
   uvicorn.run("main:app", host="127.0.0.1", port=8000)
Внутри app указан заголовок для страницы со встроенной документацией. Таким же методом, при желании, можно добавить и собственные стили на страницу.

Теперь в этом же файле нужно инклюдировать маршруты, то есть связать маршрутизатор из этого файла с маршрутизаторами из других файлов, к которым в свою очередь подключены маршруты.
Python: Скопировать в буфер обмена
Код:
app.include_router(dash_api.router)
app.include_router(panel.router, include_in_schema=False)
В данном коде инклюдированы файлы dash_api и panel, в которых и будут находиться маршрутизаторы, к которым подключены маршруты (будет объяснено позже).
include_in_schema=False означает, что все маршруты указанного маршрутизатора не будут отображаться в автоматическом API от FastAPI.

Что такое инклюдирование маршрутов?​

Инклюдирование маршрутов — это привязка маршрутов с логикой к основному приложению. Своего рода импорты библиотек в проект. Это нужно для того, чтобы не захламлять основной файл проекта и разделить все маршруты в разные файлы. Если же перенести маршруты в другой файл, то программа их просто не увидит.

Теперь нужно добавить вызов метода add_middleware. Этот метод позволяет приложению работать с сессиями пользователей, например, с какими-либо данными о пользователе, такими как авторизован ли пользователь на сайте или нет. В дальнейшем это будет использовано для того, чтобы на страницу панели мог зайти только авторизовавшийся пользователь.
Python: Скопировать в буфер обмена
app.add_middleware(SessionMiddleware, secret_key="your-secret-key")

models.py​

Теперь рассмотрим логику подключения к базе данных и создание таблицы в файле models.
Python: Скопировать в буфер обмена
Код:
import uuid
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship
import bcrypt

DATABASE_URL = "postgresql://postgres:postgres@localhost/postgres"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
  • В DATABASE_URL указана ссылка на базу данных, в которой содержатся логин, пароль и название базы данных (название базы данных было указано при ее создании через yaml файл при установке Postgres в Docker).
  • engine инициализирует метод create_engine для подключения к базе данных и работы с ней.
  • SessionLocal инициализирует объект сессии для работы с базой данных, умеет обновлять, добавлять и удалять данные.
    • autocommit=False — настройка автоматического применения изменений из сессии в базу данных сразу же.
    • autoflush=False — настройка автоматического применения изменений данных из сессии в базу данных перед выполнением новых действий.
    • bind=engine — привязка сессий к конкретному engine, который управляет подключением к базе данных.
  • Base создает базовый класс, от которого будут наследоваться все таблицы (ООП).

Далее нужно написать функцию для получения сессии для базы данных. Эта функция будет в дальнейшем вызываться в маршрутах, чтобы функции взаимодействовали с базой данных.

P.S. Взаимодействие с базой данных и ее таблицами, используя именно сессию, а не прямое подключение к базе данных, считается стандартным и правильным подходом при использовании SQLAlchemy.
Python: Скопировать в буфер обмена
Код:
def get_db():
   db = SessionLocal()
   try:
       yield db
   finally:
       db.close()
  • При вызове функции возвращается сессия.
  • yield нужен для того, чтобы функция, которая вызвала get_db, остановилась, пока get_db не завершит свою работу. Приставка yield необходима, потому что функция, которая будет вызывать, асинхронна, а значит, по умолчанию не будет ждать ответа.
  • Также у yield есть свойство сохранять состояние и не завершать функцию, и при следующем вызове продолжать работу с того же места. Но эта особенность аннулируется, так как в конце прописано db.close(), и сессия в любом случае будет закрыта.

Теперь рассмотрим модель таблицы, в которую будут записываться пользователи.
Python: Скопировать в буфер обмена
Код:
class User(Base):
   # Название таблицы
   __tablename__ = 'users'
   # Столбцы
   id = Column(Integer, primary_key=True, index=True)
   username = Column(String, unique=True, index=True)
   password_hash = Column(String)
   unique_id = Column(String, unique=True, index=True, default=str(uuid.uuid4()))  # Поле для уникального ID

   # Связь с таблицей кошельков
   wallets = relationship("DWallet", back_populates="user", cascade="all, delete-orphan")

   # Функции для расшифровки пароля из хэша и шифровки пароля в хэш
   def set_password(self, password: str):
       self.password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')

   def check_password(self, password: str):
       return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8'))
  • __tablename__ = 'users' указывать не обязательно. Если его не указать, то таблица автоматически создается с названием класса в нижнем регистре.
  • Функции set_password и check_password отвечают за шифрование и расшифровку паролей в хэш.
    • При вызове set_password из функции регистрации с передачей пароля, пароль начнет шифроваться и запишется в память как password_hash.
    • В функции регистрации после этого данные запишутся в таблицу и сохранятся (будет показано позже).
  • Связь с таблицей (которой пока что нет) будет нужна в дальнейшем.

Теперь рассмотрим вторую таблицу, которая будет нужна для будущей возможности генерировать кошельки (именно кошельки, а не просто адреса). То есть все сгенерированные кошельки будут привязываться к пользователю с конкретным id.
Python: Скопировать в буфер обмена
Код:
class DWallet(Base):
   __tablename__ = 'dwallets'
   id = Column(Integer, primary_key=True, index=True)
   wallet_name = Column(String, unique=True, index=True)  # Название кошелька
   user_unique_id = Column(String, ForeignKey('users.unique_id'), nullable=False)  # Внешний ключ на таблицу пользователей

   # Связь с таблицей пользователей
   user = relationship("User", back_populates="wallets")

Далее нужно просто вызвать метод create_all для создания всех таблиц в конкретной базе данных (параметр bind=engine определяет, в какой базе данных создавать таблицы).
Python: Скопировать в буфер обмена
Base.metadata.create_all(bind=engine)

panel.py​

Теперь рассмотрим файл с маршрутами и логикой для веб-части с авторизацией, регистрацией и т.д. И первое, что нужно сделать — это связать основной файл проекта (main.py) с маршрутами из текущего файла (panel.py), то есть сейчас как раз и будет прописана логика инклюдирования.
P.S. Как помним, в основном файле была прописана данная строка:
Python: Скопировать в буфер обмена
app.include_router(panel.router, include_in_schema=False)

Теперь в текущем файле, вызываемом в показанной выше строке, также нужно прописать маршрутизатор.
Python: Скопировать в буфер обмена
Код:
from fastapi import HTTPException, Depends, Form, Request, APIRouter
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from fastapi.templating import Jinja2Templates
from models import User, get_db
import uuid

router = APIRouter()  # Маршрутизатор

Далее нужно создать объект для работы с Jinja2 через FastAPI. Это потребуется для того, чтобы передавать данные с бекенда на фронтенд.
Python: Скопировать в буфер обмена
templates = Jinja2Templates(directory="templates")

Регистрация​

Теперь можно прописать первый маршрут, который будет указывать на веб-страницу регистрации, и логика, соответственно, будет прописана для регистрации.

Краткое описание:​

  1. Настроим маршрут для страницы, декоратор используется api_route. Данный декоратор позволяет работать с запросами всех видов, но все же были указаны только нужные, дабы не вызывать в случае чего лишние ошибки и не оставлять возможные дырки в проекте.
  2. Внутри async def register указываются параметры, например, данные из формы логина и пароля с веб-страницы в виде строки.
  3. Если метод запроса POST, то в таком случае происходит получение данных из формы.
    1. Проверяется логин из формы с логином из таблицы в модели (классе) User.
    2. Если логин сходится, то возвращается ошибка.
    3. Если же логин не сходится, процесс регистрации продолжается.
    4. Генерируется UUID для пользователя. Так как логина в БД нет, он записывается, а вместе с ним и UUID в сессию базы данных.
    5. Вызывается функция set_password из файла с моделью таблицы для того, чтобы зашифровать пароль в хэш и добавить его в сессию базы данных.
    6. Добавление только что добавленных в сессию данных в саму таблицу.
    7. Сохранение изменений таблицы.
    8. Обновление базы данных.
    9. Далее берется обычный ID пользователя из базы данных и записывается в сессию браузера (это будет нужно в дальнейшем в логике маршрута панели).
    10. Затем переадресация на страницу профиля с припиской ID пользователя в маршруте (на неё нельзя будет зайти без аккаунта, это будет предусмотрено в маршруте страницы в дальнейшем).
  4. Если запрос не POST, значит, он GET. В таком случае происходит просто переадресация на страницу регистрации.

Python: Скопировать в буфер обмена
Код:
@router.api_route("/register", methods=["GET", "POST"])
async def register(request: Request, username: str = Form(None), password: str = Form(None), db: Session = Depends(get_db)):
   if request.method == "POST":
       # Проверяет логин из формы с логином из таблицы в модели (классе) User
       # Если логин сходится, то получает ошибку
       db_user = db.query(User).filter(User.username == username).first()
       if db_user:
           raise HTTPException(status_code=400, detail="Username already registered")

       # Генерируется uuid
       unique_id = str(uuid.uuid4())
       # Добавление логина из формы и сгенерированный uuid в сессию базы данных
       new_user = User(username=username, unique_id=unique_id)
       # Добавление зашифрованного пароля в сессию базы данных
       new_user.set_password(password)
       # Добавление в базу данных
       db.add(new_user)
       # Сохранение в базе данных
       db.commit()
       # Обновление базы данных
       db.refresh(new_user)

       # Сохраняем user_id из таблицы в ключ ['user_id'] в http сессии
       request.session['user_id'] = new_user.id

       return RedirectResponse(url=f"/profile/{new_user.id}", status_code=303)

   return templates.TemplateResponse("register.html", {"request": request})

Авторизация​

Теперь рассмотрим логику авторизации. В начале всё то же самое: тот же декоратор, те же принимаемые данные в виде логина и пароля из формы веб-страницы, а также та же проверка на метод запроса. Но дальнейшая логика с точностью до наоборот.
  1. Проверка данных
    1. Проверяется логин из формы с логином из таблицы в модели (классе) User. Если логин сходится, то получаем все данные пользователя из базы данных.
    2. Проверяется пароль из базы данных с паролем из формы. Перед сравнением пароль шифруется через check_password и уже в виде хэша сравнивается с паролем из БД.
    3. Если что-то из вышеперечисленного неверно — ошибка.
  2. Если все данные верны, то, как и в случае регистрации, сохраняем обычный ID пользователя в сессию браузера, то есть в cookie.
  3. Далее происходит переадресация на страницу профиля с припиской ID пользователя в маршруте.

Python: Скопировать в буфер обмена
Код:
@router.api_route("/login", methods=["GET", "POST"])
async def login(request: Request, username: str = Form(None), password: str = Form(None), db: Session = Depends(get_db)):
   if request.method == "POST":
       db_user = db.query(User).filter(User.username == username).first()

       if not db_user or not db_user.check_password(password):
           raise HTTPException(status_code=401, detail="Invalid credentials")

       request.session['user_id'] = db_user.id
       return RedirectResponse(url=f"/profile/{db_user.id}", status_code=303)

   return templates.TemplateResponse("login.html", {"request": request})

Профиль​

Python: Скопировать в буфер обмена
Код:
@router.get("/profile/{user_id}")
async def profile_page(request: Request, user_id: int, db: Session = Depends(get_db)):
   # Проверка http сессии (cookie): только если id из сессии соответствует user_id из ссылки на маршрут
   #('user_id') это ключ в http сессии который хранит ид пользователя
   if request.session.get('user_id') != user_id:
       return RedirectResponse(url="/login", status_code=303)

   # Проверяет id из ссылки с id из таблицы в модели (классе) User
   # Если id сходится, то получает все данные пользователя из базы данных
   db_user = db.query(User).filter(User.id == user_id).first()
   if not db_user:
       raise HTTPException(status_code=404, detail="User not found")
   # Возвращает html страницу и передает в нее все данные пользователя
   return templates.TemplateResponse("profile.html", {"request": request, "user": db_user})
Данный код ещё проще, чем предыдущий. Обратите внимание на маршрут с указанным в нём user_id. При переходе на страницу будет браться данный user_id, полученный ранее в функции регистрации или авторизации.

Первым делом в функции происходит сравнение ID пользователя из ссылки с ID пользователя из HTTP-сессии. Если они одинаковые, то происходит получение данных пользователя из базы данных, если ID пользователя из ссылки такой же, как в базе данных. Если данные из базы данных получить не удалось, будет выдана ошибка. При успешном получении будет возвращена страница профиля, в которую будут переданы данные пользователя из базы данных.

Выход из аккаунта​

В данном маршруте просто прописана логика очищения сессии HTTP.
Python: Скопировать в буфер обмена
Код:
@router.get("/logout")
async def logout(request: Request):
   # Удаляем http сессию
   request.session.clear()
   return RedirectResponse(url="/login", status_code=303)

Веб интерфейс​

Теперь будет показана реализация самой веб-части с интерфейсом. Стилей в ней никаких нет, лишь только необходимые кнопки и поля. Да и HTML-файлы рассматривать особо смысла не вижу — там слишком простой код, который поймёт даже человек, впервые написавший лендинг.

Регистрация​

Просто форма, отправляемая на маршрут /register методом POST:
HTML: Скопировать в буфер обмена
Код:
<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Регистрация</title>
</head>
<body>
   <h1>Регистрация</h1>
   <form action="/register" method="post">
       <label for="username">Username:</label>
       <input type="text" id="username" name="username" required><br><br>
       <label for="password">Password:</label>
       <input type="password" id="password" name="password" required><br><br>
       <button type="submit">Зарегистрироваться</button>
   </form>
   <br>
   <a href="/login">Уже есть аккаунт? Войти</a>
</body>
</html>

Авторизация​

HTML: Скопировать в буфер обмена
Код:
<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Вход</title>
</head>
<body>
   <h1>Вход</h1>
   <form action="/login" method="post">
       <label for="username">Username:</label>
       <input type="text" id="username" name="username" required>
       <label for="password">Password:</label>
       <input type="password" id="password" name="password" required>
       <button type="submit">Войти</button>
   </form>
   <br>
   <a href="/register">Нет аккаунта? Зарегистрироваться</a>
</body>
</html>

Профиль​

В данном коде есть небольшие отличия: здесь уже отображаются значения из базы данных конкретного пользователя, которые были переданы в функцию profile_page.
HTML: Скопировать в буфер обмена
Код:
<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Профиль пользователя</title>
</head>
<body>
   <h1>Профиль пользователя</h1>
   <p>Username: {{ user.username }}</p>
   <p>UID: {{ user.unique_id }}</p>

   <!-- Ссылка на документацию -->
   <p>
       Документация по использованию API:
       <a href="/docs" target="_blank">Открыть документацию</a>
   </p>

   <!-- Кнопка выхода -->
   <form action="/logout" method="get">
       <button type="submit">Выход</button>
   </form>
</body>
</html>

Внешний вид страниц​

1732414979944.png


1732414995382.png


1732415010558.png


API​

Теперь рассмотрим работу с маршрутами для API, в которых и заключается вся суть проекта. API будет находиться в файле dash_api.py, и первое, что нужно будет сделать, — это подключить маршруты из этого файла в основное приложение FastAPI, аналогично тому, как это было сделано с маршрутами для веб-интерфейса.
Python: Скопировать в буфер обмена
Код:
import secrets
from fastapi import HTTPException, Query, Depends, APIRouter
from sqlalchemy.orm import Session
from models import User, get_db
import time
import requests

router = APIRouter()

Теперь можно приступать к разработке самого API. Первое, что потребуется сделать, — это написать функцию для подключения к ноде по RPC и отправки команд. В дальнейшем эту функцию будут использовать все функции из данного файла (dash_api.py).
Python: Скопировать в буфер обмена
Код:
# Настройки для RPC
URL = "http://localhost:9998/"
LOGIN = "rpcuser"
PASSWORD = "rpcpassword"

def rpc_call(method: str, params: list = None, wallet_name: str = None):
   if params is None:
       params = []

   rpc_url = f"{URL}wallet/{wallet_name}" if wallet_name else URL

   # Тело запроса
   data = {
       "id": str(time.time()),
       "jsonrpc": "1.0",
       "method": method,
       "params": params
   }

   try:
       # Отправка запроса в ноду по RPC
       response = requests.post(rpc_url, json=data, auth=(LOGIN, PASSWORD))
       # Возвращает ответ функции которая вызвала rpc_call
       return response.json()
   except requests.RequestException as e:
       return {"error": str(e)}
Сама функция, как видим, достаточно обычная — это просто отправка запросов. Однако считаю нужным упомянуть эту строку:
Python: Скопировать в буфер обмена
rpc_url = f"{URL}wallet/{wallet_name}" if wallet_name else URL
  • Если в функцию передаётся wallet_name=wallet_name, то обычный адрес к RPC-серверу меняется на адрес + /wallet/{wallet_name}.
  • Это нужно для того, чтобы выполнять команды к конкретному кошельку, созданному локально, ведь некоторые команды требуют ввода -rpcwallet=wallet_name в консоль. А /wallet/{wallet_name} как раз заменяет данную команду.

Также при отправке запросов нужно будет указывать UUID зарегистрированного пользователя (именно для этого и была написана логика регистрации с личного кабинета). Поэтому нужно написать ещё одну функцию, которая будет вызываться при любом из будущих запросов API, брать UUID, подключаться к базе данных и затем сверять их.
Python: Скопировать в буфер обмена
Код:
def verify_user_uuid(user_uuid: str, db: Session = Depends(get_db)):
   db_user = db.query(User).filter(User.unique_id == user_uuid).first()
   if not db_user:
       # Если uuid не найден, то выводить ошибку
       raise HTTPException(status_code=403, detail="Invalid or unauthorized UUID.")
   return db_user

Создание кошельков​

Теперь можно приступать к написанию самого первого API-запроса. Этот запрос будет создавать кошельки и выводить их данные на странице документации, или хотя бы возвращать ответ с данными, если отправляете запросы самостоятельно. В данной функции как раз будет использована таблица для хранения кошельков, которую мы создавали вместе с таблицей пользователей.

Как будет устроено создание кошелька?​

  1. Проверка, чтобы у пользователя не было больше 5 кошельков: если их уже 5, то нужно выдать ошибку.
  2. Генерация случайного названия для кошелька.
  3. Отправка по RPC команды createwallet с сгенерированным названием для кошелька (данная команда не возвращает абсолютно ничего).
  4. Добавление имени сгенерированного кошелька в таблицу.
  5. Поскольку команда создания кошелька ничего не возвращает, придется воспользоваться еще несколькими командами. Первая на очереди — это команда getnewaddress, которая возвращает адрес.
  6. Затем следует команда для получения приватного ключа.
  7. После приватного ключа последует команда для получения публичного ключа.
  8. Далее идет return, чтобы вернуть все полученные данные.
Теперь рассмотрим функцию поближе.
Python: Скопировать в буфер обмена
Код:
@router.get("/api/v1/create_wallet", tags=["DASH"])  # Параметр tags=["DASH"] позволяет выносить этот блок в отдельный раздел в документации
async def create_wallet(user_uuid: str = Query(..., description="UUID"), db: Session = Depends(get_db)):
   # Вызов функции проверки uuid, передавая в нее user_uuid из запроса и сессию
   verify_user_uuid(user_uuid, db)

   # Проверка количества кошельков для данного пользователя
   wallet_count = db.query(DWallet).filter(DWallet.user_unique_id == user_uuid).count()
   if wallet_count >= 5:
       return {"error": "Превышен лимит кошельков. У пользователя не может быть более 5 кошельков."}

   # Генерация случайного имени кошелька
   wallet_name = "dwallet_" + secrets.token_hex(8)  # Генерация случайного имени кошелька

   # Создание нового кошелька через отправку команды RCP в ноду
   method = "createwallet"
   params = [wallet_name]  # Имя кошелька
   rpc_call(method, params)  # Вызов метода для отправки команды RCP в ноду

   # Добавление кошелька в базу данных
   new_wallet = DWallet(wallet_name=wallet_name, user_unique_id=user_uuid)
   db.add(new_wallet)
   db.commit()
   db.refresh(new_wallet)

   # Получение адреса кошелька
   method = "getnewaddress"
   params = [wallet_name]  # Имя кошелька
   # Указываем имя кошелька и параметр wallet_name=wallet_name что бы rcp_call, сделал кастомный запрос именно для этой команды
   new_address = rpc_call(method, params, wallet_name=wallet_name)
   # Парсит ответа от rcp_call и вытаскивает из него значение ключа ["result"]
   address = new_address["result"]

   # Получение приватного ключа
   method = "dumpprivkey"
   params = [address]  # Сгенерированный выше кошелек
   # Указываем имя кошелька и параметр wallet_name=wallet_name что бы rcp_call, сделал кастомный запрос именно для этой команды
   privkey_result = rpc_call(method, params, wallet_name=wallet_name)
   # Парсит ответа от rcp_call и вытаскивает из него значение ключа ["result"]
   privkey = privkey_result["result"]

   # Получение публичного ключа
   method = "getaddressinfo"
   params = [address]  # Сгенерированный выше кошелек
   # Указываем имя кошелька и параметр wallet_name=wallet_name что бы rcp_call, сделал кастомный запрос именно для этой команды
   pubkey_result = rpc_call(method, params, wallet_name=wallet_name)
   # Парсит ответа от rcp_call и вытаскивает из него ключа ["result"] и из него вытаскивает значение ключа ("pubkey")
   pubkey = pubkey_result["result"].get("pubkey")

   # Возвращаем все данные
   return {
       "address": address,
       "private_key": privkey,
       "public_key": pubkey
   }
По комментариям в коде должно стать более ясно, как устроена данная функция, да и все последующие тоже. Хочу упомянуть лишь об этой строке:
Python: Скопировать в буфер обмена
privkey_result = rpc_call(method, params, wallet_name=wallet_name)
В начале написания логики в файле с API был сделан акцент на этой строке:
Python: Скопировать в буфер обмена
rpc_url = f"{URL}wallet/{wallet_name}" if wallet_name else URL
Связано это с тем, что создание кошелька можно сделать только локально, прямо в DashCore. Поэтому все команды для получения данных от этого кошелька должны иметь параметр, указывающий название и путь до кошелька в системе.
Также прошу запомнить эту строку:
Python: Скопировать в буфер обмена
tags=["DASH"]
Когда настанет время показывать интерфейс, я обязательно напомню об этом параметре.

Для чего проверка количество кошельков?​

Проверку я решил добавить лишь для того, чтобы пользователи не спамили созданием кошельков, и в DashCore не валялось куча пустых кошельков.

Проблемы с локальными кошельками​

Из-за того, что кошельки можно создать только локально, вытекает несколько проблем.
  1. Кошельки, соответственно, будут храниться на сервере с нодой, но это не было бы так страшно, если бы не вторая проблема.
  2. Локальные кошельки нельзя удалить, используя RPC-запросы. Это можно сделать только через локальную консоль сервера, или, если нода установлена на Windows, то через проводник. В моем случае нода стоит в Docker-контейнере, и ситуация не лучше.
В данной версии софта не будет ничего придумано по удалению кошельков, так как в данный момент в голову приходят только костыли.

Проверка баланса​

В данный момент создание кошельков останется в таком виде, а поэтому можно приступать к следующей функции. Данная функция будет проверять баланс. В ней ничего необычного, она ничем не отличается от предыдущей, за исключением передаваемого в функцию RPC-метода (getaddressbalance).
Python: Скопировать в буфер обмена
Код:
@router.get("/api/v1/check_balance", tags=["DASH"])
async def check_balance(
   address: str = Query(..., description="Адрес для проверки баланса"),
   user_uuid: str = Query(..., description="UUID"),
   db: Session = Depends(get_db)
):
   # Вызов функции проверки uuid, передавая в нее user_uuid из запроса и сессию
   verify_user_uuid(user_uuid, db)

   # Передача данных в функцию отправки запроса на ноду (rpc_call)
   method = "getaddressbalance" # Команда для получения баланса
   params = [address] # Кошелек чей баланс нужно получить
   # Вызов rcp_call с отправкой в него метода и параметра для вызова RCP команды в ноду
   result = rpc_call(method, params)

   if result.get("error") is not None:
       raise HTTPException(status_code=400, detail=result["error"])

   if not isinstance(result.get("result"), dict):
       raise HTTPException(status_code=400, detail="Неверный формат данных в ответе RPC.")

   return result["result"]  # Возвращает баланс кошелька

Проверка транзакции​

Данная функция также ничем не отличается, за исключением передаваемого метода (команды) и данных. А именно: вместо отправки адреса нужно отправлять хэш транзакции.
Python: Скопировать в буфер обмена
Код:
@router.get("/api/v1/check_transaction", tags=["DASH"])
async def check_transaction(
   tx_hash: str = Query(..., description="Хэш транзакции"),
   user_uuid: str = Query(..., description="UUID"),
   db: Session = Depends(get_db)
):
   # Вызов функции проверки uuid, передавая в нее user_uuid из запроса и сессию
   verify_user_uuid(user_uuid, db)

   # Передача данных в функцию отправки запроса на ноду (rpc_call)
   method = "getrawtransaction" # Команда для получения информации о транзакции по ее хэшу
   params = [tx_hash, True] # Хэш транзакции
   # Вызов rcp_call с отправкой в него метода и параметра для вызова RCP команды в ноду
   result = rpc_call(method, params)

   if result.get("error") is not None:
       raise HTTPException(status_code=400, detail=result["error"])

   if not isinstance(result.get("result"), dict):
       raise HTTPException(status_code=400, detail="Неверный формат данных в ответе RPC.")

   return result["result"]  # Возвращает информацию о транзакции

Проверка всех входов кошелька (не потраченных транзакций)​

Python: Скопировать в буфер обмена
Код:
@router.get("/api/v1/check_utxo", tags=["DASH"])
async def check_utxo(
   addresses: str = Query(..., description="Список адресов через запятую"),
   user_uuid: str = Query(..., description="UUID"),
   db: Session = Depends(get_db)
):
   # Вызов функции проверки uuid, передавая в нее user_uuid из запроса и сессию
   verify_user_uuid(user_uuid, db)

   address_list = addresses.split(",")

   # Передача данных в функцию отправки запроса на ноду (rpc_call)
   method = "getaddressutxos"
   params = [{"addresses": address_list}]  # Объект с адресами
   # Вызов rcp_call с отправкой в него метода и параметра для вызова RCP команды в ноду
   result = rpc_call(method, params)

   if result.get("error") is not None:
       raise HTTPException(status_code=400, detail=result["error"])

   if not isinstance(result.get("result"), list):
       raise HTTPException(status_code=400, detail="Неверный формат данных в ответе RPC.")

   return result["result"] # Возвращает информацию о входах (нетронутые пришедшие на кошелек транзакции)
В данной функции хочу обратить внимание на эту строку:
Python: Скопировать в буфер обмена
address_list = addresses.split(",")
Данная строка нужна тут, так как RPC-команда getaddressutxos позволяет отправлять несколько кошельков для получения выходов со всех одновременно. Глупо было бы это не реализовать, если была такая возможность.

История баланса​

В данной функции также есть возможность отправлять несколько кошельков для получения информации о всех сразу.
Python: Скопировать в буфер обмена
Код:
@router.get("/api/v1/balance_history", tags=["DASH"])
async def balance_history(
   addresses: str = Query(..., description="Список адресов через запятую"),
   user_uuid: str = Query(..., description="UUID"),
   db: Session = Depends(get_db)
):
   # Вызов функции проверки uuid, передавая в нее user_uuid из запроса и сессию
   verify_user_uuid(user_uuid, db)
   address_list = addresses.split(",")

   # Передача данных в функцию отправки запроса на ноду (rpc_call)
   method = "getaddressdeltas"
   params = [{"addresses": address_list}]  # Объект с адресами
   # Вызов rcp_call с отправкой в него метода и параметра для вызова RCP команды в ноду
   result = rpc_call(method, params)

   if result.get("error") is not None:
       raise HTTPException(status_code=400, detail=result["error"])

   if not isinstance(result.get("result"), list):
       raise HTTPException(status_code=400, detail="Неверный формат данных в ответе RPC.")

   return result["result"]

Создание, подтверждение, отправка транзакции.​

Теперь будет рассмотрена логика создания и отправки транзакции. Именно на этом моменте я потратил немало времени, чтобы найти решение некоторых вопросов и в принципе найти нужную информацию, так как документация Dash немного хромает.

Каким образом реализовать API команды?​

Например, одним из вариантов было сделать одну функцию, которая сразу же будет создавать, подписывать и отправлять транзакцию. Но проблема заключалась в том, что для отправки транзакции нужно ее подписать, а для подписания транзакции необходим приватный ключ. Не каждый захочет отправлять в сторонний сервис свой приватный ключ от кошелька, но и не каждый будет бояться это сделать, поэтому была задача угодить всем.

Таким образом, я пришел к решению, что лучше разделить все этапы на разные функции и запросы. Таким образом, любой сможет создать транзакцию и отправить ее, не используя при этом приватный ключ, при условии что пользователь самостоятельно будет подписывать транзакции. Если же пользователь не боится утечки своего кошелька, он сможет воспользоваться подписью транзакции через сервис, благодаря чему не будет тратить лишнее время на разработку своей логики подписания. В общем, довольными должны остаться все.

Как происходит подсчет комиссии?​

Комиссия рассчитывается методом считывания всех байтов в подписанной транзакции. Минимально выставляемая цена за байт — это один сатоши. Обратите внимание, что именно подписанной транзакции, так как после подписи добавляются новые компоненты.

Что находится в подписанной транзакции?​

  1. Входы (нетронутые транзакции на кошельке).
  2. Выходы (как минимум один — это кошелек для вывода с суммой, если выводится весь баланс. Если остается сдача, то добавляется еще один выход с кошельком и суммой для сдачи).

Сложности с авторасчетом комиссии.​

Сложность заключалась в том, что я не знал, сколько весит каждый объект в транзакции, из-за чего грамотно рассчитать комиссию не получалось. Методом проб и ошибок, а также несколько слитых полностью в комиссию Dash, я вычислил формулу для расчета комиссии.

Общий размер комиссии = 10 + (количество входов × 148) + (количество выходов × 34)

Что за +10 байтов к транзакции?​

В транзакции также добавляется еще 10 байтов. Это не лишние или запасные байты, как можно было бы подумать, а версия + locktime. То есть, это означает версию формата транзакции и locktime, который определяет время или номер блока, с которого транзакция станет действительной для включения в блокчейн.

Подробнее о locktime.​

  • Если значение locktime меньше 500,000, оно интерпретируется как номер блока.
  • Если больше — как метка времени (timestamp, Unix-время в секундах).
  • 0 (по умолчанию): Транзакция может быть сразу обработана.

Что находится в входах?​

  1. txid (хэш транзакции).
  2. vout (индекс конкретного выхода в предыдущей транзакции. В одной транзакции может быть несколько выходов (отправляли на несколько кошельков, и один из них — это тот, с которого сейчас выводим), поэтому нужно указать, какой из них используется).
  3. scriptSig (подпись, доказывающая право на владение монетой).
  4. sequence (параметр для блокировки транзакции, например, установить время или количество блоков, после которых можно тратить транзакцию. Если параметр не используется, то ставится 4294967295. Это означает, что транзакцию можно тратить сразу после поступления).

Что находится в выходах?​

  1. value (сумма перевода).
  2. scriptPubKey (скрипт, который определяет условия, при которых средства можно будет потратить в будущем. Например, адрес, проверка публичного ключа и подписи).

Уточнение по виду входов.​

Если проверить входы кошелька через getaddressutxos, вид входов будет отличаться от того, который я только что описал. Дело в том, что это не полный вид входа. Данные, получаемые через getaddressutxos, нужны лишь для составления новых входов новой транзакции. Если хотите увидеть полный формат входов, то нужно использовать команду getrawtransaction для получения полной информации о транзакции.

Создание транзакции​

Итак, с основами разобрались, теперь можно начинать писать функции для работы с транзакциями. Первой функцией будет создание транзакции. Но если делать автоподсчет комиссии, она будет минимальной, соответственно проверка такой транзакции будет дольше. Не всем это подойдет, поэтому было решено сделать две функции: одну с автоподсчетом и одну с ручным указанием комиссии.
Первой будет реализована функция с автоподсчетом.

Как будет устроена данная функция:
  1. Получение всех входов кошелька.
  2. Перебор всех входов и запись их в объект входов будущей транзакции, попутно считывая сумму с каждого из них и записывая в отдельную переменную.
  3. Запись полученных входов и запись выхода с кошельком и суммой перевода.
  4. Расчет комиссии по формуле, показанной ранее.
  5. Проверка, чтобы комиссия была не меньше 227 сатоши (225 — это минимально возможная комиссия, но транзакции с одним входом и выходом весят меньше, поэтому нужно округлять).
  6. Проверка, чтобы итоговая сумма (перевод + комиссия) не была больше, чем общий баланс кошелька (считался при переборе входов).
  7. Проверка, чтобы если после вычета перевода + комиссии из общего баланса оставался остаток, то создавать выход для сдачи.
  8. Вызов функции для отправки команды по RCP с подготовленными данными.

Python: Скопировать в буфер обмена
Код:
# Создание транзакции с авто подсчетом комиссии
@router.get("/api/v1/create_transaction_auto_fee", tags=["DASH"])
async def create_transaction_auto_fee(
       from_address: str = Query(..., description="Адрес отправителя"),
       to_address: str = Query(..., description="Адрес получателя"),
       spend_change_to_address: str = Query(..., description="Адрес для сдачи"),
       amount: int = Query(..., description="Сумма перевода в Satoshi"),
       user_uuid: str = Query(..., description="UUID пользователя"),
       db: Session = Depends(get_db)
):
   # Вызов функции проверки uuid, передавая в нее user_uuid из запроса и сессию
   verify_user_uuid(user_uuid, db)

   # Передача данных в функцию отправки запроса на ноду (rpc_call)
   method = "getaddressutxos"  # Команда для получения всех входов конкретного кошелька
   params = [{"addresses": [from_address]}]  # Кошелек
   # Вызов rcp_call с отправкой в него метода и параметра для вызова RCP команды в ноду
   result = rpc_call(method, params)

   if result.get("error") is not None:
       raise HTTPException(status_code=400, detail=result["error"])

   if not isinstance(result.get("result"), list):
       raise HTTPException(status_code=400, detail="Неверный формат данных в ответе RPC.")

   # Баланс по умолчанию
   total_balance = 0

   # Формируем объект для записи всех входов
   inputs = []

   # Формируем выходы
   outputs = [{to_address: amount / 100000000}]  # В выход записывается кошелек для перевода и деленная сумма в сатоши для получения суммы в Dash

   # Перебираем все элементы из списка result["result"]
   for new_utxo in result["result"]:
       # Берем из каждого элемента ключ ["satoshis"] и его значение добавляем в переменную total_balance что бы потом получить баланс со всех входов
       total_balance += new_utxo["satoshis"]
       # Добавляем входы в список
       inputs.append({
           "txid": new_utxo["txid"],
           "vout": new_utxo["outputIndex"]
       })

   # Расчет комиссии на основе количества входов и выходов
   input_count = len(inputs)
   output_count = len(outputs)
   fee_rate = 10 + (input_count * 148) + (output_count * 34)  # Общий размер транзакции

   # Если комиссия меньше 227, округляем до 227
   if fee_rate < 227:
       fee_rate = 227

   # Если баланс меньше чем сумма отправки, то ошибка
   if total_balance < amount + fee_rate:
       raise HTTPException(status_code=400, detail=f"Недостаточно средств для транзакции. "
                                                   f"Максимальная сумма для перевода: "
                                                   f"{total_balance - fee_rate} Satoshi")

   # Если остаток баланса после вычитания суммы перевода и комиссии больше нуля
   if total_balance - amount - fee_rate > 0 :
       fee_rate += 34
       spend_change = total_balance - amount - fee_rate  # Вычисляем остаток
       # Добавляем на выход кошелек для сдачи и остаток сатоши конвертированный в Dash
       outputs.append({spend_change_to_address: spend_change / 100000000})
   else:
       spend_change = 0

   # Передача данных в функцию отправки запроса на ноду (rpc_call)
   method = "createrawtransaction"  # Команды создания необработанной транзакции
   params = [inputs, outputs] # Входы (не тронутые транзакции на кошельке) и выходы (куда отправлять и сколько денег)
   # Вызов rcp_call с отправкой в него метода и параметра для вызова RCP команды в ноду
   result = rpc_call(method, params)

   if result.get("error") is not None:
       raise HTTPException(status_code=400, detail=result["error"])

   if not isinstance(result.get("result"), str):
       raise HTTPException(status_code=400, detail="Неверный формат данных в ответе RPC.")

   return {
       "raw_transaction": result["result"],
       "total_balance": total_balance,
       "amount": amount,
       "fee_rate": fee_rate,
       "spend_change": spend_change,
       "total_balance_satoshi": total_balance / 100000000,
       "amount_satoshi": amount / 100000000,
       "fee_rate_satoshi": fee_rate / 100000000,
       "spend_change_satoshi": spend_change / 100000000
   }
По комментариям в коде должно быть все понятно, хочу упомянуть лишь эту строку при проверке остатка баланса:
Python: Скопировать в буфер обмена
fee_rate += 34
Это нужно, так как если есть остаток, добавляется лишний выход для сдачи. Поэтому к общей сумме комиссии дописывается еще 34 байта, то есть в нашем случае сатоши, поскольку один выход именно столько и весит. Если проверку на остаток не делать и не добавлять выход на сдачу, то все монеты, которые не вошли в сумму перевода, будут зачислены в комиссию.

Теперь рассмотрим функцию создания транзакции с указанием комиссии вручную.
Python: Скопировать в буфер обмена
Код:
@router.get("/api/v1/create_transaction", tags=["DASH"])
async def create_transaction(
       from_address: str = Query(..., description="Адрес отправителя"),
       to_address: str = Query(..., description="Адрес получателя"),
       spend_change_to_address: str = Query(..., description="Адрес для сдачи"),
       amount: int = Query(..., description="Сумма перевода в Satoshi"),
       user_uuid: str = Query(..., description="UUID пользователя"),
       fee_rate: int = Query(5000, description="Комиссия"),
       db: Session = Depends(get_db)
):
   # Вызов функции проверки uuid, передавая в нее user_uuid из запроса и сессию
   verify_user_uuid(user_uuid, db)

   # Передача данных в функцию отправки запроса на ноду (rpc_call)
   method = "getaddressutxos"  # Команда для получения всех входов конкретного кошелька
   params = [{"addresses": [from_address]}]  # Кошелек
   # Вызов rcp_call с отправкой в него метода и параметра для вызова RCP команды в ноду
   result = rpc_call(method, params)

   if result.get("error") is not None:
       raise HTTPException(status_code=400, detail=result["error"])

   if not isinstance(result.get("result"), list):
       raise HTTPException(status_code=400, detail="Неверный формат данных в ответе RPC.")

   # Баланс по умолчанию
   total_balance = 0

   # Формируем объект для записи всех входов
   inputs = []

   # Формируем выходы
   outputs = [{to_address: amount / 100000000}]  # В выход записывается кошелек для перевода и деленная сумма в сатоши для получения суммы в Dash

   # Перебираем все элементы из списка result["result"]
   for new_utxo in result["result"]:
       # Берем из каждого элемента ключ ["satoshis"] и его значение добавляем в переменную total_balance что бы потом получить баланс со всех входов
       total_balance += new_utxo["satoshis"]
       # Добавляем входы в список
       inputs.append({
           "txid": new_utxo["txid"],
           "vout": new_utxo["outputIndex"]
       })

   # Если комиссия меньше
   if fee_rate < 227:
       fee_rate = 227

   # Если баланс меньше чем сумма отправки + начальная комиссия, то ошибка
   if total_balance < amount + fee_rate :
       raise HTTPException(status_code=400, detail=f"Недостаточно средств для транзакции. "
                                                   f"Максимальная сумма для перевода: "
                                                   f"{total_balance - fee_rate} Satoshi")

   # Если остаток баланса после вычитания суммы перевода и комиссии больше нуля
   if total_balance - amount - fee_rate > 0 :
       spend_change = total_balance - amount - fee_rate  # Вычисляем остаток
       # Добавляем на выход кошелек для сдачи и остаток сатоши конвертированный в Dash
       outputs.append({spend_change_to_address: spend_change / 100000000})
   else:
       spend_change = 0

   # Передача данных в функцию отправки запроса на ноду (rpc_call)
   method = "createrawtransaction"  # Команды создания необработанной транзакции
   params = [inputs, outputs] # Входы (не тронутые транзакции на кошельке) и выходы (куда отправлять и сколько денег)
   # Вызов rcp_call с отправкой в него метода и параметра для вызова RCP команды в ноду
   result = rpc_call(method, params)

   if result.get("error") is not None:
       raise HTTPException(status_code=400, detail=result["error"])

   if not isinstance(result.get("result"), str):
       raise HTTPException(status_code=400, detail="Неверный формат данных в ответе RPC.")

   return {
       "raw_transaction": result["result"],
       "total_balance": total_balance,
       "amount": amount,
       "fee_rate": fee_rate,
       "spend_change": spend_change,
       "total_balance_satoshi": total_balance / 100000000,
       "amount_satoshi": amount / 100000000,
       "fee_rate_satoshi": fee_rate / 100000000,
       "spend_change_satoshi": spend_change / 100000000
   }
В данной функции практически то же самое, за исключением нового поля с указанием комиссии, полного отсутствия автоматического вычисления и также проверки на сдачу. К сумме комиссии не добавляется +35, так как пользователь сам должен был ее рассчитать, и автоматом ничего не должно меняться (исключением стала только проверка на то, чтобы комиссию не указывали меньше 225, данную проверку было решено оставить).

На этом с функцией создания транзакции закончено, и при удачном выполнении будет получена транзакция в виде hex.

Подписание транзакции​

Следующая на очереди — это функция для подписания транзакции. В данной функции ничего не отличается от остальных. Просто отправка данных по RPC и получение уже подписанной транзакции в виде hex. В качестве отправляемых данных будет приватный ключ кошелька и полученная в предыдущей функции транзакция в виде hex. В ответ мы получим снова транзакцию в виде hex, но уже подписанную.
Python: Скопировать в буфер обмена
Код:
@router.get("/api/v1/sign_transaction", tags=["DASH"])
async def sign_transaction(
   raw_transaction: str = Query(..., description="Транзакция в hex формате"),
   user_uuid: str = Query(..., description="UUID"),
   privat_key: str = Query(..., description="Приватный ключ кошелька"),
   db: Session = Depends(get_db)
):
   # Вызов функции проверки uuid, передавая в нее user_uuid из запроса и сессию
   verify_user_uuid(user_uuid, db)

   method = "signrawtransactionwithkey"  # RCP команда для подписания транзакций
   params = [raw_transaction, [privat_key]]  # Передача хэша транзакции и првиатного ключа необходимого для подписания
   # Вызов rcp_call с отправкой в него метода и параметра для вызова RCP команды в ноду
   result = rpc_call(method, params)

   if result.get("error") is not None:
       raise HTTPException(status_code=400, detail=result["error"])

   if not isinstance(result.get("result"), dict):
       raise HTTPException(status_code=400, detail="Неверный формат данных в ответе RPC.")

   return result["result"]["hex"]

Отправка транзакции в сеть​

В функции отправки транзакции тоже ничего сложного, просто работа с RPC-командой и передача в нее подписанной транзакции.
Python: Скопировать в буфер обмена
Код:
@router.get("/api/v1/broadcast_transaction", tags=["DASH"])
async def broadcast_transaction(
   signed_raw_transaction: str = Query(..., description="Подписанная транзакция в hex формате"),
   user_uuid: str = Query(..., description="UUID"),
   db: Session = Depends(get_db)
):
   # Вызов функции проверки uuid, передавая в нее user_uuid из запроса и сессию
   verify_user_uuid(user_uuid, db)

   method = "sendrawtransaction"  # RCP команда для отправки подписанной транзакции в блок
   params = [str(signed_raw_transaction), 0, False, False]  # Передается подписанная транзакция
   result = rpc_call(method, params)
   return result

Также, как бонус, добавлю возможность получения информации о конкретном блоке и информацию о самой ноде (проверка центров синхронизации и т.д.).

Информация о блоке​

Python: Скопировать в буфер обмена
Код:
@router.get("/api/v1/block_info", tags=["DASH"])
async def block_info(
   block_hash: str = Query(..., description="Хэш блока"),
   user_uuid: str = Query(..., description="UUID"),
   db: Session = Depends(get_db)
):
   # Вызов функции проверки uuid, передавая в нее user_uuid из запроса и сессию
   verify_user_uuid(user_uuid, db)

   method = "getblock"  # Команда для получения информации о блоке по его хэшу
   params = [block_hash]
   result = rpc_call(method, params)

   if result.get("error") is not None:
       raise HTTPException(status_code=400, detail=result["error"])

   if not isinstance(result.get("result"), dict):
       raise HTTPException(status_code=400, detail="Неверный формат данных в ответе RPC.")

   return result["result"]

Информация о ноде​

Python: Скопировать в буфер обмена
Код:
@router.get("/api/v1/sync_status", tags=["DASH"])
async def sync_status(
   user_uuid: str = Query(..., description="UUID"),
   db: Session = Depends(get_db)
):
   # Вызов функции проверки uuid, передавая в нее user_uuid из запроса и сессию
   verify_user_uuid(user_uuid, db)

   method = "getblockchaininfo"  # Команда для получения информации о ноде
   result = rpc_call(method)

   if result.get("error") is not None:
       raise HTTPException(status_code=400, detail=result["error"])

   if not isinstance(result.get("result"), dict):
       raise HTTPException(status_code=400, detail="Неверный формат данных в ответе RPC.")

   return result["result"]

Функция миксации​

Перед написанием функции миксации нужно разъяснить несколько технических моментов.

Как работает миксация?​

В кошельке создается множество дочерних адресов, баланс с основного кошелька переводится на кошельки таких же пользователей функцией миксации мелкими транзакциями. Назад вы получаете ту же сумму, только она приходит также от разных пользователей системы CoinJoin и распределяется на ваши дочерние адреса, а не на основной.

Как будет запускаться процесс миксации?​

Миксация возможна только для локального кошелька DashCore, поэтому нужно создать кошелек, затем импортировать в него приватный ключ и только после этого запускать миксацию. Функция для создания кошельков в API уже есть, поэтому нужно написать функцию для импортирования в кошелек адреса, используя приватный ключ.

Импорт приватного ключа в кошелек​

Python: Скопировать в буфер обмена
Код:
@router.get("/api/v1/import_private_key", tags=["DASH WALLET"])
async def import_private_key(
   wallet_name: str = Query(..., description="Название кошелька"),
   private_key: str = Query(..., description="Приватный ключ кошелька"),
   user_uuid: str = Query(..., description="UUID"),
   db: Session = Depends(get_db)
):
   # Проверка UUID пользователя
   verify_user_uuid(user_uuid, db)

   # Проверка, принадлежит ли кошелек указанному пользователю
   wallet = db.query(DWallet).filter(DWallet.wallet_name == wallet_name, DWallet.user_unique_id == user_uuid).first()

   if not wallet:
       return {"error": "Кошелек с указанным именем не найден у пользователя с данным UUID."}

   # Импорт приватного ключа в новый кошелек
   method = "importprivkey"
   params = [private_key, ""]
   result = rpc_call(method, params, wallet_name=wallet_name)

   # Проверка на ошибки
   if result.get("error"):
       raise HTTPException(status_code=400, detail=result["error"])

   # Если результат отсутствует (null), возвращаем подтверждение импорта
   if result.get("result") is None:
       return {"status": "success", "message": f"Приватный ключ успешно импортирован в кошелек {wallet_name}."}

   # Возвращаем оригинальный результат в случае наличия данных
   return {"status": "success", "data": result["result"]}
Данная функция практически аналогична многим уже разобранным функциям. В ней просто отправляется запрос в ноду с приватным ключом и названием кошелька, который находится локально в DashCore. Также была добавлена проверка на то, что кошелек принадлежит пользователю с указанным uuid, чтобы случайный пользователь не мог работать с чужим кошельком.
Теперь можно рассмотреть саму функцию запуска миксера.

Запуск миксера​

В данной функции требуется отправить несколько запросов, а именно:
  • Указать количество монет для миксации
  • Указать количество подходов в миксации

Python: Скопировать в буфер обмена
Код:
@router.get("/api/v1/start_mixing", tags=["DASH COINJOIN"])
async def start_mixing(
   wallet_name: str = Query(..., description="Название кошелька"),
   coinjoin_amount: int = Query(..., description="Лимит монет для микширования"),
   coinjoin_rounds: int = Query(..., description="Количество раундов для микширования"),
   user_uuid: str = Query(..., description="UUID"),
   db: Session = Depends(get_db)
):
   # Проверка UUID пользователя
   verify_user_uuid(user_uuid, db)

   # Проверка, принадлежит ли кошелек указанному пользователю
   wallet = db.query(DWallet).filter(DWallet.wallet_name == wallet_name, DWallet.user_unique_id == user_uuid).first()

   if not wallet:
       return {"error": "Кошелек с указанным именем не найден у пользователя с данным UUID."}

   # Проверка значения coinjoin_amount
   if coinjoin_amount > 100:
       return {"error": "Значение лимита монет для микширования не может быть больше 100."}

   # Проверка значения coinjoin_rounds
   if coinjoin_rounds > 16:
       return {"error": "Количество раундов для микширования не может быть больше 16."}

   # Установка лимита монет для микширования
   method = "setcoinjoinamount"
   params = [coinjoin_amount]
   rpc_call(method, params, wallet_name=wallet_name)

   # Установка количества раундов для микширования
   method = "setcoinjoinrounds"
   params = [coinjoin_rounds]
   rpc_call(method, params, wallet_name=wallet_name)

   # Запуск микшера
   method = "coinjoin"
   params = ["start"]
   result = rpc_call(method, params, wallet_name=wallet_name)

   return {"status": result["result"],
           "wallet_name": wallet_name}

Т.к. миксация чаще всего занимает много времени, её можно остановить. Это не значит, что миксации вовсе не будет, это значит, что монета будет замиксована, просто не через такое огромное количество кошельков. Поэтому рассмотрим функцию остановки миксации.

Остановка миксера​

Python: Скопировать в буфер обмена
Код:
@router.get("/api/v1/stop_mixing", tags=["DASH COINJOIN"])
async def stop_mixing(
   wallet_name: str = Query(..., description="Название кошелька"),
   user_uuid: str = Query(..., description="UUID"),
   db: Session = Depends(get_db)
):
   # Проверка UUID пользователя
   verify_user_uuid(user_uuid, db)

   # Проверка, принадлежит ли кошелек указанному пользователю
   wallet = db.query(DWallet).filter(DWallet.wallet_name == wallet_name, DWallet.user_unique_id == user_uuid).first()

   if not wallet:
       return {"error": "Кошелек с указанным именем не найден у пользователя с данным UUID."}

   # Остановка микшера
   method = "coinjoin"
   params = ["stop"]
   result = rpc_call(method, params, wallet_name=wallet_name)
   print(result)

   return {
       "status": "CoinJoin stopped",
       "wallet_name": wallet_name
   }

После миксации на основном адресе монет не будет, и баланс будет нулевым. Поэтому нужно написать функцию для проверки баланса. У нас уже есть такая функция, но она проверяет баланс по адресу, а нам понадобится делать проверку по кошельку в DashCore, чтобы увидеть баланс со всех дочерних адресов.

Проверка баланса локального кошелька​

Python: Скопировать в буфер обмена
Код:
@router.get("/api/v1/check_balance_wallet", tags=["DASH WALLET"])
async def check_balance_wallet(
   wallet_name: str = Query(..., description="Название кошелька"),
   user_uuid: str = Query(..., description="UUID"),
   db: Session = Depends(get_db)
):
   # Проверка UUID пользователя
   verify_user_uuid(user_uuid, db)

   # Проверка, принадлежит ли кошелек указанному пользователю
   wallet = db.query(DWallet).filter(DWallet.wallet_name == wallet_name, DWallet.user_unique_id == user_uuid).first()

   if not wallet:
       return {"error": "Кошелек с указанным именем не найден у пользователя с данным UUID."}

   # Получение информации о кошельке
   method = "getwalletinfo"
   params = []
   result = rpc_call(method, params, wallet_name=wallet_name)

   # Проверка наличия ключа "result" и "balance"
   if not result or "result" not in result:
       return {"error": "Ошибка получения данных от RPC. Ответ: {}".format(result)}

   balance = result["result"].get("balance")
   if balance is None:
       return {"error": "Баланс не найден в ответе RPC. Ответ: {}".format(result)}

   # Возвращаем баланс
   return {
       "wallet_name": wallet_name,
       "balance": balance
   }

Чтобы вывести монетки, обычная функция создания транзакций не подойдет, т.к. она работает с конкретным адресом. Поэтому нужно написать новую функцию, которая, как и предыдущая, будет работать с локальными кошельками DashCore.

Вывод перемешанных монет​

Python: Скопировать в буфер обмена
Код:
@router.get("/api/v1/send_transaction_wallet", tags=["DASH WALLET"])
async def send_transaction_wallet(
   wallet_name: str = Query(..., description="Название кошелька"),
   to_address: str = Query(..., description="Адрес получателя"),
   amount: float = Query(..., description="Сумма для отправки в dash"),
   user_uuid: str = Query(..., description="UUID"),
   db: Session = Depends(get_db)
):
   # Проверка UUID пользователя
   verify_user_uuid(user_uuid, db)

   # Проверка, принадлежит ли кошелек указанному пользователю
   wallet = db.query(DWallet).filter(DWallet.wallet_name == wallet_name, DWallet.user_unique_id == user_uuid).first()

   if not wallet:
       return {"error": "Кошелек с указанным именем не найден у пользователя с данным UUID."}

   # Отправка средств
   method = "sendtoaddress"
   params = [to_address, amount]
   result = rpc_call(method, params, wallet_name=wallet_name)

   if result.get("error") is not None:
       raise HTTPException(status_code=400, detail=result["error"])

   return result["result"]

Интерфейс​

На этом с софтом полностью закончено, и можно рассмотреть то, что у нас получилось. Сразу напомню о строке:
Python: Скопировать в буфер обмена
tags=["DASH"]
Данная строка разделяет функции на страницы и на несколько категорий. Увидеть это можно на скриншоте ниже:
1732415980487.png


Как выглядит управление миксером:​

1732416061101.png


Заполняете поля названием кошелька, количеством монет для миксации, количеством этапов миксации, uuid пользователя, нажимаете кнопку Execute — и всё, миксация началась.
1732416095133.png


Вводите название кошелька, uuid пользователя, нажимаете кнопку Execute — и миксация монет остановится.

Как выглядит проверка баланса по адресу​

С остальным функционалом всё точно так же: просто заполняете поля, нажимаете кнопку — и всё, получаете результат, например, проверку баланса по адресу.
1732416139194.png


Вывод​

В данной статье было реализовано своё API для работы с Dash, в том числе был реализован миксер монет на кошельке, что я считаю достаточно полезным для читателей форума. В принципе, данное API будет хорошим инструментом для реализации на его основе других проектов, например чекеров. Но и само по себе API можно использовать как готовый инструмент благодаря встроенной в FastAPI интерактивной документации, показанной на скриншотах выше.

Статья в виде документа - https://docs.google.com/document/d/15CFGWGPVa1ZXlqndw8AqAG0Y-agXIUZI4pSxBLvqZRQ/edit?usp=sharing

Проект на GitHub - https://github.com/overlordgamedev/Hooli-Crypto-API

Сделано OverlordGameDev специально для форума XSS.IS
 
Сверху Снизу