Software Developement
May 25, 2023

Основы скриптового сибилдинга | stargate-bridger


Содержание:

  1. Введение
    1.1. Предисловие
    1.2. Итоговый результат
    1.3. Как запустить
  2. База
    2.1. Python
    2.2. Blockchain
  3. Основная часть
    3.1. Архитектура сетей
    3.2. Логика
    3.3. Взаимодействие со смарт-контрактами
    3.3.1. Проверка баланса нативного токена
    3.3.2. Проверка баланса ERC-20 токена
    3.3.3. Approve ERC-20 токена
    3.3.4. Stargate swap
    3.4. Реализация сетей
    3.5. AccountThread и состояния
    3.6. Рандомизация
  4. Финал

1. Введение

1.1 Предисловие

Well, hello, friends. В этой статье я разберу базовые аспекты написания скриптов на примере простого бота для моста Stargate. Гайд предназначен для новичков и если вы уже опытный разработчик, который понимает как взаимодействовать с блокчейном, то вряд ли найдете для себя что-то полезное. Для комфортного прочтения желательно иметь хотя бы минимальный опыт программирования, но если он отсутствует - я расскажу с чего начать.

1.2 Итоговый результат

Собственно то, ради чего мы здесь собрались.

Пройдя весь путь, мы получим скрипт на языке Python, который будет иметь следующий функционал:

  • Поддержка всех популярных EVM сетей - Ethereum, Arbitrum, Optimism, Polygon, Fantom, Avalance, BSC
  • Сканирование сетей на наличие стейблкоинов на балансе
  • Бридж через Stargate
  • Полная рандомизация путей и таймингов. Никаких закономерностей
  • Одновременная работа нескольких аккаунтов

Исходный код: https://github.com/cppmyk/layerzero-bridger

1.3 Как запустить

  • Устанавливаем Python 3.9.2 (можно и другую версию, но я не ручаюсь за нее)
  • Переходим в директорию с репозиторием (у вас путь наверняка будет другой):
cd stargate-bridger
  • Инициализируем виртуальное окружение и устанавливаем зависимости:
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
  • Настраиваем config.py (тайминги, поддерживаемые сети)
  • Добавляем приватные ключи в private_keys.txt
  • Запускаем:
python3 main.py

2. База

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

2.1 Python

Python - язык общего назначения, который идеально подходит для решения задач автоматизации благодаря следующим преимуществам:

  • Простота. Код на Python писать быстро и приятно, в нем нет слишком заумных концепций, вследствие чего язык является одним из наиболее дружелюбных к новичкам
  • Большое сообщество. Благодаря этому существует невероятное множество готовых библиотек, которые помогают в решении различных задач

В этой статье я не буду объяснять основы программирования на Python, так как уже существуют материалы, которые это сделают гораздо лучше меня. На мне лежит миссия познакомить вас с ними:

Если с английским все хорошо - настоятельно рекомендую пройти курс от Мичиганского университета:

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

2.2 Blockchain

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

С данной темой, я думаю, проблем не возникнет, так как средний криптан уже понимает как на высоком уровне работает блокчейн, смарт-контракты и для чего нужны ноды, но если вы хотите узнать больше, рекомендую отличный курс от MixBytes (топ контора, которая занимается аудитом смарт-контрактов):


3. Основная часть

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

3.1 Архитектура сетей

Для начала определим базовые классы для взаимодействия с сетями.

Архитектура сетей

Класс Network, базовый для всех сетей, будет включать в себя методы и параметры, присущие этим сетям. Его мы вводим для того, чтобы в будущем расширять функционал для non-EVM сетей (если будет в этом необходимость).

EVMNetwork же наследуется от базового и определяет весь функционал, который нам будет нужен для взаимодействия с EVM сетями. Он будет выступать базовым для конкретных сетей - Ethereum, Arbitrum, Optimism etc.

