Initial commit

This commit is contained in:
Yaser
2026-08-13 19:50:53 +03:30
commit 38084458fe
879 changed files with 95198 additions and 0 deletions

View File

@@ -0,0 +1,159 @@
class BlockchainService
Error = Class.new(StandardError)
BalanceLoadError = Class.new(StandardError)
attr_reader :blockchain, :whitelisted_smart_contract, :currencies, :adapter
def initialize(blockchain)
@blockchain = blockchain
@currencies = blockchain.currencies.deposit_enabled
@whitelisted_addresses = blockchain.whitelisted_smart_contracts.active
@adapter = Peatio::Blockchain.registry[blockchain.client.to_sym].new
@adapter.configure(server: @blockchain.server,
currencies: @currencies.map(&:to_blockchain_api_settings),
whitelisted_addresses: @whitelisted_addresses)
end
def latest_block_number
@latest_block_number ||= @adapter.latest_block_number
end
def load_balance!(address, currency_id)
@adapter.load_balance_of_address!(address, currency_id)
rescue Peatio::Blockchain::Error => e
report_exception(e)
raise BalanceLoadError
end
def case_sensitive?
@adapter.features[:case_sensitive]
end
def supports_cash_addr_format?
@adapter.features[:cash_addr_format]
end
def fetch_transaction(transaction)
tx = Peatio::Transaction.new(currency_id: transaction.currency_id,
hash: transaction.txid,
to_address: transaction.rid,
amount: transaction.amount)
if @adapter.respond_to?(:fetch_transaction)
@adapter.fetch_transaction(tx)
else
tx
end
end
def process_block(block_number)
block = @adapter.fetch_block!(block_number)
deposits = filter_deposits(block)
withdrawals = filter_withdrawals(block)
# TODO: Process Transactions with `pending` status
accepted_deposits = []
ActiveRecord::Base.transaction do
accepted_deposits = deposits.map(&method(:update_or_create_deposit)).compact
withdrawals.each(&method(:update_withdrawal))
end
accepted_deposits.each(&:process!)
block
end
# Resets current cached state.
def reset!
@latest_block_number = nil
end
def update_height(block_number)
raise Error, "#{blockchain.name} height was reset." if blockchain.height != blockchain.reload.height
# NOTE: We use update_column to not change updated_at timestamp
# because we use it for detecting blockchain configuration changes see Workers::Daemon::Blockchain#run.
blockchain.update_column(:height, block_number) if latest_block_number - block_number >= blockchain.min_confirmations
end
private
def filter_deposits(block)
addresses = PaymentAddress.where(wallet: Wallet.deposit.with_currency(@currencies.codes), address: block.transactions.map(&:to_address)).pluck(:address)
block.select { |transaction| transaction.to_address.in?(addresses) }
end
def filter_withdrawals(block)
# TODO: Process addresses in batch in case of huge number of confirming withdrawals.
withdraw_txids = Withdraws::Coin.confirming.where(currency: @currencies).pluck(:txid)
block.select { |transaction| transaction.hash.in?(withdraw_txids) }
end
def update_or_create_deposit(transaction)
if transaction.amount < Currency.find(transaction.currency_id).min_deposit_amount
# Currently we just skip tiny deposits.
Rails.logger.info do
"Skipped deposit with txid: #{transaction.hash} with amount: #{transaction.hash}"\
" to #{transaction.to_address} in block number #{transaction.block_number}"
end
return
end
# Fetch transaction from a blockchain that has `pending` status.
transaction = adapter.fetch_transaction(transaction) if @adapter.respond_to?(:fetch_transaction) && transaction.status.pending?
return unless transaction.status.success?
address = PaymentAddress.find_by(wallet: Wallet.deposit_wallet(transaction.currency_id), address: transaction.to_address)
return if address.blank?
# Skip deposit tx if there is tx for deposit collection process
# TODO: select only pending transactions
tx_collect = Transaction.where(txid: transaction.hash, reference_type: 'Deposit')
return if tx_collect.present?
if transaction.from_addresses.blank? && adapter.respond_to?(:transaction_sources)
transaction.from_addresses = adapter.transaction_sources(transaction)
end
deposit =
Deposits::Coin.find_or_create_by!(
currency_id: transaction.currency_id,
txid: transaction.hash,
txout: transaction.txout
) do |d|
d.address = transaction.to_address
d.amount = transaction.amount
d.member = address.member
d.from_addresses = transaction.from_addresses
d.block_number = transaction.block_number
end
deposit.update_column(:block_number, transaction.block_number) if deposit.block_number != transaction.block_number
# Manually calculating deposit confirmations, because blockchain height is not updated yet.
if latest_block_number - deposit.block_number >= @blockchain.min_confirmations && deposit.accept!
deposit
else
nil
end
end
def update_withdrawal(transaction)
withdrawal =
Withdraws::Coin.confirming
.find_by(currency_id: transaction.currency_id, txid: transaction.hash)
# Skip non-existing in database withdrawals.
if withdrawal.blank?
Rails.logger.info { "Skipped withdrawal: #{transaction.hash}." }
return
end
withdrawal.update_column(:block_number, transaction.block_number)
# Fetch transaction from a blockchain that has `pending` status.
transaction = adapter.fetch_transaction(transaction) if @adapter.respond_to?(:fetch_transaction) && transaction.status.pending?
# Manually calculating withdrawal confirmations, because blockchain height is not updated yet.
if transaction.status.failed?
withdrawal.fail!
elsif transaction.status.success? && latest_block_number - withdrawal.block_number >= @blockchain.min_confirmations
withdrawal.success!
end
end
end