Рассмотрим методы этого класса в двух словах:

  • approve_token_usage - взаимодействие с ERC-20 контрактом, которое разрешает spender-у тратить amount токенов с нашего кошелька
  • estimate_layerzero_swap_fee - взаимодействие с контрактом StargateRouter, которое считает необходимую комиссию для LayerZero
  • get_balance - получение баланса нативного токена (ETH, MATIC, AVAX etc.)
  • get_current_gas - получение текущего газа сети
  • get_nonce - получение текущего значения nonce параметра кошелька
  • get_token_allowance - взаимодействие с ERC-20 контрактом для получения количества токенов, которое уже разрешено потратить spender-у
  • get_token_balance - взаимодействие с ERC-20 контрактом для получения баланса токенов на адресе
  • make_stargate_swap - взаимодействие с StargateRouter-ом, которое и будет бриджить токены

BalanceHelper - utility класс, который сделает взаимодействие с балансами чуть более удобным.

3.2 Логика

Определим, какого поведения мы хотим добиться для каждого аккаунта. Для этого выведем следующим пункты:

  • Каждый аккаунт работает отдельно, независимо от других со своими таймингами
  • Если в аккаунте случается ошибка вроде нехватки баланса, фейла транзакции и т.п. - он продолжает работу, никак не влияет на другие и со временем возвращается в строй (после пополнения баланса или повторного выполнения транзакции)

Очередность действий, которой следует каждый аккаунт:

  1. Проверка баланса стейблкоинов на всех поддерживаемых сетях
  2. Выбор случайной сети с балансом
  3. Выбор случайной сети назначения (куда будет бриджиться токен)
  4. Бридж токена через Stargate
  5. Переход к пункту 1

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

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

Диаграмма последовательности этих состояний будет выглядеть как-то так:

Диаграмма состояний

Рассмотрим диаграмму классов подробнее:

Диаграмма классов

State - базовый класс, который декларирует метод handle для обработки состояния. От него наследуются произвольные состояния:

  • CheckStablecoinBalanceState - одно из первых состояний, которое отвечает за проверку баланса стейблкоинов на кошельке
  • ChooseDestinationNetworkState - выбор сети назначения, куда будут бриджиться токены
  • ChooseDestinationStablecoinState - выбор случайного стейблкоина, который поддерживается сетью назначения
  • CheckNativeTokenBalanceForGasState - проверка достаточности баланса нативного токена для оплаты комиссий
  • RefuelDecisionState - решение, которое принимается в случае нехватки баланса для оплаты комиссий. В текущей имплементации поддерживается только ручное пополнение, но может быть без особых проблем расширено до вывода с биржи, или refuel-а через какой-либо смарт-контракт
  • WaitForManualRefuelState - ожидание ручного пополнения пользователем
  • StargateSwapState - бридж токена в сеть назначения через Stargate

Также на диаграмме классов можем видеть некий AccountThread. Прежде всего, для понимания, следует объяснить что такое потоки в операционной системе и чем они отличаются от процессов:

  • Поток - это последовательность инструкций или операций, выполняющихся внутри программы или системы. Он представляет собой некоторую независимую единицу работы, которая может быть исполнена параллельно с другими потоками.
  • Процесс - это экземпляр программы, выполняющейся в операционной системе, и может включать в себя один или несколько потоков. Процессы имеют отдельные адресные пространства, ресурсы и контекст выполнения, в то время как потоки могут разделять эти ресурсы и работать в рамках одного процесса.

Грубо говоря, потоки - более легковесная версия процессов. Они обычно используют общие ресурсы и адресное пространство процесса, что позволяет им выполняться более эффективно и экономить системные ресурсы по сравнению с созданием отдельных процессов. Кроме того, переключение между потоками происходит быстрее, чем между процессами, так как оно не требует полного контекстного переключения.

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

3.3 Взаимодействие со смарт-контрактами

Теперь подробно разберем детали реализации. Начнем с темы взаимодействия со смарт-контрактами и рассмотрим, каким образом это можно осуществлять из Python кода.

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

Главная сущность, с которой мы будем работать - Web3.

Web3 предоставляет мощный набор функций для взаимодействия с EVM блокчейнами. Вот некоторые из основных функций, которые можно выполнять с помощью данного класса:

  • Подключение к ноде: Web3 позволяет установить соединение с удаленной или локальной нодой блокчейна
  • Работа с аккаунтами: Возможность создавать новые аккаунты, получать балансы и отправлять токены с одного аккаунта на другой
  • Взаимодействие с контрактами: Web3 позволяет взаимодействовать с существующими смарт-контрактами, вызывая их функции, получая данные и отправляя транзакции
  • Получение информации о блоках и транзакциях: Возможность получать данные о текущем состоянии блокчейна, получать информацию о блоках, транзакциях и других важных сущностях
  • Работа с событиями: Web3 позволяет отслеживать события, происходящие в смарт-контрактах, и реагировать на них

Для того, чтобы инстанцировать этот класс необходимо обернуть RPC в HTTPProvider. Рассмотрим на примере бесплатного Ethereum RPC от Ankr:

rpc = "https://rpc.ankr.com/eth"
w3 = Web3(HTTPProvider(rpc))

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

3.3.1 Проверка баланса нативного токена

Напомню, что все методы определены в классе EVMNetwork и будут рассматриваться с учетом этого. Первым делом взглянем на то, как можно получить баланс нативного токена:

def get_balance(self, address: str) -> int:    
    return self.w3.eth.get_balance(Web3.to_checksum_address(address))

С помощью такого нехитрого метода мы получим целочисленный результат, который и будет балансом кошелька в Wei. Для того, чтобы привести результат к привычному нам виду, следует разделить это значение на 10^18.

eth_balance = ethereum.get_balance("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")
print(eth_balance / 10 ** 18)

3.3.2 Проверка баланса ERC-20 токена

Немного более сложная операция, так как требует взаимодействия с view методом смарт-контракта. View метод не делает никаких записей в storage блокчейна, поэтому его вызов бесплатный извне и не требует никаких транзакций. Для начала, взглянем как можно вызвать этот метод из Etherscan на примере токена USDT, а после этого реализуем то же самое в коде.

Переходим на страницу смарт-контракта USDT и заходим в Contract -> Read Contract . Здесь ищем функцию balanceOf, вписываем желаемый адрес и делаем запрос:

Вызов balanceOf

Как мы видим, ничего сложного. Etherscan в данном случае выступает прослойкой между нами и блокчейном, но под капотом он так же отправляет запрос к ноде и возвращает нам результат.

Чтобы превратить вышеописанное в Python код введем понятие ABI:

ABI (Application Binary Interface) смарт-контракта - это набор спецификаций, описывающих методы и структуру данных, используемых в смарт-контракте. ABI определяет, как взаимодействовать с контрактом, какие функции и события он поддерживает, а также формат и порядок аргументов и возвращаемых значений.

Для работы с ERC-20 контрактом в коде, обязательно нужно предоставить ABI, которое можно найти как на том же Etherscan в разделе Contract, так и на сторонних ресурсах, например тут.

Теперь мы готовы и можем взглянуть на метод для проверки баланса токенов:

def get_token_balance(self, contract_address: str, address: str) -> int:
    contract = self.w3.eth.contract(address=Web3.to_checksum_address(contract_address), abi=ERC20_ABI)    
    return contract.functions.balanceOf(Web3.to_checksum_address(address)).call()

Создав объект типа Contract, остается лишь вызвать тот самый метод balanceOf и получить желаемый результат.

3.3.3 Approve ERC-20 токена

Метод approve дает разрешение адресу spender потратить некий amount ваших токенов. Например, можно разрешить мосту бриджить ваши токены, либо Uniswap-у обменивать их. В отличие от balanceOf, approve изменяет state блокчейна, поэтому для него требуется подписать и отправить транзакцию ноде. Предварительно можно поиграться в Etherscan-е в разделе Contract -> Write Contract. Мы же рассмотрим подробнее как это делается в коде:

def approve_token_usage(self, private_key: str, contract_address: str, spender: str, amount: int) -> bool:
    account = self.w3.eth.account.from_key(private_key)
    contract = self.w3.eth.contract(address=Web3.to_checksum_address(contract_address), abi=ERC20_ABI)
    
    tx = contract.functions.approve(spender, amount).build_transaction({       
      'from': account.address,
      'gas': self._get_approve_gas_limit(),
      'gasPrice': int(self.get_current_gas()),
      'nonce': self.get_nonce(account.address)})
      
    signed_tx = self.w3.eth.account.sign_transaction(tx, private_key)
    tx_hash = self.w3.eth.send_raw_transaction(signed_tx.rawTransaction) 
       
    try:        
        receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash)
    except web3.exceptions.TimeExhausted:
        print('Approve tx waiting time exceeded')
        return False
    return True