View File

@@ -0,0 +1,80 @@
# encoding: UTF-8
# frozen_string_literal: true
require 'peatio/influxdb'
class KLineService
POINT_PERIOD_IN_SECONDS = 60
# Point period units are calculated in POINT_PERIOD_IN_SECONDS.
# It means that period with value 5 is equal to 5 minutes (5 * POINT_PERIOD_IN_SECONDS = 300).
AVAILABLE_POINT_PERIODS = [1, 5, 15, 30, 60, 120, 240, 360, 720, 1440, 4320, 10_080].freeze
AVAILABLE_POINT_LIMITS = (1..10_000).freeze
HUMANIZED_POINT_PERIODS = {
1 => '1m', 5 => '5m', 15 => '15m', 30 => '30m', # minutes
60 => '1h', 120 => '2h', 240 => '4h', 360 => '6h', 720 => '12h', # hours
1440 => '1d', 4320 => '3d', # days
10_080 => '1w' # weeks
}.freeze
class << self
def [](market, period)
services[[market, period]] ||= new(market, period)
end
def services
@services ||= {}
end
end
attr_accessor :market_id, :period
def initialize(marked_id, period)
@market_id = marked_id
@period = humanize_period(period)
end
# OHCL - open, high, closing, and low prices.
def get_ohlc(options = {})
options = options.symbolize_keys.tap do |o|
o.delete(:limit) if o[:time_from].present? && o[:time_to].present?
end
time_from = options[:time_from]
time_to = options[:time_to]
offset = calculate_offset(options) if time_from.blank?
q = ["SELECT * FROM candles_#{@period} WHERE market='#{@market_id}'"]
q << "AND time >= #{time_from.to_i * 1_000_000_000}" if time_from.present?
q << "AND time <= #{time_to.to_i * 1_000_000_000}" if time_to.present?
q << "ORDER BY #{options[:order_by]}" if options[:order_by]
q << "LIMIT #{options[:limit]}" if options[:limit]
q << "OFFSET #{offset}" if offset.present? && options[:offset]
Peatio::InfluxDB.client(keyshard: @market_id, epoch: 's').query(q.join(' ')) do |_name, _tags, points|
return points.map do |point|
[point['time'], point['open'], point['high'], point['low'], point['close'], point['volume']]
end
end
end
def calculate_offset(options)
q = ["SELECT COUNT(high) FROM candles_#{@period} WHERE market='#{@market_id}'"]
q << "AND time <= #{options[:time_to].to_i * 1_000_000_000}" if options[:time_to].present?
Peatio::InfluxDB.client(keyshard: @market_id, epoch: 's').query(q.join(' ')) do |_, _, values|
return options[:limit].to_i < values.first['count'] ? values.first['count'] - options[:limit] : 0
end
end
def event_name(period)
"kline-#{humanize_period(period)}"
end
def humanize_period(period)
HUMANIZED_POINT_PERIODS.fetch(period) do
raise StandardError, "Not available period #{period}"
end
end
end