Сначала нужно сделать build транзакции с понятными дополнительными параметрами, после чего транзакция подписывается приватным ключом и отправляется в блокчейн. Ожидаем пока ее включат в блок и возвращаем результат выполнения функции.

3.3.4 Stargate swap

Переходим к самому интересному, а именно - к взаимодействию со Stargate-ом. Первое что мы должны сделать - разумеется, заглянуть в документацию. В разделе How to Swap можно видеть всю необходимую для бриджа информацию: какой смарт-контракт нужно использовать, какой метод из этого контракта отвечает за swap, какие параметры он принимает и т.д.

Из этого же раздела, мы понимаем, что перед самим swap-ом, нужно подсчитать необходимую комиссию для LayerZero, которая будет отправлена на контракт в нативной монете сети. Загрузим ABI StargateRouter-а и реализуем функцию подсчета следующим образом:

def estimate_layerzero_swap_fee(self, dst_chain_id: int, dst_address: str) -> int:
    contract = self.w3.eth.contract(
        address=Web3.to_checksum_address(self.stargate_router_address),
        abi=STARGATE_ROUTER_ABI)
          
    quote_data = contract.functions.quoteLayerZeroFee(
        dst_chain_id,  # destination chainId        
        1,  # function type (1 - swap)
        dst_address,  # destination of tokens
        "0x",  # payload, using abi.encode()
        [0,  # extra gas, if calling smart contract
        0,  # amount of dust dropped in destination wallet
        "0x"  # destination wallet for dust
        ]).call()    
    
    return quote_data[0]

Вызов view метода контракта, аналогичный получению баланса ERC-20 токена.

Идентификаторы сетей, пулов и адреса контрактов можно найти в той же документации, никаких секретов здесь нет.

Что же из себя представляет swap? Давайте посмотрим:

def make_stargate_swap(self, private_key: str, dst_chain_id: int, src_pool_id: int, dst_pool_id: int, amount: int, min_received_amount: int) -> bool:
    account = self.w3.eth.account.from_key(private_key)
    contract = self.w3.eth.contract(
        address=Web3.to_checksum_address(self.stargate_router_address),
        abi=STARGATE_ROUTER_ABI)
    
    layerzero_fee = self.estimate_layerzero_swap_fee(dst_chain_id, account.address)
    nonce = self.get_nonce(account.address)
    gas_price = self.get_current_gas()
    
    tx = contract.functions.swap(
        dst_chain_id,  # destination chainId
        src_pool_id,  # source poolId
        dst_pool_id,  # destination poolId
        account.address,  # refund address. extra gas (if any) is returned to this address
        amount,  # quantity to swap
        min_received_amount,  # the min qty you would accept on the destination
        [0,  # extra gas, if calling smart contract
        0,  # amount of dust dropped in destination wallet
        "0x"  # destination wallet for dust
        ],
        account.address,  # the address to send the tokens to on the destination
        "0x",  # "fee" is the native gas to pay for the cross chain message fee
        ).build_transaction({
            'from': account.address,
            'value': layerzero_fee,
            'gas': StargateConstants.SWAP_GAS_LIMIT[self.name],
            'gasPrice': gas_price,
            'nonce': nonce
        })
        
    signed_tx = self.w3.eth.account.sign_transaction(tx, private_key)
    tx_hash = self.w3.eth.send_raw_transaction(signed_tx.rawTransaction)
    print(f'Hash: {tx_hash.hex()}')
        
    try:
        receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash)
    except web3.exceptions.TimeExhausted:
        print('Bridge tx waiting time exceeded')
        return False
    return True

Считаем необходимую комиссию для LayerZero, формируем транзакцию, подписываем, ждем. Все невероятно просто.

3.4 Реализация сетей

Благодаря хорошо продуманной архитектуре, реализация конкретной сети умещается в ~10 строк, остается лишь определить константы:

class Ethereum(EVMNetwork):
    def __init__(self):
        supported_stablecoins = {
            'USDT': Stablecoin('USDT', EthereumConstants.USDT_CONTRACT_ADDRESS, EthereumConstants.USDT_DECIMALS,
                               EthereumConstants.STARGATE_CHAIN_ID, StargateConstants.POOLS['USDT']),
            'USDC': Stablecoin('USDC', EthereumConstants.USDC_CONTRACT_ADDRESS, EthereumConstants.USDC_DECIMALS,
                               EthereumConstants.STARGATE_CHAIN_ID, StargateConstants.POOLS['USDC'])}
        
        super().__init__(EthereumConstants.NAME, EthereumConstants.NATIVE_TOKEN, EthereumConstants.RPC,
            EthereumConstants.STARGATE_CHAIN_ID, EthereumConstants.STARGATE_ROUTER_CONTRACT_ADDRESS,
            supported_stablecoins)

В supported_stablecoins добавляем стейблкоины, которые поддерживает Stargate на данной сети, и которые мы хотим использовать в будущем.

Другие сети добавляются аналогичным образом, думаю не стоит повторяться.

3.5 AccountThread и состояния

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

class AccountThread(threading.Thread):
    def __init__(self, account_id: int, private_key: str):
        super().__init__()
        self.account_id = account_id
        self.account = Account.from_key(private_key)
        self.state = SleepBeforeStart()
        
    def run(self):
        while True:
            try:
                self.state.handle(self)
            except BaseError as ex:
                self.set_state(CheckStablecoinBalanceState())
            except requests.exceptions.HTTPError:
                self.set_state(CheckStablecoinBalanceState())
                time.sleep(TimeRanges.MINUTE)
                
    def set_state(self, state):
        self.state = state

Унаследовав AccountThread от базового класса Thread, остается только определить действие при запуске потока. Тут в игру и входят состояния: устанавливаем SleepBeforeStart первым состоянием и дергаем "магическую ручку" handle, которая обработает каждое отдельное состояние и установит следующее.

# First state
class SleepBeforeStart(State):
    def handle(self, thread):
        sleep_time = random.randint(SleepTimings.AFTER_START_RANGE[0], SleepTimings.AFTER_START_RANGE[1])
        time.sleep(sleep_time)
        thread.set_state(CheckStablecoinBalanceState())
# Last state
class StargateSwapState(State):
    def __init__(self, src_network: EVMNetwork, dst_network: EVMNetwork,
                 src_stablecoin: Stablecoin, dst_stablecoin: Stablecoin):
        self.src_network = src_network
        self.dst_network = dst_network
        self.src_stablecoin = src_stablecoin
        self.dst_stablecoin = dst_stablecoin
    
    def handle(self, thread):
        balance_helper = BalanceHelper(self.src_network, thread.account.address)
        amount = balance_helper.get_stablecoin_balance(self.src_stablecoin)
        
        bridge_helper = BridgeHelper(thread.account, balance_helper, self.src_network, self.dst_network,
                                     self.src_stablecoin, self.dst_stablecoin, amount, STARGATE_SLIPPAGE)
        bridge_helper.make_bridge()
        thread.set_state(CheckStablecoinBalanceState())

Существует еще большое количество состояний, но они все строятся по одному принципу и я не вижу смысла рассматривать каждое отдельно.

3.6 Рандомизация

За счет чего же будет достигаться уникальность аккаунта?

  • Случайный выбор сетей
  • Случайный выбор стейблкоинов
  • Случайная пауза перед началом работы (что также поможет снизить нагрузку на ноды и не попадать под Rate Limit)
  • Случайная пауза перед бриджем

Для настройки временных диапазонов существует класс SleepTimings с config.py.

Это и есть вся логика :)


4. Финал

Надеюсь, что данная статья показалась вам интересной. Буду рад, если кого-то прочтение сподвигнет начать/продолжить изучать программирование, что в наше время является невероятно полезным скиллом. Если какой-то момент показался непонятным - советую заглянуть в исходный код и полазить по нему.

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

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

Возможные улучшения:

  • Auto-refuel с биржи/смарт-контракта
  • Anti-sybil модуль для создания более "живого" кошелька (взаимодействие с децентрализованными протоколами)
  • Добавление других мостов

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

Подписывайтесь на канал cppmyk.inc. Есть вероятность, что я не забью хуй на него и буду периодически туда что-то постить.

PS. Если вы нашли какой-то баг, или код не работает по неизвестной причине - пишите в чат, я постараюсь исправить.