View File

@@ -0,0 +1,44 @@
# encoding: UTF-8
# frozen_string_literal: true
module Services
module HealthChecker
LIVENESS_CHECKS = %i[check_db check_redis check_rabbitmq].freeze
READINESS_CHECKS = %i[check_db].freeze
class << self
def alive?
check! LIVENESS_CHECKS
rescue StandardError => e
report_exception_to_screen(e)
false
end
def ready?
check! READINESS_CHECKS
rescue StandardError => e
report_exception_to_screen(e)
false
end
private
def check!(checks)
checks.all? { |m| send(m) }
end
def check_db
Market.count
Market.connected?
end
def check_redis
Rails.cache.redis.ping == 'PONG'
end
def check_rabbitmq
Bunny.run(AMQP::Config.connect) { |c| c.connected? }
end
end
end
end

View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
require 'peatio/influxdb'
class TickersService
ZERO = '0.0'.to_d
class << self
def [](market)
services[market] ||= new(market)
end
def services
@services ||= {}
end
end
attr_accessor :market_id
def initialize(market)
if market.is_a? Market
@market_id = market.id
else
@market_id = market.to_s
end
end
def ticker
ticker = Trade.market_ticker_from_influx(market_id)
format(ticker)
end
def default_ticker
{ min: ZERO, max: ZERO, last: ZERO, first: ZERO, volume: ZERO, amount: ZERO, vwap: ZERO }
end
def format(ticker)
if ticker.blank?
ticker = default_ticker
last_trade = Trade.public_from_influx(market_id, 1).first
ticker[:last] = last_trade[:price] if last_trade.present?
end
{
at: Time.now.to_i,
avg_price: ticker[:vwap].to_d,
high: ticker[:max].to_d,
last: ticker[:last].to_d,
low: ticker[:min].to_d,
open: ticker[:first].to_d,
price_change_percent: change_ratio(ticker[:first].to_d, ticker[:last].to_d),
volume: ticker[:volume].to_d,
amount: ticker[:amount].to_d
}.transform_values(&:to_s)
end
def change_ratio(open, last)
percent = open.zero? ? 0 : (last - open) / open * 100
# Prepend sign. Show two digits after the decimal point. Append '%'.
"#{'%+.2f' % percent}%"
end
end

View File

@@ -0,0 +1,155 @@
# vandar is payment proxy
module Vandar
# data keys are:
# api_key string required
# amount Integer required
# callback_url String required
# mobile_number String optional
# factorNumber String optional
# description String optional
# valid_card_number String optional
module Deposit
def generate_token(data)
url = 'https://ipg.vandar.io/api/v3/send'
# POST JSON content
data.merge!(api_key: VandarService::API_KEY)
parse Faraday.post(url, data.to_json, 'Content-Type' => 'application/json')
end
# step two
# handle by ui, open payment page by token
# METHOD: get
# URL: https://ipg.vandar.io/v3/{token}
# step three
def transaction(token)
url = 'https://vandar.io/api/ipg/2step/transaction'
data = { api_key: VandarService::API_KEY, token: token }
# POST JSON content
parse Faraday.post(url, data.to_json, 'Content-Type' => 'application/json')
end
# step four
def verify(token)
url = 'https://ipg.vandar.io/api/v3/verify'
data = { api_key: VandarService::API_KEY, token: token }
# POST JSON content
parse Faraday.post(url, data.to_json, 'Content-Type' => 'application/json')
end
end
# vandar withdraw fiat
module Withdraw
def list_withdraw
url = "https://api.vandar.io/v2.1/business/#{VandarService::BUSINESS_NAME}/settlement"
headers = { 'Content-Type' => 'application/json', 'authorization' => "Bearer #{@login_token}" }
Faraday.get(url, nil, headers).body
end
def create_withdraw(data)
# amount: تومان و بزرگتر یا مساوی 5000
url = "https://api.vandar.io/v3/business/#{VandarService::BUSINESS_NAME}/settlement/store"
headers = { 'Content-Type' => 'application/json', 'authorization' => "Bearer #{@login_token}" }
# POST JSON content
data.merge!(track_id: ::SecureRandom.uuid)
self.class.parse Faraday.post(url, data.to_json, headers)
end
def info_withdraw(id)
url = "https://api.vandar.io/v2.1/business/#{VandarService::BUSINESS_NAME}/settlement/#{id}"
headers = { 'Content-Type' => 'application/json', 'authorization' => "Bearer #{@login_token}" }
self.class.parse Faraday.get(url, nil, headers)
end
def delete_withdraw
url = "https://api.vandar.io/v2.1/business/#{VandarService::BUSINESS_NAME}/settlement/{transaction_id}"
headers = { 'Content-Type' => 'application/json', 'authorization' => "Bearer #{@login_token}" }
self.class.parse Faraday.delete(url, nil, headers)
end
end
# for login in Vandar Account
module Login
MOBILE = ENV.fetch('VANDAR_MOBILE') || raise('please set Vandar mobile')
PASSWORD = ENV.fetch('VANDAR_PASSWORD') || raise('please set Vandar password')
def login
return @login_token if @login_token.present?
url = 'https://api.vandar.io/v3/login'
# POST JSON content
data = { mobile: MOBILE, password: PASSWORD }
response = self.class.parse Faraday.post(url, data.to_json, 'Content-Type' => 'application/json')
@login_token = response.dig('access_token') || raise('login in vandar accrued error')
::Rails.cache.write('vandar_login_token', @login_token, expires_in: 345_600)
@login_token
end
end
# related to Vandar transactions
module Transaction
# Name Type Status
# fromDate String optional
# toDate String optional
# statusKind String optional
# status String optional
# channel String optional
# formId String optional
# ref_id String optional
# tracking_code String optional
# per_page String optional
# q String optional
#
#
# status
# 1: نشان دهنده تراکنش با وضعیت موفق
# - 1: نشان دهنده تراکنش با وضعیت ناموفق
# 3 : نشان دهنده تراکنش با وضعیت درحال انجام
# 2 : نشان دهنده تسویه با وضعیت موفق
# -2 : نشان دهنده تسویه با وضعیت درحال انجام
# -3 : نشان دهنده تسویه با وضعیت ناموفق
# -4 : نشان دهنده تسویه با وضعیت لغو شده
# 5 : نشان دهنده تراکنش واریزی از نوع انتقال وجه داخلی
# -5 : نشان دهنده تراکنش خروجی از نوع انتقال وجه داخلی
# 6 : نشان دهنده تراکنش نوع حق اشتراک
def list_transactions(**filters)
url = "https://api.vandar.io/v2/business/#{VandarService::BUSINESS_NAME}/transaction"
headers = { 'Content-Type' => 'application/json', 'authorization' => "Bearer #{@login_token}" }
response = Faraday.get(url, filters, headers)
self.class.parse response
end
end
end
class VandarService
API_KEY = ENV.fetch('VANDAR_API_KEY') || raise('please set Vandar Api_Key')
BUSINESS_NAME = ENV.fetch('VANDAR_BUSINESS_NAME') || 'developers'
SUCCESS_WITHDRAW = [2].freeze
UNSUCCESS_WITHDRAW = [-3, -4].freeze
attr_accessor :login_token
def initialize
@login_token = ::Rails.cache.read('vandar_login_token') || login
end
extend Vandar::Deposit
include Vandar::Login
include Vandar::Withdraw
include Vandar::Transaction
class << self
# # step one
def parse(response)
raise 'response is empty' unless response.present?
raise "unexpected response status #{response.status}" unless response.status == 200
JSON.parse(response.body)
end
end
end

View File

@@ -0,0 +1,202 @@
class WalletService
attr_reader :wallet, :adapter
def initialize(wallet)
@wallet = wallet
@adapter = Peatio::Wallet.registry[wallet.gateway.to_sym].new(wallet.settings.symbolize_keys)
end
def create_address!(uid, pa_details)
@adapter.configure(wallet: @wallet.to_wallet_api_settings,
currency: @wallet.currencies.first.to_blockchain_api_settings)
# uid is member uid
@adapter.create_address!(uid: uid, pa_details: pa_details)
end
def build_withdrawal!(withdrawal)
@adapter.configure(wallet: @wallet.to_wallet_api_settings,
currency: withdrawal.currency.to_blockchain_api_settings)
transaction = Peatio::Transaction.new(to_address: withdrawal.rid,
amount: withdrawal.amount,
currency_id: withdrawal.currency_id,
options: { tid: withdrawal.tid })
transaction = @adapter.create_transaction!(transaction)
save_transaction(transaction.as_json.merge(from_address: @wallet.address), withdrawal) if transaction.present?
transaction
end
def spread_deposit(deposit)
@adapter.configure(wallet: @wallet.to_wallet_api_settings,
currency: deposit.currency.to_blockchain_api_settings)
destination_wallets =
Wallet.active.withdraw.ordered
.joins(:currencies).where(currencies: { id: deposit.currency_id })
.map do |w|
# NOTE: Consider min_collection_amount is defined per wallet.
# For now min_collection_amount is currency config.
{ address: w.address,
balance: w.current_balance(deposit.currency),
# Wallet max_balance will be in the platform currency
max_balance: (w.max_balance / deposit.currency.get_price.to_d).round(deposit.currency.precision, BigDecimal::ROUND_DOWN),
min_collection_amount: deposit.currency.min_collection_amount,
skip_deposit_collection: w.service.skip_deposit_collection? }
end
raise StandardError, "destination wallets don't exist" if destination_wallets.blank?
# Since last wallet is considered to be the most secure we need always
# have it in spread even if we don't know the balance.
# All money which doesn't fit to other wallets will be collected to cold.
# That is why cold wallet balance is considered to be 0 because there is no
destination_wallets.last[:balance] = 0
# Remove all wallets not available current balance
# (except the last one see previous comment).
destination_wallets.reject! { |dw| dw[:balance] == Wallet::NOT_AVAILABLE }
spread_between_wallets(deposit, destination_wallets)
end
# TODO: We don't need deposit_spread anymore.
def collect_deposit!(deposit, deposit_spread)
@adapter.configure(wallet: @wallet.to_wallet_api_settings,
currency: deposit.currency.to_blockchain_api_settings)
pa = deposit.member.payment_address(@wallet.id)
# NOTE: Deposit wallet configuration is tricky because wallet UIR
# is saved on Wallet model but wallet address and secret
# are saved in PaymentAddress.
@adapter.configure(
wallet: @wallet.to_wallet_api_settings
.merge(pa.details.symbolize_keys)
.merge(address: pa.address)
.tap { |s| s.merge!(secret: pa.secret) if pa.secret.present? }
.compact
)
deposit_spread.map do |transaction|
# In #spread_deposit valid transactions saved with pending state
if transaction.status.pending?
transaction = @adapter.create_transaction!(transaction, subtract_fee: true)
save_transaction(transaction.as_json.merge(from_address: deposit.address), deposit) if transaction.present?
end
transaction
end
end
# TODO: We don't need deposit_spread anymore.
def deposit_collection_fees!(deposit, deposit_spread)
@adapter.configure(wallet: @wallet.to_wallet_api_settings,
currency: deposit.currency.to_blockchain_api_settings)
deposit_transaction = Peatio::Transaction.new(hash: deposit.txid,
txout: deposit.txout,
to_address: deposit.address,
block_number: deposit.block_number,
amount: deposit.amount)
transactions = @adapter.prepare_deposit_collection!(deposit_transaction,
# In #spread_deposit valid transactions saved with pending state
deposit_spread.select { |t| t.status.pending? },
deposit.currency.to_blockchain_api_settings)
if transactions.present?
deposit.update(spread: deposit.spread.map { |s| s.merge(options: transactions.first.options) })
transactions.each { |t| save_transaction(t.as_json.merge(from_address: @wallet.address), deposit) }
end
transactions
end
def refund!(refund)
refund_transaction = Peatio::Transaction.new(to_address: refund.address,
amount: refund.deposit.amount)
@adapter.create_transaction!(refund_transaction, subtract_fee: true)
end
def load_balance!(currency)
@adapter.configure(wallet: @wallet.to_wallet_api_settings,
currency: currency)
@adapter.load_balance!
rescue Peatio::Wallet::Error => e
report_exception(e)
BlockchainService.new(wallet.blockchain).load_balance!(@wallet.address, currency.id)
end
def register_webhooks!(url)
@adapter.register_webhooks!(url)
end
def fetch_transfer!(id)
@adapter.fetch_transfer!(id)
end
def trigger_webhook_event(event)
currency = Currency.find(event[:coin])
@adapter.configure(wallet: @wallet.to_wallet_api_settings,
currency: currency.to_blockchain_api_settings)
@adapter.trigger_webhook_event(event)
end
def skip_deposit_collection?
@adapter.features[:skip_deposit_collection]
end
private
# @return [Array<Peatio::Transaction>] result of spread in form of
# transactions array with amount and to_address defined.
def spread_between_wallets(deposit, destination_wallets)
original_amount = deposit.amount
return [] if original_amount < destination_wallets.pluck(:min_collection_amount).min
left_amount = original_amount
spread = destination_wallets.map do |dw|
amount_for_wallet = [dw[:max_balance] - dw[:balance], left_amount].min
# If free amount in current wallet is too small,
# we will not able to collect it.
# Put 0 for this wallet.
amount_for_wallet = 0 if amount_for_wallet < [dw[:min_collection_amount], 0].max
left_amount -= amount_for_wallet
# If amount left is too small we will not able to collect it.
# So we collect everything to current wallet.
if left_amount < dw[:min_collection_amount]
amount_for_wallet += left_amount
left_amount = 0
end
transaction = Peatio::Transaction.new(to_address: dw[:address],
amount: amount_for_wallet.to_d,
currency_id: deposit.currency_id)
# Tx will not be collected to this destination wallet
transaction.status = :skipped if dw[:skip_deposit_collection]
transaction
rescue => e
# If have exception skip wallet.
report_exception(e)
end
if left_amount.positive?
# If deposit doesn't fit to any wallet, collect it to the last one.
# Since the last wallet is considered to be the most secure.
spread.last.amount += left_amount
left_amount = 0
end
# Remove zero and skipped transactions from spread.
spread.filter { |t| t.amount > 0 }.tap do |sp|
unless sp.map(&:amount).sum == original_amount
raise Error, "Deposit spread failed deposit.amount != collection_spread.values.sum"
end
end
end
# Record blockchain transactions in DB
def save_transaction(transaction, reference)
transaction['txid'] = transaction.delete('hash')
Transaction.create!(transaction.merge(reference: reference))
end
end