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

23
app/models/ability.rb Normal file
View File

@@ -0,0 +1,23 @@
# encoding: UTF-8
# frozen_string_literal: true
# Full list of roles abilities could be found on docs/roles.md
class Ability
class << self
def abilities
@abilities ||= YAML.load_file("#{Rails.root}/config/abilities.yml")
end
def admin_permissions
abilities['admin_permissions']
end
def user_permissions
abilities['user_permissions']
end
def roles
abilities['roles']
end
end
end

155
app/models/account.rb Normal file
View File

@@ -0,0 +1,155 @@
# frozen_string_literal: true
class Account < ApplicationRecord
AccountError = Class.new(StandardError)
belongs_to :currency, required: true
belongs_to :member, required: true
acts_as_eventable prefix: 'account', on: %i[create update]
ZERO = 0.to_d
validates :member_id, uniqueness: { scope: :currency_id }
validates :balance, :locked, numericality: { greater_than_or_equal_to: 0.to_d }
scope :visible, -> { joins(:currency).merge(Currency.where(visible: true)) }
scope :ordered, -> { joins(:currency).order(position: :asc) }
def as_json_for_event_api
{
id: id,
member_id: member_id,
currency_id: currency_id,
balance: balance,
locked: locked,
created_at: created_at&.iso8601,
updated_at: updated_at&.iso8601
}
end
def plus_funds!(amount)
update_columns(attributes_after_plus_funds!(amount))
end
def plus_funds(amount)
with_lock { plus_funds!(amount) }
self
end
def attributes_after_plus_funds!(amount)
if amount <= ZERO
raise AccountError, "Cannot add funds (account id: #{id}, amount: #{amount}, balance: #{balance})."
end
{ balance: balance + amount }
end
def plus_locked_funds!(amount)
update_columns(attributes_after_plus_locked_funds!(amount))
end
def plus_locked_funds(amount)
with_lock { plus_locked_funds!(amount) }
self
end
def attributes_after_plus_locked_funds!(amount)
if amount <= ZERO
raise AccountError, "Cannot add funds (account id: #{id}, amount: #{amount}, locked: #{locked})."
end
{ locked: locked + amount }
end
def sub_funds!(amount)
update_columns(attributes_after_sub_funds!(amount))
end
def sub_funds(amount)
with_lock { sub_funds!(amount) }
self
end
def attributes_after_sub_funds!(amount)
if amount <= ZERO || amount > balance
raise AccountError, "Cannot subtract funds (account id: #{id}, amount: #{amount}, balance: #{balance})."
end
{ balance: balance - amount }
end
def lock_funds!(amount)
update_columns(attributes_after_lock_funds!(amount))
end
def lock_funds(amount)
with_lock { lock_funds!(amount) }
self
end
def attributes_after_lock_funds!(amount)
if amount <= ZERO || amount > balance
raise AccountError, "Cannot lock funds (account id: #{id}, amount: #{amount}, balance: #{balance}, locked: #{locked})."
end
{ balance: balance - amount, locked: locked + amount }
end
def unlock_funds!(amount)
update_columns(attributes_after_unlock_funds!(amount))
end
def unlock_funds(amount)
with_lock { unlock_funds!(amount) }
self
end
def attributes_after_unlock_funds!(amount)
if amount <= ZERO || amount > locked
raise AccountError, "Cannot unlock funds (account id: #{id}, amount: #{amount}, balance: #{balance} locked: #{locked})."
end
{ balance: balance + amount, locked: locked - amount }
end
def unlock_and_sub_funds!(amount)
update_columns(attributes_after_unlock_and_sub_funds!(amount))
end
def unlock_and_sub_funds(amount)
with_lock { unlock_and_sub_funds!(amount) }
self
end
def attributes_after_unlock_and_sub_funds!(amount)
if amount <= ZERO || amount > locked
raise AccountError, "Cannot unlock and sub funds (account id: #{id}, amount: #{amount}, locked: #{locked})."
end
{ locked: locked - amount }
end
def amount
balance + locked
end
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: accounts
#
# id :integer not null, primary key
# member_id :integer not null
# currency_id :string(10) not null
# balance :decimal(32, 16) default(0.0), not null
# locked :decimal(32, 16) default(0.0), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_accounts_on_currency_id_and_member_id (currency_id,member_id) UNIQUE
# index_accounts_on_member_id (member_id)
#

161
app/models/adjustment.rb Normal file
View File

@@ -0,0 +1,161 @@
# encoding: UTF-8
# frozen_string_literal: true
class Adjustment < ApplicationRecord
# == Constants ============================================================
include AASM
include AASM::Locking
CATEGORIES = %w[asset_registration investment minting_token
balance_anomaly misc refund compensation
incentive bank_fees bank_interest minor].freeze
# == Attributes ===========================================================
enum category: CATEGORIES
enum state: { pending: 1, accepted: 2, rejected: 3 }
# == Relationships ========================================================
belongs_to :currency
belongs_to :creator, class_name: :Member, required: true
belongs_to :validator, class_name: :Member
# Define has_one relation with Operations::{Asset,Expense,Liability,Revenue}.
::Operations::Account::TYPES.each do |op_t|
has_one op_t.to_sym,
class_name: "::Operations::#{op_t.to_s.camelize}",
as: :reference
end
# == Validations ==========================================================
validates :validator, presence: { unless: :pending? }
validates :category, inclusion: { in: CATEGORIES }
validates :currency_id, inclusion: { in: ->(_) { Currency.codes } }
validate do
errors.add(:base, 'invalidates accounting equation') unless Operations.validate_accounting_equation(fetch_operations)
end
validate on: :create do
errors.add(:prebuild_operations, 'are invalid') unless prebuild_operations.map(&:valid?).all?(true)
end
# == Extensions ===========================================================
aasm column: :state, enum: true, whiny_transitions: false do
state :pending, initial: true
state :accepted
state :rejected
event :accept do
transitions from: :pending, to: :accepted, after: :assign_validator do
guard do
prebuild_operations.map(&:valid?).all?(true)
end
after do
prebuild_operations.map(&:save!)
Operations.update_legacy_balance(liability)
end
end
end
event :reject do
transitions from: :pending, to: :rejected, after: :assign_validator
end
end
# Custom ransackers.
ransacker :state, formatter: proc { |v| states[v] } do |parent|
parent.table[:state]
end
ransacker :category, formatter: proc { |v| categories[v] } do |parent|
parent.table[:category]
end
# == Scopes ===============================================================
# == Callbacks ============================================================
# == Instance Methods =====================================================
def assign_validator(validator:)
update!(validator: validator)
end
def fetch_operations
if pending? || rejected?
prebuild_operations
elsif accepted?
load_operations
end
end
def load_operations
operations = %i[asset liability revenue expense].map do |op_type|
"Operations::#{op_type.capitalize}".constantize.find_by(reference: self)
end
operations.compact
end
%i[asset liability revenue expense].each do |op_type|
define_method("fetch_#{op_type}") do
fetch_operations.find { |op| op.is_a?("Operations::#{op_type.capitalize}".constantize) }
end
end
def prebuild_operations
account_number_hash = Operations.split_account_number(account_number: receiving_account_number)
currency_id = account_number_hash[:currency_id]
code = account_number_hash[:code]
member = Member.find_by(uid: account_number_hash[:member_uid]) if account_number_hash.key?(:member_uid)
amount > 0 ? credit = amount : debit = -amount
klass = Operations.klass_for(code: code)
params = {
currency_id: currency_id.downcase,
code: asset_account_code,
debit: debit.to_d,
credit: credit.to_d,
reference: self
}
asset = Operations::Asset.new(params)
# For expense we need swap debit and credit values due to:
# asset - liabilities = revenue - expense
params.merge!(credit: debit.to_d, debit: credit.to_d) if klass == Operations::Expense
params.merge!(member_id: member.id) if member.present? && klass.column_names.include?('member_id')
receiving_operation = klass.new(params.merge(code: code))
[asset, receiving_operation]
end
end
# == Schema Information
# Schema version: 20190830082950
#
# Table name: adjustments
#
# id :bigint not null, primary key
# reason :string(255) not null
# description :text(65535) not null
# creator_id :bigint not null
# validator_id :bigint
# amount :decimal(32, 16) not null
# asset_account_code :integer unsigned, not null
# receiving_account_number :string(64) not null
# currency_id :string(255) not null
# category :integer not null
# state :integer not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_adjustments_on_currency_id (currency_id)
# index_adjustments_on_currency_id_and_state (currency_id,state)
#

View File

@@ -0,0 +1,24 @@
class AdminAbility
include CanCan::Ability
def initialize(member)
return if Ability.admin_permissions[member.role].nil?
# Iterate through member permissions
Ability.admin_permissions[member.role].each do |action, rules|
# Iterate through a list of member model access
rules.each do |rule|
# check if rule define attributes
if rule.is_a?(Hash)
model = rule.keys.first
attributes = rule[model].map(&:to_sym)
# example, can :update, Currency, [:visible, :name] (model attributes)
else
model = rule
# example, can :update, Currency
end
can action.to_sym, model == 'all' ? model.to_sym : model.constantize, attributes
end
end
end
end

View File

@@ -0,0 +1,26 @@
# encoding: UTF-8
# frozen_string_literal: true
# We use the next convention for organizing Ruby on Rails Models.
# https://www.zmwolski.com/Organizing-Ruby-on-Rails-Models
class ApplicationRecord < ActiveRecord::Base
# == Constants ============================================================
# == Attributes ===========================================================
# == Extensions ===========================================================
# == Relationships ========================================================
# == Validations ==========================================================
# == Scopes ===============================================================
# == Callbacks ============================================================
# == Class Methods ========================================================
# == Instance Methods =====================================================
self.abstract_class = true
end

216
app/models/beneficiary.rb Normal file
View File

@@ -0,0 +1,216 @@
# encoding: UTF-8
# frozen_string_literal: true
class Beneficiary < ApplicationRecord
# == Constants ============================================================
extend Enumerize
include Vault::EncryptedModel
vault_lazy_decrypt!
acts_as_eventable prefix: 'beneficiary', on: %i[create update]
include AASM
include AASM::Locking
STATES_MAPPING = { pending: 0, active: 1, archived: 2, aml_processing: 3, aml_suspicious: 4 }.freeze
STATES = %i[pending aml_processing aml_suspicious active archived].freeze
STATES_AVAILABLE_FOR_MEMBER = %i[pending active]
PIN_LENGTH = 6
PIN_RANGE = 10**5..10**Beneficiary::PIN_LENGTH
INVALID_ADDRESS_SYMBOLS = /[\<\>\'\,\[\]\}\{\"\)\(\*\&\^\%\$\#\`\~\{\}\@]/.freeze
# == Attributes ===========================================================
vault_attribute :data, serialize: :json, default: {}
# == Extensions ===========================================================
enumerize :state, in: STATES_MAPPING, scope: true
aasm column: :state, enum: :states_mapping, whiny_transitions: false do
state :pending, initial: true
state :active
state :aml_processing
state :aml_suspicious
state :archived
event :activate do
if Peatio::AML.adapter.present?
transitions from: :pending, to: :aml_processing, guard: :valid_pin?
after do
enable! if aml_check!
end
else
transitions from: :pending, to: :active, guard: :valid_pin?
end
end
event :enable do
transitions from: :aml_processing, to: :active
end if Peatio::AML.adapter.present?
event :aml_suspicious do
transitions from: :aml_processing, to: :aml_suspicious
end if Peatio::AML.adapter.present?
event :archive do
transitions from: %i[pending aml_processing aml_suspicious active], to: :archived
end
end
# == Relationships ========================================================
belongs_to :currency, required: true
belongs_to :member, required: true
# == Validations ==========================================================
validates :pin, presence: true, numericality: { only_integer: true }
validates :data, presence: true
# Validates that data contains address field which is required for coin.
validate if: ->(b) { b.currency.present? && b.currency.coin? } do
errors.add(:data, 'address can\'t be blank') if data.blank? || data.symbolize_keys[:address].blank?
end
# Validates address field which is required for coin.
validate if: ->(b) { b.currency.present? && b.currency.coin? } do
errors.add(:data, 'invlalid address') if data.present? && data.symbolize_keys[:address].present? && data.symbolize_keys[:address].match?(INVALID_ADDRESS_SYMBOLS)
end
# Validates that data contains full_name field which is required for fiat.
validate if: ->(b) { b.currency.present? && b.currency.fiat? } do
errors.add(:data, 'full_name can\'t be blank') if data.blank? || data.symbolize_keys[:full_name].blank?
end
# == Scopes ===============================================================
scope :available_to_member, -> { with_state(:pending, :active) }
scope :active_to_member, -> { with_state(:active) }
# == Callbacks ============================================================
before_validation(on: :create) do
# Truncate spaces
data['address'] = data['address'].gsub(/\s+/, '') if data.present? && data['address'].present?
# Generate Beneficiary Pin
self.pin ||= self.class.generate_pin
# Record time when we send event to Event API
self.sent_at = Time.now
end
# == Class Methods ========================================================
class << self
def generate_pin
SecureRandom.rand(Beneficiary::PIN_RANGE)
end
# Method used for passing states mapping to AASM.
def states_mapping
STATES_MAPPING
end
end
# == Instance Methods =====================================================
def as_json_for_event_api
{ user: { uid: member.uid, email: member.email },
currency: currency_id,
name: name,
description: description,
data: data,
pin: pin,
state: state,
sent_at: sent_at.iso8601,
created_at: created_at.iso8601,
updated_at: updated_at.iso8601 }
end
def aml_check!
result = Peatio::AML.check!(rid, currency_id, member.uid)
if result.risk_detected
b.aml_suspicious!
return nil
end
return nil if result.pending
true
end
def valid_pin?(user_pin)
case user_pin
when Integer
return pin == user_pin
when String
return pin == user_pin.to_i
else
return false
end
end
def rid
currency.coin? ? coin_rid : fiat_rid
end
def regenerate_pin!
update(pin: self.class.generate_pin, sent_at: Time.now)
end
def masked_account_number
account_number = data.symbolize_keys[:account_number]
if data.present? && account_number.present?
account_number.sub(/(?<=\A.{2})(.*)(?=.{4}\z)/) { |match| '*' * match.length }
end
end
def masked_data
data.merge(account_number: masked_account_number).compact if data.present?
end
private
def coin_rid
return unless currency.coin?
data.symbolize_keys[:address]
end
def fiat_rid
return unless currency.fiat?
"%s-%s-%08d" % [data.symbolize_keys[:full_name].downcase.split.join('-'), currency_id.downcase, id]
end
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: beneficiaries
#
# id :bigint not null, primary key
# member_id :bigint not null
# currency_id :string(10) not null
# name :string(64) not null
# description :string(255) default("")
# data_encrypted :string(1024)
# pin :integer unsigned, not null
# sent_at :datetime
# state :integer default("pending"), unsigned, not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_beneficiaries_on_currency_id (currency_id)
# index_beneficiaries_on_member_id (member_id)
#

71
app/models/blockchain.rb Normal file
View File

@@ -0,0 +1,71 @@
# encoding: UTF-8
# frozen_string_literal: true
class Blockchain < ApplicationRecord
has_many :currencies, foreign_key: :blockchain_key, primary_key: :key
has_many :wallets, foreign_key: :blockchain_key, primary_key: :key
has_many :whitelisted_smart_contracts, foreign_key: :blockchain_key, primary_key: :key
validates :key, :name, :client, presence: true
validates :key, uniqueness: true
validates :status, inclusion: { in: %w[active disabled] }
validates :height,
:min_confirmations,
numericality: { greater_than_or_equal_to: 1, only_integer: true }
validates :server, url: { allow_blank: true }
validates :client, inclusion: { in: -> (_) { clients.map(&:to_s) } }
before_create { self.key = self.key.strip.downcase }
scope :active, -> { where(status: :active) }
class << self
def clients
Peatio::Blockchain.registry.adapters.keys
end
end
def explorer=(hash)
write_attribute(:explorer_address, hash.fetch('address'))
write_attribute(:explorer_transaction, hash.fetch('transaction'))
end
def status
super&.inquiry
end
def blockchain_api
BlockchainService.new(self)
rescue StandardError
return
end
# The latest block which blockchain worker has processed
def processed_height
height + min_confirmations
end
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: blockchains
#
# id :integer not null, primary key
# key :string(255) not null
# name :string(255)
# client :string(255) not null
# server :string(255)
# height :bigint not null
# explorer_address :string(255)
# explorer_transaction :string(255)
# min_confirmations :integer default(6), not null
# status :string(255) not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_blockchains_on_key (key) UNIQUE
# index_blockchains_on_status (status)
#

11
app/models/bonus.rb Normal file
View File

@@ -0,0 +1,11 @@
class Bonus < ApplicationRecord
belongs_to :trade, class_name: 'Trade', foreign_key: :trade_id, required: true
belongs_to :sender_member, class_name: 'Member', foreign_key: :sender_member_id, required: true
belongs_to :bonus_member, class_name: 'Member', foreign_key: :bonus_member_id, required: true
validates_uniqueness_of :trade, :scope => [:sender_member_id]
enum state: { pending: 0, payed: 1, rejected: 2 }
scope :h24, -> { where('created_at > ?', 24.hours.ago) }
end

View File

@@ -0,0 +1,52 @@
# encoding: UTF-8
# frozen_string_literal: true
module FeeChargeable
extend ActiveSupport::Concern
included do
attr_readonly :amount, :fee
validates :amount, presence: true, numericality: { greater_than: 0.to_d }
validates :fee, presence: true, numericality: { greater_than_or_equal_to: 0.to_d }
if self <= Deposit
before_validation on: :create do
next unless currency
self.fee ||= currency.deposit_fee
self.amount = amount.to_d - fee
end
validates :fee, numericality: { less_than: :amount }, if: -> (record) { record.amount.to_d > 0.to_d }
end
if self <= Withdraw
attr_readonly :sum
before_validation on: :create do
next unless currency
self.sum ||= 0.to_d
self.fee ||= currency.withdraw_fee
self.amount = sum - fee
end
validates :sum,
presence: true,
numericality: { greater_than: 0.to_d },
precision: { less_than_or_eq_to: ->(w) { w.currency.precision } }
validates :amount,
precision: { less_than_or_eq_to: ->(w) { w.currency.precision } }
validate on: :create do
next if !account || [sum, amount, fee].any?(&:blank?)
if sum > account.balance || (amount + fee) > sum
# raise ::Account::AccountError, 'Account balance is insufficient'
errors.add(:id, 'Account balance is insufficient')
end
end
end
end
end

View File

@@ -0,0 +1,23 @@
module ModelCaching
extend ActiveSupport::Concern
def write_cache(action, value, expiration_time)
Rails.cache.write(cache_key_generator(action), value, expires_in: expiration_time.seconds)
end
def read_cache(action)
Rails.cache.read(cache_key_generator(action))
end
def delete_cache(action)
Rails.cache.delete(cache_key_generator(action))
end
private
def cache_key_generator(action)
class_name = self.class.name.to_s
object_id = id.to_s
"#{class_name}_#{object_id}_#{action}"
end
end

View File

@@ -0,0 +1,23 @@
# encoding: UTF-8
# frozen_string_literal: true
class PrecisionValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return unless options.key?(:less_than_or_eq_to) && value.present?
precision = if options[:less_than_or_eq_to].respond_to?(:call)
options[:less_than_or_eq_to].call(record)
else
options[:less_than_or_eq_to]
end
unless value.is_a?(Numeric)
record.errors.add(attribute, 'must be a number')
return
end
unless value.round(precision) == value
record.errors.add(attribute, "precision must be less than or equal to #{precision}")
end
end
end

View File

@@ -0,0 +1,17 @@
# encoding: UTF-8
# frozen_string_literal: true
module TIDIdentifiable
extend ActiveSupport::Concern
included do
validates :tid, presence: true, uniqueness: { case_sensitive: false }
before_validation do
next unless tid.blank?
begin
self.tid = "TID#{SecureRandom.hex(5).upcase}"
end while self.class.where(tid: tid).any?
end
end
end

249
app/models/currency.rb Normal file
View File

@@ -0,0 +1,249 @@
# encoding: UTF-8
# frozen_string_literal: true
class Currency < ApplicationRecord
# == Constants ============================================================
OPTIONS_ATTRIBUTES = %i[erc20_contract_address gas_limit gas_price].freeze
TOP_POSITION = 1
# == Attributes ===========================================================
attr_readonly :id,
:type,
:base_factor
# Code is aliased to id because it's more user-friendly primary key.
# It's preferred to use code where this attributes are equal.
alias_attribute :code, :id
# == Extensions ===========================================================
serialize :options, JSON unless Rails.configuration.database_support_json
include Helpers::ReorderPosition
OPTIONS_ATTRIBUTES.each do |attribute|
define_method attribute do
self.options[attribute.to_s]
end
define_method "#{attribute}=".to_sym do |value|
self.options = options.merge(attribute.to_s => value)
end
end
# == Relationships ========================================================
belongs_to :blockchain, foreign_key: :blockchain_key, primary_key: :key
has_and_belongs_to_many :wallets
has_one :parent, class_name: 'Currency', foreign_key: :id, primary_key: :parent_id
# == Validations ==========================================================
validate on: :create do
if ENV['MAX_CURRENCIES'].present? && Currency.count >= ENV['MAX_CURRENCIES'].to_i
errors.add(:max, 'Currency limit has been reached')
end
end
validates :code, presence: true, uniqueness: { case_sensitive: false }
validates :position,
presence: true,
numericality: { greater_than_or_equal_to: TOP_POSITION, only_integer: true }
validates :parent_id, allow_blank: true,
inclusion: { in: ->(_) { Currency.coins_without_tokens.pluck(:id).map(&:to_s) } },
if: :coin?
validates :blockchain_key,
inclusion: { in: ->(_) { Blockchain.pluck(:key).map(&:to_s) } },
if: :coin?
validates :type, inclusion: { in: ->(_) { Currency.types.map(&:to_s) } }
validates :options, length: { maximum: 1000 }
validates :base_factor, numericality: { greater_than_or_equal_to: 1, only_integer: true }
validates :deposit_fee,
:min_deposit_amount,
:min_collection_amount,
:withdraw_fee,
:min_withdraw_amount,
:withdraw_limit_24h,
:withdraw_limit_72h,
:precision,
numericality: { greater_than_or_equal_to: 0 }
# == Scopes ===============================================================
scope :visible, -> { where(visible: true) }
scope :irt, -> { find('irt')}
scope :deposit_enabled, -> { where(deposit_enabled: true) }
scope :withdrawal_enabled, -> { where(withdrawal_enabled: true) }
scope :ordered, -> { order(position: :asc) }
scope :coins, -> { where(type: :coin) }
scope :fiats, -> { where(type: :fiat) }
# This scope select all coins without parent_id, which means that they are not tokens
scope :coins_without_tokens, -> { coins.where(parent_id: nil) }
# == Callbacks ============================================================
after_initialize :initialize_defaults
after_create do
link_wallets
insert_position(self)
end
before_validation { self.code = code.downcase }
before_validation { self.deposit_fee = 0 unless fiat? }
before_validation { self.blockchain_key = parent.blockchain_key if token? && blockchain_key.blank? }
before_validation(on: :create) { self.position = Currency.count + 1 unless position.present? }
before_validation do
self.erc20_contract_address = erc20_contract_address.try(:downcase) if erc20_contract_address.present?
end
before_update { update_position(self) if position_changed? }
after_commit :wipe_cache
# == Class Methods ========================================================
# NOTE: type column reserved for STI
self.inheritance_column = nil
class << self
def codes(options = {})
pluck(:id).yield_self do |downcase_codes|
case
when options.fetch(:bothcase, false)
downcase_codes + downcase_codes.map(&:upcase)
when options.fetch(:upcase, false)
downcase_codes.map(&:upcase)
else
downcase_codes
end
end
end
def types
%i[fiat coin].freeze
end
end
# == Instance Methods =====================================================
delegate :explorer_transaction, :blockchain_api, :explorer_address, to: :blockchain
types.each { |t| define_method("#{t}?") { type == t.to_s } }
def blockchain
Rails.cache.fetch("#{code}_blockchain", expires_in: 60) { Blockchain.find_by(key: blockchain_key) }
end
def wipe_cache
Rails.cache.delete_matched("currencies*")
end
def initialize_defaults
self.options = {} if options.blank?
end
def link_wallets
if parent_id.present?
# Iterate through active deposit/withdraw wallets
Wallet.active.where.not(kind: :fee).with_currency(parent_id).each do |wallet|
# Link parent currency with wallet
CurrencyWallet.create(currency_id: id, wallet_id: wallet.id)
end
end
end
# Allows to dynamically check value of id/code:
#
# id.btc? # true if code equals to "btc".
# code.eth? # true if code equals to "eth".
def id
super&.inquiry
end
# subunit (or fractional monetary unit) - a monetary unit
# that is valued at a fraction (usually one hundredth)
# of the basic monetary unit
def subunits=(n)
self.base_factor = 10 ** n
end
# This method defines that token currency need to have parent_id and coin type
# We use parent_id for token type to inherit some useful info such as blockchain_key from parent currency
# For coin currency enough to have only coin type
def token?
parent_id.present? && coin?
end
def get_price
if price.blank? || price.zero?
raise "Price for currency #{id} is unknown"
else
price
end
end
def to_blockchain_api_settings
# We pass options are available as top-level hash keys and via options for
# compatibility with Wallet#to_wallet_api_settings.
opt = options.compact.deep_symbolize_keys
opt.deep_symbolize_keys.merge(id: id,
base_factor: base_factor,
options: opt)
end
def dependent_markets
Market.where('base_unit = ? OR quote_unit = ?', id, id)
end
def subunits
Math.log(base_factor, 10).round
end
end
# == Schema Information
# Schema version: 20201207134745
#
# Table name: currencies
#
# id :string(10) not null, primary key
# name :string(255)
# description :text(65535)
# homepage :string(255)
# blockchain_key :string(32)
# parent_id :string(255)
# type :string(30) default("coin"), not null
# deposit_fee :decimal(32, 16) default(0.0), not null
# min_deposit_amount :decimal(32, 16) default(0.0), not null
# min_collection_amount :decimal(32, 16) default(0.0), not null
# withdraw_fee :decimal(32, 16) default(0.0), not null
# min_withdraw_amount :decimal(32, 16) default(0.0), not null
# withdraw_limit_24h :decimal(32, 16) default(0.0), not null
# withdraw_limit_72h :decimal(32, 16) default(0.0), not null
# position :integer not null
# options :json
# visible :boolean default(TRUE), not null
# deposit_enabled :boolean default(TRUE), not null
# withdrawal_enabled :boolean default(TRUE), not null
# base_factor :bigint default(1), not null
# precision :integer default(8), not null
# icon_url :string(255)
# price :decimal(32, 16) default(1.0), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_currencies_on_parent_id (parent_id)
# index_currencies_on_position (position)
# index_currencies_on_visible (visible)
#

View File

@@ -0,0 +1,24 @@
# frozen_string_literal: true
class CurrencyWallet < ApplicationRecord
self.table_name = 'currencies_wallets'
belongs_to :currency
belongs_to :wallet
validates :currency_id, uniqueness: { scope: :wallet_id }
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: currencies_wallets
#
# currency_id :string(255)
# wallet_id :integer
#
# Indexes
#
# index_currencies_wallets_on_currency_id (currency_id)
# index_currencies_wallets_on_currency_id_and_wallet_id (currency_id,wallet_id) UNIQUE
# index_currencies_wallets_on_wallet_id (wallet_id)
#

294
app/models/deposit.rb Normal file
View File

@@ -0,0 +1,294 @@
# encoding: UTF-8
# frozen_string_literal: true
class Deposit < ApplicationRecord
STATES = %i[submitted canceled rejected accepted collected skipped processing fee_processing].freeze
SUCCEED_PROCESSING_STATES = %i[accepted collected skipped processing fee_processing].freeze
serialize :spread, Array
serialize :from_addresses, Array
include AASM
include AASM::Locking
include TIDIdentifiable
include FeeChargeable
extend Enumerize
TRANSFER_TYPES = { fiat: 100, crypto: 200 }.freeze
belongs_to :currency, required: true
belongs_to :member, required: true
acts_as_eventable prefix: 'deposit', on: %i[create update]
validates :tid, presence: true, uniqueness: { case_sensitive: false }
validates :aasm_state, :type, presence: true
validates :completed_at, presence: { if: :completed? }
validates :block_number, allow_blank: true, numericality: { greater_than_or_equal_to: 0, only_integer: true }
validates :amount,
numericality: {
greater_than_or_equal_to:
-> (deposit){ deposit.currency.min_deposit_amount }
}
validate :verify_limits, on: :create
scope :recent, -> { order(id: :desc) }
before_validation { self.completed_at ||= Time.current if completed? }
before_validation { self.transfer_type ||= currency.coin? ? 'crypto' : 'fiat' }
aasm whiny_transitions: false do
state :submitted, initial: true
state :canceled
state :rejected
state :accepted
state :aml_processing
state :aml_suspicious
state :processing
state :skipped
state :collected
state :fee_processing
event(:cancel) { transitions from: :submitted, to: :canceled }
event(:reject) { transitions from: :submitted, to: :rejected }
event :accept do
transitions from: :submitted, to: :accepted
after do
if currency.coin? && Peatio::App.config.deposit_funds_locked
account.plus_locked_funds(amount)
else
account.plus_funds(amount)
end
record_submit_operations!
end
end
event :skip do
transitions from: :processing, to: :skipped
end
event :process do
if Peatio::AML.adapter.present?
transitions from: %i[aml_processing aml_suspicious accepted], to: :aml_processing do
after do
process_collect! if aml_check!
end
end
else
transitions from: %i[accepted skipped], to: :processing do
guard { currency.coin? }
end
end
end
event :fee_process do
transitions from: %i[accepted processing skipped], to: :fee_processing do
guard { currency.coin? }
end
end
event :process_collect do
transitions from: %i[aml_processing aml_suspicious], to: :processing do
guard { currency.coin? }
end
end if Peatio::AML.adapter.present?
event :aml_suspicious do
transitions from: :aml_processing, to: :aml_suspicious
end if Peatio::AML.adapter.present?
event :dispatch do
transitions from: %i[processing fee_processing], to: :collected
after do
if Peatio::App.config.deposit_funds_locked
account.unlock_funds(amount)
record_complete_operations!
end
end
end
event :refund do
transitions from: %i[aml_suspicious skipped], to: :refunding do
guard { currency.coin? }
end
end
end
def aml_check!
from_addresses.each do |address|
result = Peatio::AML.check!(address, currency_id, member.uid)
if result.risk_detected
aml_suspicious!
return nil
end
return nil if result.pending
end
true
end
def verify_limits
limits = DepositLimit.for(kyc_level: member.level, group: member.group, kind: transfer_type)
# If there are no limits in DB or current user withdraw limit
# has 0.0 for 24 hour and 1 month it will skip this checks
return true if limits.limit_24_hour.zero? && limits.limit_1_month.zero?
# Withdraw limits in USD and withdraw sum in currency.
# Convert withdraw sums with price from the currency model.
sum_24_hours, sum_1_month = ::Deposit.sanitize_execute_sum_queries(member_id)
errors.add(:member_id, 'reached 24 hours limitation') if sum_24_hours >= limits.limit_24_hour
errors.add(:member_id, 'reached 1 month limitation') if sum_1_month >= limits.limit_1_month
end
def blockchain_api
currency.blockchain_api
end
def confirmations
return 0 if block_number.blank?
return blockchain.processed_height - block_number if (blockchain.processed_height - block_number) >= 0
'N/A'
rescue StandardError => e
report_exception(e)
'N/A'
end
def spread_to_transactions
spread.map { |s| Peatio::Transaction.new(s) }
end
def spread_between_wallets!
return false if spread.present?
spread = WalletService.new(Wallet.deposit_wallet(currency_id)).spread_deposit(self)
update!(spread: spread.map(&:as_json))
end
def spread
super.map(&:symbolize_keys)
end
def account
member&.get_account(currency)
end
def uid
member&.uid
end
def uid=(uid)
self.member = Member.find_by_uid(uid)
end
def as_json_for_event_api
{ tid: tid,
user: { uid: member.uid, email: member.email },
uid: member.uid,
currency: currency_id,
amount: amount.to_s('F'),
state: aasm_state,
created_at: created_at.iso8601,
updated_at: updated_at.iso8601,
completed_at: completed_at&.iso8601,
blockchain_address: address,
blockchain_txid: txid }
end
def completed?
!submitted?
end
private
# Creates dependant operations for deposit.
def record_submit_operations!
transaction do
# Credit main fiat/crypto Asset account.
Operations::Asset.credit!(
amount: amount + fee,
currency: currency,
reference: self
)
# Credit main fiat/crypto Revenue account.
Operations::Revenue.credit!(
amount: fee,
currency: currency,
reference: self,
member_id: member_id
)
kind = currency.coin? && Peatio::App.config.deposit_funds_locked ? :locked : :main
# Credit locked fiat/crypto Liability account.
Operations::Liability.credit!(
amount: amount,
currency: currency,
reference: self,
member_id: member_id,
kind: kind
)
end
end
# Creates dependant operations for complete deposit.
def record_complete_operations!
transaction do
Operations::Liability.transfer!(
amount: amount,
currency: currency,
reference: self,
from_kind: :locked,
to_kind: :main,
member_id: member_id
)
end
end
class << self
def sum_query
'SELECT sum(d.amount) as sum FROM deposits as d ' \
'where d.transfer_type = 100 ' \
'AND d.member_id = ? AND d.aasm_state IN (?) AND d.created_at > ?;'
end
def sanitize_execute_sum_queries(member_id)
squery_24h = ActiveRecord::Base.sanitize_sql_for_conditions([sum_query, member_id, SUCCEED_PROCESSING_STATES, 24.hours.ago])
squery_1m = ActiveRecord::Base.sanitize_sql_for_conditions([sum_query, member_id, SUCCEED_PROCESSING_STATES, 1.month.ago])
sum_withdraws_24_hours = ActiveRecord::Base.connection.exec_query(squery_24h).to_hash.first['sum'].to_d
sum_withdraws_1_month = ActiveRecord::Base.connection.exec_query(squery_1m).to_hash.first['sum'].to_d
[sum_withdraws_24_hours, sum_withdraws_1_month]
end
end
end
# == Schema Information
# Schema version: 20200827105929
#
# Table name: deposits
#
# member_id :integer not null
# currency_id :string(10) not null
# amount :decimal(32, 16) not null
# fee :decimal(32, 16) not null
# address :string(95)
# from_addresses :string(1000)
# txid :string(128)
# txout :integer
# aasm_state :string(30) not null
# block_number :integer
# type :string(30) not null
# transfer_type :integer
# tid :string(64) not null
# spread :string(1000)
# created_at :datetime not null
# updated_at :datetime not null
# completed_at :datetime
#
# Indexes
#
# index_deposits_on_aasm_state_and_member_id_and_currency_id (aasm_state,member_id,currency_id)
# index_deposits_on_currency_id (currency_id)
# index_deposits_on_currency_id_and_txid_and_txout (currency_id,txid,txout) UNIQUE
# index_deposits_on_member_id_and_txid (member_id,txid)
# index_deposits_on_tid (tid)
# index_deposits_on_type (type)
#

View File

@@ -0,0 +1,67 @@
# frozen_string_literal: true
class DepositLimit < ApplicationRecord
# Default value for kyc_level, group name and currency_id in DepositLimit table;
ANY = 'any'
enum kind: { fiat: 0, crypto: 1, both: 2 }
# == Relationships ========================================================
belongs_to :currency, optional: true
# == Validations ==========================================================
validates :kyc_level,
presence: true,
uniqueness: { scope: %i[group kind] }
validates :group,
presence: true
validates :limit_24_hour,
:limit_1_month,
presence: true
# == Scopes ===============================================================
# == Callbacks ============================================================
before_create { self.group = self.group.strip.downcase }
after_commit :wipe_cache
# == Class Methods ========================================================
class << self
# Get withdrawal limit for specific withdraw that based on member kyc_level and group.
# WithdrawLimit record selected with the next priorities:
# 1. kyc_level, group match
# 2. kyc_level match
# 3. group match
# 5. kyc_level, group are 'any'
# 6. default (zero limits)
def for(kyc_level:, group:, kind:)
where(kyc_level: [kyc_level, ANY], group: [group, ANY], kind: kind).max_by(&:weight) || new
end
end
# == Instance Methods =====================================================
# deposit limit suitability expressed in weight.
# deposit limit with the greatest weight selected.
# Kyc_level has greater weight then group match.
# E.g deposit for member with kyc_level 2, group 'vip-0''
# (kyc_level == 2 && group == 'vip-0') >
# (kyc_level == 2 && group == 'any') >
# (kyc_level == 'any' && group == 'vip-0') >
# (kyc_level == 'any' && group == 'any') >
def weight
(kyc_level == 'any' ? 0 : 10) + (group == 'any' ? 0 : 1)
end
def wipe_cache
Rails.cache.delete_matched("deposit_limits_fees*")
end
end
#

View File

@@ -0,0 +1,63 @@
# encoding: UTF-8
# frozen_string_literal: true
# Deprecated
# TODO: Delete this class and update type column
module Deposits
class Coin < Deposit
has_one :blockchain, through: :currency
validate { errors.add(:currency, :invalid) if currency && !currency.coin? }
validates :address, :txid, presence: true
validates :txid, uniqueness: { scope: %i[currency_id txout] }
before_validation do
next if blockchain_api&.case_sensitive?
self.txid = txid.try(:downcase)
self.address = address.try(:downcase)
end
before_validation do
next unless blockchain_api&.supports_cash_addr_format? && address?
self.address = CashAddr::Converter.to_cash_address(address)
end
def as_json_for_event_api
super.merge blockchain_confirmations: confirmations
end
end
end
# == Schema Information
# Schema version: 20200827105929
#
# Table name: deposits
#
# id :integer not null, primary key
# member_id :integer not null
# currency_id :string(10) not null
# amount :decimal(32, 16) not null
# fee :decimal(32, 16) not null
# address :string(95)
# from_addresses :string(1000)
# txid :string(128)
# txout :integer
# aasm_state :string(30) not null
# block_number :integer
# type :string(30) not null
# transfer_type :integer
# tid :string(64) not null
# spread :string(1000)
# created_at :datetime not null
# updated_at :datetime not null
# completed_at :datetime
#
# Indexes
#
# index_deposits_on_aasm_state_and_member_id_and_currency_id (aasm_state,member_id,currency_id)
# index_deposits_on_currency_id (currency_id)
# index_deposits_on_currency_id_and_txid_and_txout (currency_id,txid,txout) UNIQUE
# index_deposits_on_member_id_and_txid (member_id,txid)
# index_deposits_on_tid (tid)
# index_deposits_on_type (type)
#

View File

@@ -0,0 +1,55 @@
# encoding: UTF-8
# frozen_string_literal: true
# Deprecated
# TODO: Delete this class and update type column
module Deposits
class Fiat < Deposit
has_one :blockchain, through: :currency
validate { errors.add(:currency, :invalid) if currency && !currency.fiat? }
def initialize(*)
super
verify_limits
end
def charge!
with_lock { accept! }
end
end
end
# == Schema Information
# Schema version: 20200827105929
#
# Table name: deposits
#
# id :integer not null, primary key
# member_id :integer not null
# currency_id :string(10) not null
# amount :decimal(32, 16) not null
# fee :decimal(32, 16) not null
# address :string(95)
# from_addresses :string(1000)
# txid :string(128)
# txout :integer
# aasm_state :string(30) not null
# block_number :integer
# type :string(30) not null
# transfer_type :integer
# tid :string(64) not null
# spread :string(1000)
# created_at :datetime not null
# updated_at :datetime not null
# completed_at :datetime
#
# Indexes
#
# index_deposits_on_aasm_state_and_member_id_and_currency_id (aasm_state,member_id,currency_id)
# index_deposits_on_currency_id (currency_id)
# index_deposits_on_currency_id_and_txid_and_txout (currency_id,txid,txout) UNIQUE
# index_deposits_on_member_id_and_txid (member_id,txid)
# index_deposits_on_tid (tid)
# index_deposits_on_type (type)
#

62
app/models/engine.rb Normal file
View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
class Engine < ApplicationRecord
# == Constants ============================================================
include Vault::EncryptedModel
vault_lazy_decrypt!
extend Enumerize
STATES = { online: 1, offline: 0 }.freeze
PEATIO_ENGINE_DRIVERS = %w[peatio].freeze
enumerize :state, in: STATES, scope: true
# == Attributes ===========================================================
vault_attribute :key
vault_attribute :secret
vault_attribute :data, serialize: :json, default: {}
# == Extensions ===========================================================
# == Relationships ========================================================
has_many :markets
has_one :member, foreign_key: :uid, primary_key: :uid
# == Validations ==========================================================
validates :name, uniqueness: true, presence: true
validates :driver, presence: true
# == Scopes ===============================================================
# == Callbacks ============================================================
before_create { self.name = name.strip.downcase }
# == Class Methods ========================================================
# == Instance Methods =====================================================
def peatio_engine?
self.driver.in?(PEATIO_ENGINE_DRIVERS)
end
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: engines
#
# id :bigint not null, primary key
# name :string(255) not null
# driver :string(255) not null
# uid :string(255)
# url :string(255)
# key_encrypted :string(255)
# secret_encrypted :string(255)
# data_encrypted :string(1024)
# state :integer default("online"), not null
#

View File

@@ -0,0 +1,91 @@
# frozen_string_literal: true
module Helpers
module ReorderPosition
# Function which insert object inside existing list
def insert_position(model)
# Get current currency amount
count = model.class.count
# If position value greater than currency amount
# System should add this object in the end of the list
# For example:
# List size eq to 15 and user want to set position to 24, system will set it on 16 position
if model.position > count
# Use update_column in favor of update to skip callback methods
model.update_column(:position, count)
elsif model.position == count
# System shouldn't reorder objects if new object has last position in the list
return
else
# As soon as create doesnt have old position value
# System will move the list up to the highest position(count)
# So techically old position == highest_position = count
highest_position = count
# Current model position
new_position = model.position
shuffle_positions_on_intermediate_items(model, highest_position, new_position)
end
end
# Function which update object inside existing list
def update_position(model)
# Get current currency amount
count = model.class.count
# Previous model position
old_position = model.position_was
# If new position value greater than currency amount
# System should add this object in the end of the list
new_position = model.position > count ? count : model.position
shuffle_positions_on_intermediate_items(model, old_position, new_position)
end
private
def shuffle_positions_on_intermediate_items(model, old_position, new_position)
# https://apidock.com/rails/String/tableize
# Currency => currencies Market => markets
table_name = model.class.name.tableize
# Define SQL query for reordering positions
sql = if old_position > new_position
increment_positions_on_lower_items(model.id, table_name, old_position, new_position)
else
decrement_positions_on_higher_items(model.id, table_name, old_position, new_position)
end
ActiveRecord::Base.transaction do
# Use update_column in favor of update to skip callback methods
# Set position as 0 before reordering
# Top list position starts from 1, so 0 is safe place for those updating
model.update_column(:position, 0)
# Reorder ojects in the list
ActiveRecord::Base.connection.execute(sql)
# Update object with desired position
model.update_column(:position, new_position)
end
end
# Updates objects positions between old position and new position
# If old position > new position
def increment_positions_on_lower_items(model_id, table_name, old_position, new_position)
"UPDATE #{table_name} SET position = (#{table_name}.position + 1) "\
"WHERE (#{table_name}.id != '#{model_id}') "\
"AND (#{table_name}.position >= #{new_position}) "\
"AND (#{table_name}.position < #{old_position})"
end
# Updates objects positions between old position and new position
# If old position < new position
def decrement_positions_on_higher_items(model_id, table_name, old_position, new_position)
"UPDATE #{table_name} SET position = (#{table_name}.position - 1) "\
"WHERE (#{table_name}.id != '#{model_id}') "\
"AND (#{table_name}.position > #{old_position}) "\
"AND (#{table_name}.position <= #{new_position})"
end
end
end

View File

@@ -0,0 +1,55 @@
class InternalTransfer < ApplicationRecord
# == Constants ============================================================
# == Attributes ===========================================================
# == Extensions ===========================================================
acts_as_eventable prefix: 'internal_transfer', on: %i[create update]
# == Relationships ========================================================
belongs_to :currency
belongs_to :sender, class_name: :Member, required: true
belongs_to :receiver, class_name: :Member, required: true
# == Validations ==========================================================
validates :currency, :amount, :sender, :receiver, :state, presence: true
# == Scopes ===============================================================
# == Callbacks ============================================================
before_commit on: :create do
InternalTransfer.transaction do
liabilities = [
Operations::Liability.debit!(amount: amount, currency: currency, reference: self, member_id: sender_id),
Operations::Liability.credit!(amount: amount, currency: currency, reference: self, member_id: receiver_id)
]
liabilities.each { |l| Operations.update_legacy_balance(l) }
end
end
# == Class Methods ========================================================
# == Instance Methods =====================================================
enum state: { completed: 1 }
def direction(user)
user == sender ? 'out' : 'in'
end
end
# == Schema Information
# Schema version: 20210120133912
#
# Table name: internal_transfers
#
# id :bigint not null, primary key
# currency_id :string(255) not null
# amount :decimal(32, 16) not null
# sender_id :bigint not null
# receiver_id :bigint not null
# state :integer not null
# created_at :datetime not null
# updated_at :datetime not null
#

34
app/models/job.rb Normal file
View File

@@ -0,0 +1,34 @@
# frozen_string_literal: true
class Job < ApplicationRecord
serialize :data, JSON unless Rails.configuration.database_support_json
before_create { self.finished_at = Time.now }
def self.execute(name)
job = new(name: name, started_at: Time.now)
result = yield.symbolize_keys
job.update!(pointer: result[:pointer], counter: result[:counter], error_code: 0)
rescue StandardError => e
job.error_code = 1
job.error_message = e.message
job.save!
end
end
# == Schema Information
# Schema version: 20200827105929
#
# Table name: jobs
#
# id :bigint not null, primary key
# name :string(255) not null
# pointer :integer unsigned
# counter :integer
# data :json
# error_code :integer default(255), unsigned, not null
# error_message :string(255)
# started_at :datetime
# finished_at :datetime
#

235
app/models/market.rb Normal file
View File

@@ -0,0 +1,235 @@
# encoding: UTF-8
# frozen_string_literal: true
# People exchange commodities in markets. Each market focuses on certain
# commodity pair `{A, B}`. By convention, we call people exchange A for B
# *sellers* who submit *ask* orders, and people exchange B for A *buyers*
# who submit *bid* orders.
#
# ID of market is always in the form "#{B}#{A}". For example, in 'btcusd'
# market, the commodity pair is `{btc, usd}`. Sellers sell out _btc_ for
# _usd_, buyers buy in _btc_ with _usd_. _btc_ is the `base_unit`, while
# _usd_ is the `quote_unit`.
#
# Given market BTCUSD.
# Ask/Base currency/unit = BTC.
# Bid/Quote currency/unit = USD.
class Market < ApplicationRecord
# == Constants ============================================================
# Since we use decimal with 16 digits fractional part for storing numbers in DB
# sum of multipliers fractional parts must not be greater then 16.
# In the worst situation we have 3 multipliers (price * amount * fee).
# For fee we define static precision - 6. See TradingFee::FEE_PRECISION.
# So 10 left for amount and price precision.
DB_DECIMAL_PRECISION = 16
FUNDS_PRECISION = 10
TOP_POSITION = 1
AVERAGE_NUMBER = 5
STATES = %w[enabled disabled hidden locked sale presale].freeze
# enabled - user can view and trade.
# disabled - none can trade, user can't view.
# hidden - user can't view but can trade.
# locked - user can view but can't trade.
# sale - user can't view but can trade with market orders.
# presale - user can't view and trade. Admin can trade.
# == Attributes ===========================================================
attr_readonly :base_unit, :quote_unit
# base_currency & quote_currency is preferred names instead of legacy
# base_unit & quote_unit.
# For avoiding DB migration and config we use alias as temporary solution.
alias_attribute :base_currency, :base_unit
alias_attribute :quote_currency, :quote_unit
# == Extensions ===========================================================
serialize :data, JSON unless ::Rails.configuration.database_support_json
include Helpers::ReorderPosition
# == Relationships ========================================================
has_one :base, class_name: 'Currency', foreign_key: :id, primary_key: :base_unit
has_one :quote, class_name: 'Currency', foreign_key: :id, primary_key: :quote_unit
belongs_to :engine, required: true
has_many :trading_fees, dependent: :delete_all
# == Validations ==========================================================
validate do
if quote_currency == base_currency
errors.add(:quote_currency, 'duplicates base currency')
end
end
validate on: :create do
if ENV['MAX_MARKETS'].present? && Market.count >= ENV['MAX_MARKETS'].to_i
errors.add(:max, 'Market limit has been reached')
end
if Market.where(base_currency: quote_currency, quote_currency: base_currency).present? ||
Market.where(base_currency: base_currency, quote_currency: quote_currency).present?
errors.add(:base, "#{base_currency.upcase}, #{quote_currency.upcase} market already exists")
end
end
validates :id, uniqueness: { case_sensitive: false }, presence: true
validates :base_currency, :quote_currency, presence: true
validates :min_price, :max_price, precision: { less_than_or_eq_to: ->(m) { m.price_precision } }
validates :min_amount, precision: { less_than_or_eq_to: ->(m) { m.amount_precision } }
validates :position,
presence: true,
numericality: { greater_than_or_equal_to: TOP_POSITION, only_integer: true }
validates :amount_precision,
:price_precision,
numericality: { greater_than_or_equal_to: 0, only_integer: true }
validates :price_precision,
numericality: {
less_than_or_equal_to: ->(_m) { FUNDS_PRECISION }
}
validates :amount_precision,
numericality: {
less_than_or_equal_to: ->(m) { FUNDS_PRECISION - m.price_precision }
}
validates :base_currency, :quote_currency, inclusion: { in: -> (_) { Currency.codes } }
validates :min_price,
presence: true,
numericality: { greater_than_or_equal_to: ->(market) { market.min_price_by_precision } }
validates :max_price,
numericality: { allow_blank: true, greater_than_or_equal_to: ->(market) { market.min_price }},
if: ->(market) { !market.max_price.zero? }
validates :min_amount,
presence: true,
numericality: { greater_than_or_equal_to: ->(market) { market.min_amount_by_precision } }
validates :state, inclusion: { in: STATES }
# == Scopes ===============================================================
scope :ordered, -> { order(position: :asc) }
scope :active, -> { where(state: %i[enabled hidden]) }
scope :enabled, -> { where(state: :enabled) }
# == Callbacks ============================================================
after_initialize :initialize_defaults, if: :new_record?
before_validation(on: :create) { self.id = "#{base_currency}#{quote_currency}" }
before_validation(on: :create) { self.position = Market.count + 1 unless position.present? }
after_commit { AMQP::Queue.enqueue(:matching, action: 'new', market: id) }
after_commit :wipe_cache
after_create { insert_position(self) }
before_update { update_position(self) if position_changed? }
# == Instance Methods =====================================================
def price_now
last_trades = Trade.public_from_influx(self.id, AVERAGE_NUMBER)
prices = last_trades.inject(0) { |sum,x| sum + x[:price] }
prices / AVERAGE_NUMBER
end
def initialize_defaults
self.data = {} if data.blank?
end
def wipe_cache
Rails.cache.delete_matched("markets*")
end
def name
"#{base_currency}/#{quote_currency}".upcase
end
def underscore_name
"#{base_currency.upcase}_#{quote_currency.upcase}"
end
alias to_s name
def round_amount(d)
d.round(amount_precision, BigDecimal::ROUND_DOWN)
end
def round_price(d)
d.round(price_precision, BigDecimal::ROUND_DOWN)
end
def unit_info
{ name: name, base_unit: base_currency, quote_unit: quote_currency }
end
# min_amount_by_precision - is the smallest positive number which could be
# rounded to value greater then 0 with precision defined by
# Market #amount_precision. So min_amount_by_precision is the smallest amount
# of order/trade for current market.
# E.g.
# market.amount_precision => 4
# min_amount_by_precision => 0.0001
#
# market.amount_precision => 2
# min_amount_by_precision => 0.01
#
def min_amount_by_precision
0.1.to_d**amount_precision
end
# See #min_amount_by_precision.
def min_price_by_precision
0.1.to_d**price_precision
end
def engine_name=(engine_name)
self.engine = Engine.find_by(name: engine_name)
end
end
# == Schema Information
# Schema version: 20200909083000
#
# Table name: markets
#
# id :string(20) not null, primary key
# base_unit :string(10) not null
# quote_unit :string(10) not null
# engine_id :bigint not null
# amount_precision :integer default(4), not null
# price_precision :integer default(4), not null
# min_price :decimal(32, 16) default(0.0), not null
# max_price :decimal(32, 16) default(0.0), not null
# min_amount :decimal(32, 16) default(0.0), not null
# position :integer not null
# data :json
# state :string(32) default("enabled"), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_markets_on_base_unit (base_unit)
# index_markets_on_base_unit_and_quote_unit (base_unit,quote_unit) UNIQUE
# index_markets_on_engine_id (engine_id)
# index_markets_on_position (position)
# index_markets_on_quote_unit (quote_unit)
#

327
app/models/member.rb Normal file
View File

@@ -0,0 +1,327 @@
# encoding: UTF-8
# frozen_string_literal: true
require 'securerandom'
class Member < ApplicationRecord
include ModelCaching
MAX_GROUP = 4
has_many :orders
has_many :accounts
has_many :stats_member_pnl
has_many :payment_addresses
has_many :withdraws, -> { order(id: :desc) }
has_many :deposits, -> { order(id: :desc) }
has_many :beneficiaries, -> { order(id: :desc) }
has_many :bonus
serialize :cards, Array
serialize :ibans, Array
scope :enabled, -> { where(state: 'active') }
before_validation :downcase_email
validates :uid, length: { maximum: 32 }
validates :referral_uid, length: { maximum: 32 }
validates :email, presence: true, uniqueness: true, email: true
validates :level, numericality: { greater_than_or_equal_to: 0 }
validates :role, inclusion: { in: ::Ability.roles }
before_create { self.group = self.group.strip.downcase }
class << self
def groups
TradingFee.distinct.pluck(:group)
end
def group_name_by_number(number)
"vip-#{number}"
end
def min_max_group(group_name)
return [ENV.fetch('VIP_4_AMOUNT').to_d, 0] if group_name == 'vip-4'
return [ENV.fetch('VIP_3_AMOUNT').to_d, ENV.fetch('VIP_4_AMOUNT').to_d] if group_name == 'vip-3'
return [ENV.fetch('VIP_2_AMOUNT').to_d, ENV.fetch('VIP_3_AMOUNT').to_d] if group_name == 'vip-2'
return [ENV.fetch('VIP_1_AMOUNT').to_d, ENV.fetch('VIP_2_AMOUNT').to_d] if group_name == 'vip-1'
return [0, ENV.fetch('VIP_1_AMOUNT').to_d] if group_name == 'vip-0'
end
end
def group_update(order)
return true if self.group == 'bot'
return true unless self.group.include?('vip')
all = self.trades.last_month.inject(0){ |sum, x| sum + x.rls }
return self.update(group: 'vip-4') if all > ENV.fetch('VIP_4_AMOUNT').to_d
return self.update(group: 'vip-3') if all > ENV.fetch('VIP_3_AMOUNT').to_d
return self.update(group: 'vip-2') if all > ENV.fetch('VIP_2_AMOUNT').to_d
return self.update(group: 'vip-1') if all > ENV.fetch('VIP_1_AMOUNT').to_d
self.update(group: 'vip-0')
end
def next_group
return group if group == Member.group_name_by_number(MAX_GROUP)
Member.group_name_by_number(group[4].to_i + 1)
end
def trades
Trade.where('maker_id = ? OR taker_id = ?', id, id)
end
def role
super&.inquiry
end
def admin?
role == "admin"
end
def get_account(model_or_id_or_code)
if model_or_id_or_code.is_a?(String) || model_or_id_or_code.is_a?(Symbol)
accounts.find_or_create_by(currency_id: model_or_id_or_code)
elsif model_or_id_or_code.is_a?(Currency)
accounts.find_or_create_by(currency: model_or_id_or_code)
end
# Thread Safe Account creation
rescue ActiveRecord::RecordNotUnique
if model_or_id_or_code.is_a?(String) || model_or_id_or_code.is_a?(Symbol)
accounts.find_by(currency_id: model_or_id_or_code)
elsif model_or_id_or_code.is_a?(Currency)
accounts.find_by(currency: model_or_id_or_code)
end
end
def referral_user
self.class.find_by(uid: referral_uid) if referral_uid.present?
end
# @deprecated
def touch_accounts
Currency.find_each do |currency|
next if accounts.where(currency: currency).exists?
accounts.create!(currency: currency)
end
end
def balance_for(currency:, kind:)
account_code = Operations::Account.find_by(
type: :liability,
kind: kind,
currency_type: currency.type
).code
liabilities = Operations::Liability.where(member_id: id, currency: currency, code: account_code)
liabilities.sum('credit - debit')
end
def legacy_balance_for(currency:, kind:)
if kind.to_sym == :main
get_account(currency).balance
elsif kind.to_sym == :locked
get_account(currency).locked
else
raise Operations::Exception, "Account for #{options} doesn't exists."
end
end
def revert_trading_activity!(trades)
trades.each(&:revert_trade!)
end
def payment_address(wallet_id, remote = false)
wallet = Wallet.find(wallet_id)
return if wallet.blank?
pa = PaymentAddress.find_by(member: self, wallet: wallet, remote: remote)
if pa.blank?
pa = payment_addresses.create!(wallet: wallet)
elsif pa.address.blank?
pa.enqueue_address_generation
end
pa
end
# Attempts to create additional deposit address for account.
def payment_address!(wallet_id, remote = false)
wallet = Wallet.find(wallet_id)
return if wallet.blank?
pa = PaymentAddress.find_by(member: self, wallet: wallet)
# The address generation process is in progress.
if pa.present? && pa.address.blank?
pa
else
# allows user to have multiple addresses
pa = payment_addresses.create!(wallet: wallet, remote: remote)
end
pa
end
def as_json_for_event_api
{
uid: uid,
email: email,
role: role,
level: level,
otp: otp,
state: state,
referral_uid: referral_uid
}
end
def sync_beneficiaries
fiat_currency = Currency.find('irt')
fiat_beneficiaries = beneficiaries.active_to_member.where(currency: fiat_currency)
array_beneficiaries = fiat_beneficiaries.map { |beneficiary| beneficiary&.data.dig('account_number') }&.compact
# delete extra beneficiary that was deleted in the Dalan
fiat_beneficiaries.each { |b| b.delete unless b.data.dig('account_number')&.in?(flat_cards + flat_ibans) }
# add beneficiary that was added in the Dalan
cards&.each { |c| create_beneficiary('card', c&.keys&.last, c&.values&.last, fiat_currency) unless c&.values&.last.in? (array_beneficiaries) }
ibans&.each { |i| create_beneficiary('iban', i&.keys&.last, i&.values&.last, fiat_currency) unless i&.values&.last.in? (array_beneficiaries) }
end
def flat_cards
return [] unless cards.present?
cards.map(&:values).flatten
end
def flat_ibans
return [] unless ibans.present?
ibans.map(&:values).flatten
end
def create_beneficiary(kind, name, data, currency, state: 'active')
beneficiaries.create!(currency: currency,
data: { full_name: name, account_number: data, kind: kind },
name: name, state: state)
end
private
def downcase_email
self.email = email.try(:downcase)
end
class << self
def uid(member_id)
Member.find_by(id: member_id)&.uid
end
def find_by_username_or_uid(uid_or_username)
if Member.find_by(uid: uid_or_username).present?
Member.find_by(uid: uid_or_username)
elsif Member.find_by(username: uid_or_username).present?
Member.find_by(username: uid_or_username)
end
end
def from_payload(p)
params = filter_payload(p)
validate_payload(params)
member = ::Member.find_or_create_by(uid: p[:uid]) do |m|
m.email = params[:email]
m.username = params[:username]
m.role = params[:role]
m.state = params[:state]
m.level = params[:level]
m.referral_uid = params.dig(:referral_uid)
m.last_change_pass = params.dig(:last_change_pass)
m.cards = params.dig(:cards)
m.ibans = params.dig(:ibans)
m.otp = params.dig(:otp)
end
member.assign_attributes(params)
# comment if dont want sync beneficiary
# flag_beneficiary = member.ibans_changed? || member.cards_changed? ? true : false
member.save! if member.changed?
# member.reload.sync_beneficiaries if flag_beneficiary
member
end
# Filter and validate payload params
def filter_payload(payload)
payload.slice(:email, :username, :uid, :role, :state, :level, :referral_uid, :last_change_pass, :cards, :ibans, :otp)
end
def validate_payload(p)
fetch_email(p)
p.fetch(:uid).tap { |uid| raise(Peatio::Auth::Error, 'UID is blank.') if uid.blank? }
p.fetch(:role).tap { |role| raise(Peatio::Auth::Error, 'Role is blank.') if role.blank? }
p.fetch(:level).tap { |level| raise(Peatio::Auth::Error, 'Level is blank.') if level.blank? }
p.fetch(:state).tap do |state|
raise(Peatio::Auth::Error, 'State is blank.') if state.blank?
raise(Peatio::Auth::Error, 'State is not active.') unless state == 'active'
end
end
def fetch_email(payload)
payload[:email].to_s.tap do |email|
raise(Peatio::Auth::Error, 'E-Mail is blank.') if email.blank?
raise(Peatio::Auth::Error, 'E-Mail is invalid.') unless EmailValidator.valid?(email)
end
end
def search(field: nil, term: nil)
term = "%#{term}%"
case field
when 'email'
where("email LIKE ?", term)
when 'uid'
where('uid LIKE ?', term)
when 'wallet_address'
joins(:payment_addresses).where('payment_addresses.address LIKE ?', term)
else
all
end.order(:id).reverse_order
end
end
end
# == Schema Information
# Schema version: 20201207134745
#
# Table name: members
#
# id :integer not null, primary key
# uid :string(32) not null
# email :string(255) not null
# username :string(255)
# level :integer not null
# role :string(16) not null
# group :string(32) default("vip-0"), not null
# state :string(16) not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_members_on_email (email) UNIQUE
# index_members_on_uid (uid) UNIQUE
# index_members_on_username (username) UNIQUE
#

96
app/models/operation.rb Normal file
View File

@@ -0,0 +1,96 @@
# frozen_string_literal: true
# {Operation} provides generic methods for the accounting operations
# models.
# @abstract
class Operation < ApplicationRecord
belongs_to :reference, polymorphic: true
belongs_to :currency, foreign_key: :currency_id
belongs_to :account, class_name: 'Operations::Account',
foreign_key: :code, primary_key: :code
validates :credit, :debit, numericality: { greater_than_or_equal_to: 0 }
validates :currency, :code, presence: true
validate do
errors.add(:account, 'account doesn\'t exist') unless account
end
validate do
unless account&.currency_type == currency&.type
errors.add(:currency, 'type and account currency type don\'t match')
end
end
validate do
unless account&.type == self.class.operation_type
errors.add(:base, 'Account type and operation type don\'t match')
end
end
self.abstract_class = true
# Returns operation amount with sign.
def amount
credit.zero? ? -debit : credit
end
class << self
def operation_type
name.demodulize.downcase
end
def credit!(amount:, currency:, kind: :main, **opt)
return if amount.zero?
opt[:code] ||= Operations::Account.find_by(
type: operation_type,
kind: kind,
currency_type: currency.type
).code
opt.merge(credit: amount, currency_id: currency.id)
.yield_self { |attr| new(attr) }
.tap(&:save!)
end
def debit!(amount:, currency:, kind: :main, **opt)
return if amount.zero?
opt[:code] ||= Operations::Account.find_by(
type: operation_type,
kind: kind,
currency_type: currency.type
).code
opt.merge(debit: amount, currency_id: currency.id)
.yield_self { |attr| new(attr) }
.tap(&:save!)
end
def transfer!(amount:, currency:, from_kind:, to_kind:, **opt)
params = opt.merge(amount: amount, currency: currency)
[
debit!(params.merge(kind: from_kind)),
credit!(params.merge(kind: to_kind))
]
end
def balance(currency: nil, created_at_from: nil, created_at_to: nil)
if currency.blank?
db_balances = all
db_balances = db_balances.where('created_at > ?', created_at_from) if created_at_from.present?
db_balances = db_balances.where('created_at < ?', created_at_to) if created_at_to.present?
db_balances = db_balances.group(:currency_id)
.sum('credit - debit')
Currency.ids.map(&:to_sym).each_with_object({}) do |id, memo|
memo[id] = db_balances[id.to_s] || 0
end
else
where(currency: currency).sum('credit - debit')
end
end
end
end

67
app/models/operations.rb Normal file
View File

@@ -0,0 +1,67 @@
# encoding: UTF-8
# frozen_string_literal: true
module Operations
class << self
def build_account_number(currency_id:, account_code:, member_uid: nil)
[currency_id.to_s, account_code.to_s, member_uid].compact.join('-')
end
def split_account_number(account_number:)
currency_id, code, member_uid = account_number.split('-')
{ currency_id: currency_id,
code: code,
member_uid: member_uid }
end
def klass_for(code:)
account = Operations::Account.find_by(code: code)
{ asset: Operations::Asset,
liability: Operations::Liability,
revenue: Operations::Revenue,
expense: Operations::Expense }.fetch(account.type.to_sym)
end
def update_legacy_balance(liability)
return unless liability.present? || liability.is_a?(Operations::Liability)
account = liability.account
legacy_account = liability.member.get_account(liability.currency)
credit = liability.credit
debit = liability.debit
if account.kind.main?
if liability.credit.nonzero?
legacy_account.plus_funds(credit)
else
legacy_account.sub_funds(debit)
end
elsif account.kind.locked?
if credit.nonzero?
legacy_account.plus_funds(credit)
legacy_account.lock_funds(credit)
else
legacy_account.unlock_and_sub_funds(debit)
end
end
end
def validate_accounting_equation(operations)
balance_sheet = Hash.new(0)
assets = operations.select { |op| op.is_a?(Operations::Asset) }
liabilities = operations.select { |op| op.is_a?(Operations::Liability) }
revenues = operations.select { |op| op.is_a?(Operations::Revenue) }
expenses = operations.select { |op| op.is_a?(Operations::Expense) }
(assets + expenses).each do |op|
balance_sheet[op.currency_id] += op.amount
end
(liabilities + revenues).each do |op|
balance_sheet[op.currency_id] -= op.amount
end
balance_sheet.delete_if { |_k, v| v.zero? }.empty?
end
end
end

View File

@@ -0,0 +1,61 @@
# frozen_string_literal: true
# TODO: Add admin rubric for Account.
module Operations
class Account < ApplicationRecord
SCOPES = %w[member platform].freeze
MEMBER_TYPES = %w[liability].freeze
PLATFORM_TYPES = %w[asset expense revenue].freeze
TYPES = (MEMBER_TYPES + PLATFORM_TYPES).freeze
validates :code, presence: true, uniqueness: true
validates :type, presence: true, inclusion: { in: TYPES }
validates :kind, presence: true, uniqueness: { scope: %i[type currency_type] }
validates :currency_type, presence: true, inclusion: { in: Currency.types.map(&:to_s) }
validates :scope, presence: true, inclusion: { in: SCOPES }
def self.table_name_prefix
'operations_'
end
# Type column reserved for STI.
self.inheritance_column = nil
# Allows dynamically check scopes.
# scope.platform?
def scope
super&.inquiry
end
# Allows dynamically check kinds.
# kind.main?
def kind
super&.inquiry
end
end
end
# == Schema Information
# Schema version: 20190115165813
#
# Table name: operations_accounts
#
# id :integer not null, primary key
# code :integer not null
# type :string(10) not null
# kind :string(30) not null
# currency_type :string(10) not null
# description :string(100)
# scope :string(10) not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_operations_accounts_on_code (code) UNIQUE
# index_operations_accounts_on_currency_type (currency_type)
# index_operations_accounts_on_scope (scope)
# index_operations_accounts_on_type (type)
# index_operations_accounts_on_type_and_kind_and_currency_type (type,kind,currency_type) UNIQUE
#

View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
module Operations
# {Asset} is a balance sheet operation
class Asset < Operation
end
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: assets
#
# id :integer not null, primary key
# code :integer not null
# currency_id :string(255) not null
# reference_type :string(255)
# reference_id :integer
# debit :decimal(32, 16) default(0.0), not null
# credit :decimal(32, 16) default(0.0), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_assets_on_currency_id (currency_id)
# index_assets_on_reference_type_and_reference_id (reference_type,reference_id)
#

View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
module Operations
# {Expense} is a income statement operation
class Expense < Operation
end
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: expenses
#
# id :integer not null, primary key
# code :integer not null
# currency_id :string(255) not null
# reference_type :string(255)
# reference_id :integer
# debit :decimal(32, 16) default(0.0), not null
# credit :decimal(32, 16) default(0.0), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_expenses_on_currency_id (currency_id)
# index_expenses_on_reference_type_and_reference_id (reference_type,reference_id)
#

View File

@@ -0,0 +1,56 @@
# frozen_string_literal: true
module Operations
# {Liability} is a balance sheet operation
class Liability < Operation
belongs_to :member
validates :member_id, presence: {
if: ->(liability) { liability.account.scope == 'member' }
}
validates :member_id, absence: {
if: ->(liability) { liability.account.scope != 'member' }
}
# Notify third party trading engine about member balance update.
after_commit on: :create do
AMQP::Queue.enqueue(:events_processor,
subject: :operation,
payload: as_json_for_events_processor)
end
def as_json_for_events_processor
{ code: code,
currency: currency_id,
member_id: member_id,
reference_id: reference_id,
reference_type: reference_type&.downcase,
debit: debit,
credit: credit }
end
end
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: liabilities
#
# id :integer not null, primary key
# code :integer not null
# currency_id :string(255) not null
# member_id :integer
# reference_type :string(255)
# reference_id :integer
# debit :decimal(32, 16) default(0.0), not null
# credit :decimal(32, 16) default(0.0), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_liabilities_on_currency_id (currency_id)
# index_liabilities_on_member_id (member_id)
# index_liabilities_on_reference_type_and_reference_id (reference_type,reference_id)
#

View File

@@ -0,0 +1,32 @@
# frozen_string_literal: true
module Operations
# {Revenue} is a income statement operation
class Revenue < Operation
belongs_to :member
scope :h24, -> { where('created_at > ?', 24.hours.ago) }
end
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: revenues
#
# id :integer not null, primary key
# code :integer not null
# currency_id :string(255) not null
# member_id :integer
# reference_type :string(255)
# reference_id :integer
# debit :decimal(32, 16) default(0.0), not null
# credit :decimal(32, 16) default(0.0), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_revenues_on_currency_id (currency_id)
# index_revenues_on_reference_type_and_reference_id (reference_type,reference_id)
#

415
app/models/order.rb Normal file
View File

@@ -0,0 +1,415 @@
# encoding: UTF-8
# frozen_string_literal: true
require 'csv'
class Order < ApplicationRecord
belongs_to :market, required: true
belongs_to :member, required: true
attribute :uuid, :uuid if Rails.configuration.database_adapter.downcase != 'PostgreSQL'.downcase
# Error is raised in case market doesn't have enough volume to fulfill the Order.
InsufficientMarketLiquidity = Class.new(StandardError)
extend Enumerize
STATES = { pending: 0, wait: 100, done: 200, cancel: -100, reject: -200 }.freeze
enumerize :state, in: STATES, scope: true
TYPES = %w[market limit].freeze
THIRD_PARTY_ORDER_ACTION_TYPE = {
'submit_single' => 0,
'cancel_single' => 3,
'cancel_bulk' => 4
}.freeze
belongs_to :ask_currency, class_name: 'Currency', foreign_key: :ask
belongs_to :bid_currency, class_name: 'Currency', foreign_key: :bid
after_commit :trigger_event
validates :ord_type, :volume, :origin_volume, :locked, :origin_locked, presence: true
validates :price, numericality: { greater_than: 0 }, if: ->(order) { order.ord_type == 'limit' }
validates :origin_volume,
numericality: { greater_than: 0, greater_than_or_equal_to: ->(order){ order.market.min_amount } },
on: :create
validates :origin_volume, precision: { less_than_or_eq_to: ->(o) { o.market.amount_precision } },
if: ->(o) { o.origin_volume.present? }, on: :create
validate :market_order_validations, if: ->(order) { order.ord_type == 'market' }
validates :price, presence: true, if: :is_limit_order?
validates :price, precision: { less_than_or_eq_to: ->(o) { o.market.price_precision } },
if: ->(o) { o.price.present? }, on: :create
validates :price,
numericality: { less_than_or_equal_to: ->(order){ order.market.max_price }},
if: ->(order) { order.is_limit_order? && order.market.max_price.nonzero? },
on: :create
validates :price,
numericality: { greater_than_or_equal_to: ->(order){ order.market.min_price }},
if: :is_limit_order?, on: :create
attr_readonly :member_id,
:bid,
:ask,
:market_id,
:ord_type,
:origin_volume,
:origin_locked,
:created_at
PENDING = 'pending'
WAIT = 'wait'
DONE = 'done'
CANCEL = 'cancel'
REJECT = 'reject'
scope :done, -> { with_state(:done) }
scope :active, -> { with_state(:wait) }
scope :with_market, ->(market) { where(market_id: market) }
# Custom ransackers.
ransacker :state, formatter: proc { |v| STATES[v.to_sym] } do |parent|
parent.table[:state]
end
# Single Order can produce multiple Trades with different fee types (maker and taker).
# Since we can't predict fee types on order creation step and
# Market fees configuration can change we need to store fees on Order creation.
after_validation(on: :create, if: ->(o) { o.errors.blank? }) do
member.group_update(self)
trading_fee = TradingFee.for(group: member.group, market_id: market_id)
self.maker_fee = trading_fee.maker
self.taker_fee = trading_fee.taker
end
before_create do
self.uuid = UUID.generate if uuid.blank?
end
after_commit on: :create do
next unless ord_type == 'limit'
EventAPI.notify ['market', market_id, 'order_created'].join('.'), \
Serializers::EventAPI::OrderCreated.call(self)
end
after_commit on: :update do
next unless ord_type == 'limit'
event = case state
when 'cancel' then 'order_canceled'
when 'done' then 'order_completed'
else 'order_updated'
end
Serializers::EventAPI.const_get(event.camelize).call(self).tap do |payload|
EventAPI.notify ['market', market_id, event].join('.'), payload
end
end
class << self
def submit(id)
ActiveRecord::Base.transaction do
order = lock.find_by_id!(id)
return unless order.state == ::Order::PENDING
order.hold_account!.lock_funds!(order.locked)
order.record_submit_operations!
order.update!(state: ::Order::WAIT)
AMQP::Queue.enqueue(:matching, action: 'submit', order: order.to_matching_attributes)
end
rescue => e
order = find_by_id!(id)
order.update!(state: ::Order::REJECT) if order
raise e
end
def cancel(id)
order = lock.find_by_id!(id)
market_engine = order.market.engine
return unless order.state == ::Order::WAIT
return order.trigger_third_party_cancellation unless market_engine.peatio_engine?
ActiveRecord::Base.transaction do
order.hold_account!.unlock_funds!(order.locked)
order.record_cancel_operations!
order.update!(state: ::Order::CANCEL)
end
end
def trigger_bulk_cancel_third_party(engine_driver, filters = {})
AMQP::Queue.publish(engine_driver,
data: filters,
type: THIRD_PARTY_ORDER_ACTION_TYPE['cancel_bulk'])
end
def to_csv
attributes = %w[id market_id ord_type side price volume origin_volume avg_price trades_count state created_at updated_at]
CSV.generate(headers: true) do |csv|
csv << attributes
all.each do |order|
data = attributes[0...-2].map { |attr| order.send(attr) }
data += attributes[-2..-1].map { |attr| order.send(attr).iso8601 }
csv << data
end
end
end
end
def submit_order
return unless new_record?
self.locked = self.origin_locked = if ord_type == 'market' && side == 'buy'
[compute_locked * OrderBid::LOCKING_BUFFER_FACTOR, member_balance].min
else
compute_locked
end
raise ::Account::AccountError unless member_balance >= locked
return trigger_third_party_creation unless market.engine.peatio_engine?
save!
AMQP::Queue.enqueue(:order_processor,
{ action: 'submit', order: attributes },
{ persistent: false })
end
def trigger_third_party_creation
return unless new_record?
self.uuid ||= UUID.generate
self.created_at ||= Time.now
AMQP::Queue.publish(market.engine.driver, data: as_json_for_third_party, type: THIRD_PARTY_ORDER_ACTION_TYPE['submit_single'])
end
def trigger_cancellation
market.engine.peatio_engine? ? trigger_internal_cancellation : trigger_third_party_cancellation
end
def trigger_internal_cancellation
AMQP::Queue.enqueue(:matching, action: 'cancel', order: to_matching_attributes)
end
def trigger_third_party_cancellation
AMQP::Queue.publish(market.engine.driver,
data: as_json_for_third_party,
type: THIRD_PARTY_ORDER_ACTION_TYPE['cancel_single'])
end
def trades
Trade.where('maker_order_id = ? OR taker_order_id = ?', id, id)
end
def funds_used
origin_locked - locked
end
def trigger_event
# skip market type orders, they should not appear on trading-ui
return unless ord_type == 'limit' || state == 'done'
::AMQP::Queue.enqueue_event('private', member&.uid, 'order', for_notify)
end
def side
self.class.name.underscore[-3, 3] == 'ask' ? 'sell' : 'buy'
end
# @deprecated Please use {#side} instead
def kind
self.class.name.underscore[-3, 3]
end
# @deprecated Please use {#created_at} instead
def at
created_at.to_i
end
def for_notify
{
id: id,
market: market_id,
kind: kind,
side: side,
ord_type: ord_type,
price: price&.to_s('F'),
avg_price: avg_price&.to_s('F'),
state: state,
origin_volume: origin_volume.to_s('F'),
remaining_volume: volume.to_s('F'),
executed_volume: (origin_volume - volume).to_s('F'),
at: at,
created_at: created_at.to_i,
updated_at: updated_at.to_i,
trades_count: trades_count
}
end
def to_matching_attributes
{ id: id,
market: market_id,
type: type[-3, 3].downcase.to_sym,
ord_type: ord_type,
volume: volume,
price: price,
locked: locked,
timestamp: created_at.to_i }
end
def as_json_for_events_processor
{ id: id,
member_id: member_id,
member_uid: member.uid,
ask: ask,
bid: bid,
type: type,
ord_type: ord_type,
price: price,
volume: volume,
origin_volume: origin_volume,
market_id: market_id,
maker_fee: maker_fee,
taker_fee: taker_fee,
locked: locked,
state: read_attribute_before_type_cast(:state) }
end
def as_json_for_third_party
{
uuid: uuid,
market_id: market_id,
member_uid: member.uid,
origin_volume: origin_volume,
volume: volume,
price: price,
side: type,
type: ord_type,
created_at: created_at.to_i
}
end
# @deprecated
def round_amount_and_price
self.price = market.round_price(price.to_d) if price
if volume
self.volume = market.round_amount(volume.to_d)
self.origin_volume = origin_volume.present? ? market.round_amount(origin_volume.to_d) : volume
end
end
def record_submit_operations!
transaction do
# Debit main fiat/crypto Liability account.
# Credit locked fiat/crypto Liability account.
Operations::Liability.transfer!(
amount: locked,
currency: currency,
reference: self,
from_kind: :main,
to_kind: :locked,
member_id: member_id
)
end
end
def record_cancel_operations!
transaction do
# Debit locked fiat/crypto Liability account.
# Credit main fiat/crypto Liability account.
Operations::Liability.transfer!(
amount: locked,
currency: currency,
reference: self,
from_kind: :locked,
to_kind: :main,
member_id: member_id
)
end
end
def is_limit_order?
ord_type == 'limit'
end
def member_balance
member.get_account(currency).balance
end
private
def market_order_validations
errors.add(:price, 'must not be present') if price.present?
end
FUSE = '0.9'.to_d
def estimate_required_funds(price_levels)
required_funds = Account::ZERO
expected_volume = volume
until expected_volume.zero? || price_levels.empty?
level_price, level_volume = price_levels.shift
v = [expected_volume, level_volume].min
required_funds += yield level_price, v
expected_volume -= v
end
raise InsufficientMarketLiquidity if expected_volume.nonzero?
required_funds
end
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: orders
#
# id :integer not null, primary key
# uuid :binary(16) not null
# remote_id :string(255)
# bid :string(10) not null
# ask :string(10) not null
# market_id :string(20) not null
# price :decimal(32, 16)
# volume :decimal(32, 16) not null
# origin_volume :decimal(32, 16) not null
# maker_fee :decimal(17, 16) default(0.0), not null
# taker_fee :decimal(17, 16) default(0.0), not null
# state :integer not null
# type :string(8) not null
# member_id :integer not null
# ord_type :string(30) not null
# locked :decimal(32, 16) default(0.0), not null
# origin_locked :decimal(32, 16) default(0.0), not null
# funds_received :decimal(32, 16) default(0.0)
# trades_count :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_orders_on_member_id (member_id)
# index_orders_on_state (state)
# index_orders_on_type_and_market_id (type,market_id)
# index_orders_on_type_and_member_id (type,member_id)
# index_orders_on_type_and_state_and_market_id (type,state,market_id)
# index_orders_on_type_and_state_and_member_id (type,state,member_id)
# index_orders_on_updated_at (updated_at)
# index_orders_on_uuid (uuid) UNIQUE
#

100
app/models/order_ask.rb Normal file
View File

@@ -0,0 +1,100 @@
# encoding: UTF-8
# frozen_string_literal: true
class OrderAsk < Order
scope :matching_rule, -> { order(price: :asc, created_at: :asc) }
class << self
def get_depth(market_id)
where(market_id: market_id, state: :wait)
.where.not(ord_type: :market)
.order(price: :asc)
.group(:price)
.sum(:volume)
.to_a
end
end
# @deprecated
def hold_account
member.get_account(ask)
end
# @deprecated
def hold_account!
Account.lock.find_by!(member_id: member_id, currency_id: ask)
end
def expect_account
member.get_account(bid)
end
def expect_account!
Account.lock.find_by!(member_id: member_id, currency_id: bid)
end
def avg_price
return ::Trade::ZERO if funds_used.zero?
market.round_price(funds_received / funds_used)
end
# @deprecated Please use {income/outcome_currency} in Order model
def currency
Currency.find(ask)
end
def income_currency
bid_currency
end
def outcome_currency
ask_currency
end
def compute_locked
case ord_type
when 'limit'
volume
when 'market'
estimate_required_funds(OrderBid.get_depth(market_id)) {|_p, v| v}
end
end
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: orders
#
# id :integer not null, primary key
# uuid :binary(16) not null
# remote_id :string(255)
# bid :string(10) not null
# ask :string(10) not null
# market_id :string(20) not null
# price :decimal(32, 16)
# volume :decimal(32, 16) not null
# origin_volume :decimal(32, 16) not null
# maker_fee :decimal(17, 16) default(0.0), not null
# taker_fee :decimal(17, 16) default(0.0), not null
# state :integer not null
# type :string(8) not null
# member_id :integer not null
# ord_type :string(30) not null
# locked :decimal(32, 16) default(0.0), not null
# origin_locked :decimal(32, 16) default(0.0), not null
# funds_received :decimal(32, 16) default(0.0)
# trades_count :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_orders_on_member_id (member_id)
# index_orders_on_state (state)
# index_orders_on_type_and_market_id (type,market_id)
# index_orders_on_type_and_member_id (type,member_id)
# index_orders_on_type_and_state_and_market_id (type,state,market_id)
# index_orders_on_type_and_state_and_member_id (type,state,member_id)
# index_orders_on_updated_at (updated_at)
# index_orders_on_uuid (uuid) UNIQUE
#

104
app/models/order_bid.rb Normal file
View File

@@ -0,0 +1,104 @@
# encoding: UTF-8
# frozen_string_literal: true
class OrderBid < Order
LOCKING_BUFFER_FACTOR = '1.1'.to_d
scope :matching_rule, -> { order(price: :desc, created_at: :asc) }
class << self
def get_depth(market_id)
where(market_id: market_id, state: :wait)
.where.not(ord_type: :market)
.order(price: :desc)
.group(:price)
.sum(:volume)
.to_a
end
end
# @deprecated
def hold_account
member.get_account(bid)
end
# @deprecated
def hold_account!
Account.lock.find_by!(member_id: member_id, currency_id: bid)
end
def expect_account
member.get_account(ask)
end
def expect_account!
Account.lock.find_by!(member_id: member_id, currency_id: ask)
end
def avg_price
return ::Trade::ZERO if funds_received.zero?
market.round_price(funds_used / funds_received)
end
# @deprecated Please use {income/outcome_currency} in Order model
def currency
Currency.find(bid)
end
def income_currency
ask_currency
end
def outcome_currency
bid_currency
end
def compute_locked
case ord_type
when 'limit'
price*volume
when 'market'
funds = estimate_required_funds(OrderAsk.get_depth(market_id)) {|p, v| p*v }
# Maximum funds precision defined in Market::FUNDS_PRECISION.
funds.round(Market::FUNDS_PRECISION, BigDecimal::ROUND_UP)
end
end
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: orders
#
# id :integer not null, primary key
# uuid :binary(16) not null
# remote_id :string(255)
# bid :string(10) not null
# ask :string(10) not null
# market_id :string(20) not null
# price :decimal(32, 16)
# volume :decimal(32, 16) not null
# origin_volume :decimal(32, 16) not null
# maker_fee :decimal(17, 16) default(0.0), not null
# taker_fee :decimal(17, 16) default(0.0), not null
# state :integer not null
# type :string(8) not null
# member_id :integer not null
# ord_type :string(30) not null
# locked :decimal(32, 16) default(0.0), not null
# origin_locked :decimal(32, 16) default(0.0), not null
# funds_received :decimal(32, 16) default(0.0)
# trades_count :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_orders_on_member_id (member_id)
# index_orders_on_state (state)
# index_orders_on_type_and_market_id (type,market_id)
# index_orders_on_type_and_member_id (type,member_id)
# index_orders_on_type_and_state_and_market_id (type,state,market_id)
# index_orders_on_type_and_state_and_member_id (type,state,member_id)
# index_orders_on_updated_at (updated_at)
# index_orders_on_uuid (uuid) UNIQUE
#

View File

@@ -0,0 +1,78 @@
# encoding: UTF-8
# frozen_string_literal: true
# TODO: Rename to DepositAddress
class PaymentAddress < ApplicationRecord
include Vault::EncryptedModel
vault_lazy_decrypt!
after_commit :enqueue_address_generation
validates :address, uniqueness: { scope: :wallet_id }, if: :address?
vault_attribute :details, serialize: :json, default: {}
vault_attribute :secret
belongs_to :wallet
belongs_to :member
before_validation do
next if blockchain_api&.case_sensitive?
self.address = address.try(:downcase)
end
before_validation do
next unless address? && blockchain_api&.supports_cash_addr_format?
self.address = CashAddr::Converter.to_cash_address(address)
end
def blockchain_api
BlockchainService.new(wallet.blockchain)
end
def enqueue_address_generation
AMQP::Queue.enqueue(:deposit_coin_address, { member_id: member.id, wallet_id: wallet.id }, { persistent: true })
end
def format_address(format)
format == 'legacy' ? to_legacy_address : to_cash_address
end
def to_legacy_address
CashAddr::Converter.to_legacy_address(address)
end
def to_cash_address
CashAddr::Converter.to_cash_address(address)
end
def trigger_address_event
::AMQP::Queue.enqueue_event('private', member.uid, :deposit_address, type: :create,
currencies: wallet.currencies.codes,
address: address)
end
end
# == Schema Information
# Schema version: 20210128083207
#
# Table name: payment_addresses
#
# id :integer not null, primary key
# member_id :bigint
# wallet_id :bigint
# address :string(95)
# remote :boolean default(FALSE), not null
# secret_encrypted :string(255)
# details_encrypted :string(1024)
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_payment_addresses_on_member_id (member_id)
# index_payment_addresses_on_wallet_id (wallet_id)
#

50
app/models/refund.rb Normal file
View File

@@ -0,0 +1,50 @@
# encoding: UTF-8
# frozen_string_literal: true
class Refund < ApplicationRecord
extend Enumerize
include AASM
include AASM::Locking
belongs_to :deposit, required: true
aasm column: :state, whiny_transitions: false do
state :pending, initial: true
state :processed
state :failed
event :process do
transitions from: :pending, to: :processed
after do
process_refund!
end
end
event :fail do
transitions from: %i[pending processed], to: :failed
end
end
def process_refund!
transaction = WalletService.new(Wallet.deposit.find_by(currency: deposit.currency)).refund!(self)
deposit.refund! if transaction.present?
end
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: refunds
#
# id :bigint not null, primary key
# deposit_id :bigint not null
# state :string(30) not null
# address :string(255) not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_refunds_on_deposit_id (deposit_id)
# index_refunds_on_state (state)
#

View File

@@ -0,0 +1,58 @@
# encoding: UTF-8
# frozen_string_literal: true
class StatsMemberPnl < ApplicationRecord
self.table_name = 'stats_member_pnl'
# == Constants ============================================================
# == Extensions ===========================================================
# == Attributes ===========================================================
# == Relationships ========================================================
belongs_to :currency, required: true, foreign_key: :currency_id
belongs_to :currency, required: true, foreign_key: :pnl_currency_id
belongs_to :member, required: true
# == Validations ==========================================================
validates :total_credit, :total_debit, :total_credit_fees, :total_debit_fees,
:total_credit_value, :total_debit_value,
numericality: { greater_than_or_equal_to: 0 }
# == Scopes ===============================================================
default_scope { order(id: :asc) }
# == Callbacks ============================================================
# == Class Methods ========================================================
# == Instance Methods =====================================================
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: stats_member_pnl
#
# id :bigint not null, primary key
# member_id :integer not null
# pnl_currency_id :string(10) not null
# currency_id :string(10) not null
# total_credit :decimal(48, 16) default(0.0)
# total_credit_fees :decimal(48, 16) default(0.0)
# total_debit_fees :decimal(48, 16) default(0.0)
# total_debit :decimal(48, 16) default(0.0)
# total_credit_value :decimal(48, 16) default(0.0)
# total_debit_value :decimal(48, 16) default(0.0)
# total_balance_value :decimal(48, 16) default(0.0)
# average_balance_price :decimal(48, 16) default(0.0)
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_currency_ids_and_member_id (pnl_currency_id,currency_id,member_id) UNIQUE
#

389
app/models/trade.rb Normal file
View File

@@ -0,0 +1,389 @@
# encoding: UTF-8
# frozen_string_literal: true
require 'peatio/influxdb'
class Trade < ApplicationRecord
# == Constants ============================================================
extend Enumerize
ZERO = '0.0'.to_d
# == Relationships ========================================================
belongs_to :market, required: true
belongs_to :maker_order, class_name: 'Order', foreign_key: :maker_order_id, required: true
belongs_to :taker_order, class_name: 'Order', foreign_key: :taker_order_id, required: true
belongs_to :maker, class_name: 'Member', foreign_key: :maker_id, required: true
belongs_to :taker, class_name: 'Member', foreign_key: :taker_id, required: true
has_many :bonus
# == Validations ==========================================================
validates :price, :amount, :total, numericality: { greater_than_or_equal_to: 0.to_d }
# == Scopes ===============================================================
scope :h24, -> { where('created_at > ?', 24.hours.ago) }
scope :with_market, ->(market) { where(market_id: market) }
scope :last_month, -> { where('created_at > ?', 1.month.ago) }
# == Callbacks ============================================================
after_validation(on: :create) do
# Set taker type before creation
self.taker_type = taker_order&.side
end
after_commit on: :create do
EventAPI.notify ['market', market_id, 'trade_completed'].join('.'), \
Serializers::EventAPI::TradeCompleted.call(self)
end
before_save :rial_total!
# before_create :rial_total!
# == Class Methods ========================================================
class << self
def to_csv
attributes = %w[id price amount maker_order_id taker_order_id market_id maker_id taker_id total created_at updated_at]
CSV.generate(headers: true) do |csv|
csv << attributes
all.each do |trade|
data = attributes[0...-2].map { |attr| trade.send(attr) }
data += attributes[-2..-1].map { |attr| trade.send(attr).iso8601 }
csv << data
end
end
end
def public_from_influx(market, limit = 100, options = {})
trades_query = ['SELECT id, price, amount, total, taker_type, market, created_at FROM trades WHERE market=%{market}']
trades_query << 'AND taker_type=%{type}' if options[:type].present?
trades_query << 'AND created_at>=%{start_time}' if options[:start_time].present?
trades_query << 'AND created_at<=%{end_time}' if options[:end_time].present?
trades_query << 'AND price=%{price_eq}' if options[:price_eq].present?
trades_query << 'AND price>=%{price_gt}' if options[:price_gt].present?
trades_query << 'AND price=%{price_lt}' if options[:price_lt].present?
trades_query << 'ORDER BY desc'
unless limit.to_i.zero?
trades_query << 'LIMIT %{limit}'
options.merge!(limit: limit)
end
Peatio::InfluxDB.client(keyshard: market).query trades_query.join(' '), params: options.merge(market: market) do |_name, _tags, points|
return points.map(&:deep_symbolize_keys!)
end
end
# Low, High, First, Last, sum total (amount * price), sum 24 hours amount and average 24 hours price calculated using VWAP ratio for 24 hours trades
def market_ticker_from_influx(market)
tickers_query = 'SELECT MIN(price), MAX(price), FIRST(price), LAST(price), SUM(total) AS volume, SUM(amount) AS amount, SUM(total) / SUM(amount) AS vwap FROM trades WHERE market=%{market} AND time > now() - 24h'
Peatio::InfluxDB.client(keyshard: market).query tickers_query, params: { market: market } do |_name, _tags, points|
return points.map(&:deep_symbolize_keys!).first
end
end
def trade_from_influx_before_date(market, date)
trades_query = 'SELECT id, price, amount, total, taker_type, market, created_at FROM trades WHERE market=%{market} AND created_at < %{date} ORDER BY DESC LIMIT 1 '
Peatio::InfluxDB.client(keyshard: market).query trades_query, params: { market: market, date: date.to_i } do |_name, _tags, points|
return points.map(&:deep_symbolize_keys!).first
end
end
def trade_from_influx_after_date(market, date)
trades_query = 'SELECT id, price, amount, total, taker_type, market, created_at FROM trades WHERE market=%{market} AND created_at >= %{date} ORDER BY ASC LIMIT 1 '
Peatio::InfluxDB.client(keyshard: market).query trades_query, params: { market: market, date: date.to_i } do |_name, _tags, points|
return points.map(&:deep_symbolize_keys!).first
end
end
def nearest_trade_from_influx(market, date)
res = trade_from_influx_before_date(market, date)
res.blank? ? trade_from_influx_after_date(market, date) : res
end
end
# == Instance Methods =====================================================
def irt_price
rls / total
end
def rial_total!
return self.rls = total if market.quote.code == 'irt'
tether_market = Market.find_by(quote: 'irt', base: 'usdt')
tether_price = tether_market.price_now
self.rls = tether_price * self.total
end
def order_fee(order)
maker_order_id == order.id ? order.maker_fee : order.taker_fee
end
def side(member)
return unless member
order_for_member(member).side
end
def order_for_member(member)
return unless member
if member.id == maker_id
maker_order
elsif member.id == taker_id
taker_order
end
end
def sell_order
[maker_order, taker_order].find { |o| o.side == 'sell' }
end
def buy_order
[maker_order, taker_order].find { |o| o.side == 'buy' }
end
def trigger_event
::AMQP::Queue.enqueue_event("private", maker.uid, "trade", for_notify(maker))
::AMQP::Queue.enqueue_event("private", taker.uid, "trade", for_notify(taker))
::AMQP::Queue.enqueue_event("public", market.id, "trades", {trades: [for_global]})
end
def for_notify(member = nil)
{ id: id,
price: price.to_s || ZERO,
amount: amount.to_s || ZERO,
total: total.to_s || ZERO,
market: market.id,
side: side(member),
taker_type: taker_type,
created_at: created_at.to_i,
order_id: order_for_member(member).id }
end
def for_global
{ tid: id,
taker_type: taker_type,
date: created_at.to_i,
price: price.to_s || ZERO,
amount: amount.to_s || ZERO }
end
def record_complete_operations!
transaction do
record_liability_debit!
record_liability_credit!
record_liability_transfer!
record_revenues!
end
end
def revert_trade!
transaction do
revert_sell_side!
revert_buy_side!
revert_fees!
end
end
def influx_data
{ values: { id: id,
price: price,
amount: amount,
total: total,
taker_type: taker_type,
created_at: created_at.to_i },
tags: { market: market.id } }
end
def write_to_influx
Peatio::InfluxDB.client(keyshard: market_id).write_point(self.class.table_name, influx_data, "ns")
end
private
def record_liability_debit!
seller_outcome = amount
buyer_outcome = total
# Debit locked fiat/crypto Liability account for member who created ask.
Operations::Liability.debit!(
amount: seller_outcome,
currency: sell_order.outcome_currency,
reference: self,
kind: :locked,
member_id: sell_order.member_id,
)
# Debit locked fiat/crypto Liability account for member who created bid.
Operations::Liability.debit!(
amount: buyer_outcome,
currency: buy_order.outcome_currency,
reference: self,
kind: :locked,
member_id: buy_order.member_id,
)
end
def record_liability_credit!
seller_income = total - total * order_fee(sell_order)
buyer_income = amount - amount * order_fee(buy_order)
# Credit main fiat/crypto Liability account for member who created ask.
Operations::Liability.credit!(
amount: buyer_income,
currency: buy_order.income_currency,
reference: self,
kind: :main,
member_id: buy_order.member_id
)
# Credit main fiat/crypto Liability account for member who created bid.
Operations::Liability.credit!(
amount: seller_income,
currency: sell_order.income_currency,
reference: self,
kind: :main,
member_id: sell_order.member_id
)
end
def record_liability_transfer!
# Unlock unused funds.
[maker_order, taker_order].each do |order|
if order.volume.zero? && !order.locked.zero?
Operations::Liability.transfer!(
amount: order.locked,
currency: order.outcome_currency,
reference: self,
from_kind: :locked,
to_kind: :main,
member_id: order.member_id
)
end
end
end
def record_revenues!
seller_fee = total * order_fee(sell_order)
buyer_fee = amount * order_fee(buy_order)
# Credit main fiat/crypto Revenue account.
Operations::Revenue.credit!(
amount: seller_fee,
currency: sell_order.income_currency,
reference: self,
member_id: sell_order.member_id
)
# Credit main fiat/crypto Revenue account.
Operations::Revenue.credit!(
amount: buyer_fee,
currency: buy_order.income_currency,
reference: self,
member_id: buy_order.member_id
)
end
def revert_sell_side!
seller_outcome = amount
seller_income = total - total * order_fee(sell_order)
# Revert Trade for Sell side
# Debit main fiat/crypto Liability account for member who created bid.
Operations::Liability.debit!(
amount: seller_income,
currency: sell_order.income_currency,
reference: self,
kind: :main,
member_id: sell_order.member_id
)
Account.find_by(currency_id: sell_order.income_currency.id, member_id: sell_order.member_id).sub_funds(seller_income)
# Credit main fiat/crypto Liability account for member who created ask.
Operations::Liability.credit!(
amount: seller_outcome,
currency: sell_order.outcome_currency,
reference: self,
kind: :main,
member_id: sell_order.member_id
)
Account.find_by(currency_id: sell_order.outcome_currency.id, member_id: sell_order.member_id).plus_funds(seller_outcome)
end
def revert_buy_side!
buyer_outcome = total
buyer_income = amount - amount * order_fee(buy_order)
# Revert Trade for Buy side
# Debit main fiat/crypto Liability account for member who created ask
Operations::Liability.debit!(
amount: buyer_income,
currency: buy_order.income_currency,
reference: self,
kind: :main,
member_id: buy_order.member_id
)
Account.find_by(currency_id: buy_order.income_currency.id, member_id: buy_order.member_id).sub_funds(buyer_income)
# Credit main fiat/crypto Liability account for member who created bid.
Operations::Liability.credit!(
amount: buyer_outcome,
currency: buy_order.outcome_currency,
reference: self,
kind: :main,
member_id: buy_order.member_id
)
Account.find_by(currency_id: buy_order.outcome_currency.id, member_id: buy_order.member_id).plus_funds(buyer_outcome)
end
def revert_fees!
seller_fee = total * order_fee(sell_order)
buyer_fee = amount * order_fee(buy_order)
# Revert Revenues
Operations::Revenue.debit!(
amount: seller_fee,
currency: sell_order.income_currency,
reference: self,
member_id: sell_order.member_id
)
Operations::Revenue.debit!(
amount: buyer_fee,
currency: buy_order.income_currency,
reference: self,
member_id: buy_order.member_id
)
end
end
# == Schema Information
# Schema version: 20210120133912
#
# Table name: trades
#
# id :integer not null, primary key
# price :decimal(32, 16) not null
# amount :decimal(32, 16) not null
# total :decimal(32, 16) default(0.0), not null
# maker_order_id :integer not null
# taker_order_id :integer not null
# market_id :string(20) not null
# maker_id :integer not null
# taker_id :integer not null
# taker_type :string(20) default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_trades_on_created_at (created_at)
# index_trades_on_maker_id (maker_id)
# index_trades_on_maker_order_id (maker_order_id)
# index_trades_on_market_id_and_created_at (market_id,created_at)
# index_trades_on_taker_id (taker_id)
# index_trades_on_taker_order_id (taker_order_id)
#

137
app/models/trading_fee.rb Normal file
View File

@@ -0,0 +1,137 @@
# encoding: UTF-8
# frozen_string_literal: true
# A trading fee schedule is a complete listing of maker and taker fees.
#
# E.g
# +-----------+---------+---------+---------+---------------------+---------------------+
# | market_id | group | maker | taker | created_at | updated_at |
# +-----------+---------+---------+---------+---------------------+---------------------+
# | any | any | 0.0012 | 0.0012 | 2019-07-31 13:41:00 | 2019-07-31 13:41:00 |
# | any | vip-0 | 0.0011 | 0.0011 | 2019-07-31 13:41:00 | 2019-07-31 13:41:00 |
# | btcusd | any | 0.0011 | 0.0011 | 2019-07-31 13:41:00 | 2019-07-31 13:41:00 |
# | btcusd | vip-0 | 0.001 | 0.001 | 2019-07-31 13:41:00 | 2019-07-31 13:41:00 |
# | btcusd | vip-1 | 0.0009 | 0.001 | 2019-07-31 13:41:00 | 2019-07-31 13:41:00 |
# | btcusd | vip-2 | 0.0007 | 0.0009 | 2019-07-31 13:41:00 | 2019-07-31 13:41:00 |
# +-----------+---------+---------+---------+---------------------+---------------------+
#
# for member with unspecified group and market
# maker fee will be 0.12%;
# taker fee will be 0.12%;
# for member with group vip-0 and with unspecified market
# maker fee will be 0.11%;
# taker fee will be 0.11%;
# for member with market btcusd and with unspecified group
# maker fee will be 0.11%;
# taker fee will be 0.11%;
# for member with group vip-0 and for market btcusd
# maker fee will be 0.1%;
# taker fee will be 0.1%;
# for member with group vip-1 and for market btcusd
# maker fee will be 0.09%;
# taker fee will be 0.1%;
# for member with group vip-2 and for market btcusd
# maker fee will be 0.07%;
# taker fee will be 0.09%;
#
class TradingFee < ApplicationRecord
# == Constants ============================================================
# For fee we define static precision - 4.
FEE_PRECISION = 6
MIN_FEE = 0
MAX_FEE = 0.5
# Default value for group name and market_id in TradingFee table;
ANY = 'any'
# == Attributes ===========================================================
# == Extensions ===========================================================
# == Relationships ========================================================
belongs_to :market, optional: true
# == Validations ==========================================================
validates :group,
presence: true,
uniqueness: { scope: :market_id }
validates :maker,
:taker,
presence: true,
numericality: { greater_than_or_equal_to: MIN_FEE,
less_than_or_equal_to: MAX_FEE }
validates :market_id,
presence: true,
inclusion: { in: ->(_fs){ Market.ids.append(ANY) } }
validates :maker, :taker, precision: { less_than_or_eq_to: FEE_PRECISION }
# == Scopes ===============================================================
# == Callbacks ============================================================
before_create { self.group = self.group.strip.downcase }
after_commit :wipe_cache
# == Class Methods ========================================================
class << self
# Get trading fee for specific order that based on member group and market_id.
# TradingFee record selected with the next priorities:
# 1. both group and market_id match
# 2. group match
# 3. market_id match
# 4. both group and market_id are 'any'
# 5. default (zero fees)
def for(group:, market_id:)
TradingFee
.where(market_id: [market_id, ANY], group: [group, ANY])
.max_by { |fs| fs.weight } || TradingFee.new
end
end
# == Instance Methods =====================================================
# Trading fee suitability expressed in weight.
# Trading fee with the greatest weight selected.
# Group match has greater weight then market_id match.
# E.g. Order for member with group 'vip-0' and market_id 'btcusd'
# (group == 'vip-0' && market_id == 'btcusd') >
# (group == 'vip-0' && market_id == 'any') >
# (group == 'any' && market_id == 'btcusd') >
# (group == 'any' && market_id == 'any')
def weight
(group == 'any' ? 0 : 10) + (market_id == 'any' ? 0 : 1)
end
def wipe_cache
Rails.cache.delete_matched("trading_fees*")
end
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: trading_fees
#
# id :bigint not null, primary key
# market_id :string(20) default("any"), not null
# group :string(32) default("any"), not null
# maker :decimal(7, 6) default(0.0), not null
# taker :decimal(7, 6) default(0.0), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_trading_fees_on_group (group)
# index_trading_fees_on_market_id (market_id)
# index_trading_fees_on_market_id_and_group (market_id,group) UNIQUE
#

66
app/models/transaction.rb Normal file
View File

@@ -0,0 +1,66 @@
class Transaction < ApplicationRecord
# == Constants ============================================================
STATUSES = %w[pending succeed].freeze
# == Attributes ===========================================================
# == Extensions ===========================================================
serialize :data, JSON unless Rails.configuration.database_support_json
# == Relationships ========================================================
belongs_to :reference, polymorphic: true
belongs_to :currency, foreign_key: :currency_id
# == Validations ==========================================================
validates :currency, :amount, :from_address, :to_address, :status, presence: true
validates :status, inclusion: { in: STATUSES }
# == Scopes ===============================================================
# == Callbacks ============================================================
after_initialize :initialize_defaults, if: :new_record?
# TODO: record expenses for succeed transactions
# == Class Methods ========================================================
# == Instance Methods =====================================================
def initialize_defaults
self.status = :pending if status.blank?
end
end
# == Schema Information
# Schema version: 20201207134745
#
# Table name: transactions
#
# id :bigint not null, primary key
# currency_id :string(255) not null
# reference_type :string(255)
# reference_id :bigint
# txid :string(255)
# from_address :string(255)
# to_address :string(255)
# amount :decimal(32, 16) default(0.0), not null
# block_number :integer
# txout :integer
# status :string(255)
# options :json
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_transactions_on_currency_id (currency_id)
# index_transactions_on_currency_id_and_txid (currency_id,txid) UNIQUE
# index_transactions_on_reference_type_and_reference_id (reference_type,reference_id)
# index_transactions_on_txid (txid)
#

73
app/models/transfer.rb Normal file
View File

@@ -0,0 +1,73 @@
# encoding: UTF-8
# frozen_string_literal: true
class Transfer < ApplicationRecord
# == Constants ============================================================
extend Enumerize
CATEGORIES = %w[wire refund purchases commission airdrop].freeze
CATEGORIES_MAPPING = { wire: 1, refund: 2, purchases: 3, commission: 4, airdrop: 5 }.freeze
# == Attributes ===========================================================
enumerize :category, in: CATEGORIES_MAPPING, scope: true
# == Extensions ===========================================================
# == Relationships ========================================================
# Define has_many relation with Operations::{Asset,Expense,Liability,Revenue}.
::Operations::Account::TYPES.map(&:pluralize).each do |op_t|
has_many op_t.to_sym,
class_name: "::Operations::#{op_t.to_s.singularize.camelize}",
as: :reference
end
# == Validations ==========================================================
validates :key, uniqueness: true, presence: true
validates :category, presence: true
validate do
errors.add(:base, 'invalidates accounting equation') unless Operations.validate_accounting_equation(operations)
end
# == Scopes ===============================================================
# == Callbacks ============================================================
before_create { self.key = self.key.strip.downcase }
before_commit on: :create do
update_legacy_balances
end
# == Class Methods ========================================================
# == Instance Methods =====================================================
def update_legacy_balances
liabilities.where.not(member_id: nil).find_each { |l| Operations.update_legacy_balance(l) }
end
def operations
assets + liabilities + revenues + expenses
end
end
# == Schema Information
# Schema version: 20190905050444
#
# Table name: transfers
#
# id :integer not null, primary key
# key :string(30) not null
# category :integer not null
# description :string(255) default("")
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_transfers_on_key (key) UNIQUE
#

82
app/models/trigger.rb Normal file
View File

@@ -0,0 +1,82 @@
# encoding: UTF-8
# frozen_string_literal: true
class Trigger < ApplicationRecord
extend Enumerize
belongs_to :order, required: true
# Enumerized list of statuses supported by trigger
#
# @note
# pending(initial,default)
# Trigger and order were created and persisted in DB.
#
# active
# Trigger was added to triggerbook and waiting for being triggered by trade.
#
# done
# Trigger was triggered by trade and thrown appropriate order.
#
# cancelled
# Trigger was created but order was rejected by system on creation or
# trigger was activated but order was cancelled by user.
#
# (1) (2)
# Pending --------> Active ----------> Done
# | |
# |(3) |(4)
# | |
# '------------> Cancelled
#
# 1 - add to triggerbook and lock order funds
# 2 - triggered by trade
# 3 - reject order on submit
# 4 - cancel order by user
STATES = { pending: 0, active: 100, done: 200, cancelled: 255 }.freeze
# TODO: Order types documentation.
TYPES = {
# Regular order types:
market: 10,
limit: 11,
stop_loss: 20,
stop_loss_limit: 21,
trailing_stop: 30,
trailing_stop_limit: 31,
oco: 41,
# Margin order types:
margin_market: 110,
margin_limit: 111,
margin_stop_loss: 120,
margin_stop_loss_limit: 121,
margin_trailing_stop: 130,
margin_trailing_stop_limit: 131,
margin_oco: 141
}.freeze
enumerize :state, in: STATES, scope: true
enumerize :order_type, in: TYPES, scope: true
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: triggers
#
# id :bigint not null, primary key
# order_id :bigint not null
# order_type :integer unsigned, not null
# value :binary(128) not null
# state :integer default("pending"), unsigned, not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_triggers_on_order_id (order_id)
# index_triggers_on_order_type (order_type)
# index_triggers_on_state (state)
#

View File

@@ -0,0 +1,28 @@
class UserAbility
include CanCan::Ability
def initialize(member)
return if Ability.user_permissions[member.role].nil?
# Iterate through member permissions
Ability.user_permissions[member.role].each do |action, rules|
if rules.kind_of?(Array)
# Iterate through a list of member model access
rules.each do |rule|
# check if rule define attributes
if rule.is_a?(Hash)
model = rule.keys.first
attributes = rule[model].map(&:to_sym)
# example, can :update, Currency, [:visible, :name] (model attributes)
else
model = rule
# example, can :update, Currency
end
can action.to_sym, model == 'all' ? model.to_sym : model.constantize, attributes
end
else
can action.to_sym, rules == 'all' ? rules.to_sym : rules.constantize
end
end
end
end

168
app/models/wallet.rb Normal file
View File

@@ -0,0 +1,168 @@
# encoding: UTF-8
# frozen_string_literal: true
class Wallet < ApplicationRecord
extend Enumerize
serialize :balance, JSON unless Rails.configuration.database_support_json
include Vault::EncryptedModel
vault_lazy_decrypt!
# We use this attribute values rules for wallet kinds:
# 1** - for deposit wallets.
# 2** - for fee wallets.
# 3** - for withdraw wallets (sorted by security hot < warm < cold).
ENUMERIZED_KINDS = { deposit: 100, fee: 200, hot: 310, warm: 320, cold: 330 }.freeze
enumerize :kind, in: ENUMERIZED_KINDS, scope: true
SETTING_ATTRIBUTES = %i[ uri secret ].freeze
SETTING_ATTRIBUTES.each do |attribute|
define_method attribute do
self.settings[attribute.to_s]
end
define_method "#{attribute}=".to_sym do |value|
self.settings = self.settings.merge(attribute.to_s => value)
end
end
NOT_AVAILABLE = 'N/A'.freeze
vault_attribute :settings, serialize: :json, default: {}
belongs_to :blockchain, foreign_key: :blockchain_key, primary_key: :key
has_and_belongs_to_many :currencies
validates :name, presence: true, uniqueness: true
validates :address, presence: true
validates :status, inclusion: { in: %w[active disabled] }
validates :gateway, inclusion: { in: ->(_){ Wallet.gateways.map(&:to_s) } }
validates :max_balance, numericality: { greater_than_or_equal_to: 0 }
scope :active, -> { where(status: :active) }
scope :deposit, -> { where(kind: kinds(deposit: true, values: true)) }
scope :fee, -> { where(kind: kinds(fee: true, values: true)) }
scope :withdraw, -> { where(kind: kinds(withdraw: true, values: true)) }
scope :with_currency, ->(currency) { joins(:currencies).where(currencies: { id: currency }) }
scope :ordered, -> { order(kind: :asc) }
before_validation(on: :create) do
if address.blank? && settings[:uri].present? && currencies.present?
begin
result = generate_settings
rescue StandardError => e
Rails.logger.info { "Cannot generate wallet address and secret error: #{e.message}" }
result = { address: 'changeme', secret: 'changeme' }
ensure
if result.present?
self.address = result[:address]
self.secret = result[:secret]
end
end
end
end
before_validation do
next unless address? && blockchain.blockchain_api.supports_cash_addr_format?
self.address = CashAddr::Converter.to_cash_address(address)
end
class << self
def gateways
Peatio::Wallet.registry.adapters.keys
end
def kinds(options={})
ENUMERIZED_KINDS
.yield_self do |kinds|
case
when options.fetch(:deposit, false)
kinds.select { |_k, v| v / 100 == 1 }
when options.fetch(:fee, false)
kinds.select { |_k, v| v / 100 == 2 }
when options.fetch(:withdraw, false)
kinds.select { |_k, v| v / 100 == 3 }
else
kinds
end
end
.yield_self do |kinds|
case
when options.fetch(:keys, false)
kinds.keys
when options.fetch(:values, false)
kinds.values
else
kinds
end
end
end
def deposit_wallet(currency_id)
Wallet.active.deposit.joins(:currencies).find_by(currencies: { id: currency_id })
end
end
def current_balance(currency = nil)
if currency.present?
WalletService.new(self).load_balance!(currency)
else
currencies.each_with_object({}) do |c, balances|
balances[c.id] = WalletService.new(self).load_balance!(c)
rescue StandardError => e
report_exception(e)
balances[c.id] = NOT_AVAILABLE
end
end
rescue StandardError => e
report_exception(e)
NOT_AVAILABLE
end
def to_wallet_api_settings
settings.compact.deep_symbolize_keys.merge(address: address)
end
def wallet_url
blockchain.explorer_address.gsub('#{address}', address) if blockchain
end
def service
::WalletService.new(self)
end
def generate_settings
service.create_address!('peatio', {})
end
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: wallets
#
# id :integer not null, primary key
# blockchain_key :string(32)
# name :string(64)
# address :string(255) not null
# kind :integer not null
# gateway :string(20) default(""), not null
# settings_encrypted :string(1024)
# balance :json
# max_balance :decimal(32, 16) default(0.0), not null
# status :string(32)
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_wallets_on_kind (kind)
# index_wallets_on_kind_and_currency_id_and_status (kind,status)
# index_wallets_on_status (status)
#

View File

@@ -0,0 +1,51 @@
# encoding: UTF-8
# frozen_string_literal: true
class WhitelistedSmartContract < ApplicationRecord
# == Constants ============================================================
STATES = %w[active disabled].freeze
# == Relationships ========================================================
belongs_to :blockchain, foreign_key: :blockchain_key, primary_key: :key
# == Validations ==========================================================
validates :address, presence: true, uniqueness: { scope: :blockchain_key }
validates :blockchain_key,
presence: true,
inclusion: { in: ->(_) { Blockchain.pluck(:key).map(&:to_s) } }
validates :state, inclusion: { in: STATES }
# == Scopes ===============================================================
scope :active, -> { where(state: :active) }
scope :ordered, -> { order(kind: :asc) }
after_save :update_blockchain
def update_blockchain
blockchain.touch
end
end
# == Schema Information
# Schema version: 20210128144535
#
# Table name: whitelisted_smart_contracts
#
# id :bigint not null, primary key
# description :string(255)
# address :string(255) not null
# state :string(30) not null
# blockchain_key :string(32) not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_whitelisted_smart_contracts_on_address_and_blockchain_key (address,blockchain_key) UNIQUE
#

360
app/models/withdraw.rb Normal file
View File

@@ -0,0 +1,360 @@
# encoding: UTF-8
# frozen_string_literal: true
class Withdraw < ApplicationRecord
STATES = %i[ prepared
rejected
accepted
skipped
processing
succeed
canceled
failed
errored
confirming].freeze
COMPLETED_STATES = %i[succeed rejected canceled failed].freeze
# TODO: skipped errored deleted ???
SUCCEED_PROCESSING_STATES = %i[prepared accepted skipped processing errored confirming succeed].freeze
include AASM
include AASM::Locking
include TIDIdentifiable
include FeeChargeable
extend Enumerize
serialize :error, JSON unless Rails.configuration.database_support_json
serialize :metadata, JSON unless Rails.configuration.database_support_json
TRANSFER_TYPES = { fiat: 100, crypto: 200 }
belongs_to :currency, required: true
belongs_to :member, required: true
# Optional beneficiary association gives ability to support both in-peatio
# beneficiaries and managed by third party application.
belongs_to :beneficiary, optional: true
acts_as_eventable prefix: 'withdraw', on: %i[create update]
after_initialize :initialize_defaults, if: :new_record?
before_validation(on: :create) { self.rid ||= beneficiary.rid if beneficiary.present? }
before_validation { self.completed_at ||= Time.current if completed? }
before_validation { self.transfer_type ||= currency.coin? ? 'crypto' : 'fiat' }
validates :rid, :aasm_state, presence: true
validates :txid, uniqueness: { scope: :currency_id }, if: :txid?
validates :block_number, allow_blank: true, numericality: { greater_than_or_equal_to: 0, only_integer: true }
validates :sum,
presence: true,
numericality: { greater_than_or_equal_to: ->(withdraw) { withdraw.currency.min_withdraw_amount } }
validate do
errors.add(:beneficiary, 'not active') if beneficiary.present? && !beneficiary.active? && !aasm_state.to_sym.in?(COMPLETED_STATES)
end
validate :verify_limits, on: :create
scope :completed, -> { where(aasm_state: COMPLETED_STATES) }
scope :succeed_processing, -> { where(aasm_state: SUCCEED_PROCESSING_STATES) }
scope :last_24_hours, -> { where('created_at > ?', 24.hour.ago) }
scope :last_1_month, -> { where('created_at > ?', 1.month.ago) }
aasm whiny_transitions: false do
state :prepared, initial: true
state :canceled
state :accepted
state :skipped
state :to_reject
state :rejected
state :processing
state :succeed
state :failed
state :errored
state :confirming
event :accept do
transitions from: :prepared, to: :accepted
after do
lock_funds
record_submit_operations!
end
after_commit do
# auto process withdrawal if sum less than limits and WITHDRAW_ADMIN_APPROVE env set to false (not set)
process! if ENV.false?('WITHDRAW_ADMIN_APPROVE') && currency.coin?
end
end
event :cancel do
transitions from: %i[prepared accepted], to: :canceled
after do
unless aasm.from_state == :prepared
unlock_funds
record_cancel_operations!
end
end
end
event :reject do
transitions from: %i[to_reject accepted confirming], to: :rejected
after do
unlock_funds
record_cancel_operations!
end
end
event :process do
transitions from: %i[accepted skipped errored], to: :processing
after :send_coins!
end
event :load do
transitions from: :accepted, to: :confirming do
# Load event is available only for coin withdrawals.
guard do
currency.coin? && txid?
end
end
after_commit do
tx = currency.blockchain_api.fetch_transaction(self)
if tx.present?
success! if tx.status.success?
end
end
end
event :dispatch do
transitions from: :processing, to: :confirming do
# Validate txid presence on coin withdrawal dispatch.
guard do
currency.fiat? || txid?
end
end
end
event :success do
transitions from: %i[confirming errored], to: :succeed do
guard do
currency.fiat? || txid?
end
after do
unlock_and_sub_funds
record_complete_operations!
end
end
end
event :skip do
transitions from: :processing, to: :skipped
end
event :fail do
transitions from: %i[processing confirming skipped errored], to: :failed
after do
unlock_funds
record_cancel_operations!
end
end
event :err do
transitions from: :processing, to: :errored, after: :add_error
end
end
class << self
def sum_query
'SELECT sum(w.sum * c.price) as sum FROM withdraws as w ' \
'INNER JOIN currencies as c ON c.id=w.currency_id ' \
'where w.member_id = ? AND w.aasm_state IN (?) AND w.created_at > ?;'
end
def sanitize_execute_sum_queries(member_id)
squery_24h = ActiveRecord::Base.sanitize_sql_for_conditions([sum_query, member_id, SUCCEED_PROCESSING_STATES, 24.hours.ago])
squery_1m = ActiveRecord::Base.sanitize_sql_for_conditions([sum_query, member_id, SUCCEED_PROCESSING_STATES, 1.month.ago])
sum_withdraws_24_hours = ActiveRecord::Base.connection.exec_query(squery_24h).to_hash.first['sum'].to_d
sum_withdraws_1_month = ActiveRecord::Base.connection.exec_query(squery_1m).to_hash.first['sum'].to_d
[sum_withdraws_24_hours, sum_withdraws_1_month]
end
end
def initialize_defaults
self.metadata = {} if metadata.blank?
end
def account
member&.get_account(currency)
end
def add_error(e)
if error.blank?
update!(error: [{ class: e.class.to_s, message: e.message }])
else
update!(error: error << { class: e.class.to_s, message: e.message })
end
end
def verify_limits
if member.last_change_pass.present? && member.last_change_pass + 24.hours > Time.zone.now && type == 'coin'
return errors.add(:member_id, 'member password changed recently')
end
limits = WithdrawLimit.for(kyc_level: member.level, group: member.group, kind: transfer_type)
# If there are no limits in DB or current user withdraw limit
# has 0.0 for 24 hour and 1 mounth it will skip this checks
return true if limits.limit_24_hour.zero? && limits.limit_1_month.zero?
# Withdraw limits in USD and withdraw sum in currency.
# Convert withdraw sums with price from the currency model.
sum_24_hours, sum_1_month = Withdraw.sanitize_execute_sum_queries(member_id)
errors.add(:member_id, 'reached 24 hours limitation') if sum_24_hours + sum * currency.get_price >= limits.limit_24_hour
errors.add(:member_id, 'reached 1 month limitation') if sum_1_month + sum * currency.get_price >= limits.limit_1_month
end
def blockchain_api
currency.blockchain_api
end
def confirmations
return 0 if block_number.blank?
return blockchain.processed_height - block_number if (blockchain.processed_height - block_number) >= 0
'N/A'
rescue StandardError => e
report_exception(e)
'N/A'
end
def completed?
aasm_state.in?(COMPLETED_STATES.map(&:to_s))
end
def as_json_for_event_api
{ tid: tid,
user: { uid: member.uid, email: member.email },
uid: member.uid,
rid: rid,
currency: currency_id,
amount: amount.to_s('F'),
fee: fee.to_s('F'),
state: aasm_state,
created_at: created_at.iso8601,
updated_at: updated_at.iso8601,
completed_at: completed_at&.iso8601,
blockchain_txid: txid }
end
private
# @deprecated
def lock_funds
account.lock_funds(sum)
end
# @deprecated
def unlock_funds
account.unlock_funds(sum)
end
# @deprecated
def unlock_and_sub_funds
account.unlock_and_sub_funds(sum)
end
def record_submit_operations!
transaction do
# Debit main fiat/crypto Liability account.
# Credit locked fiat/crypto Liability account.
Operations::Liability.transfer!(
amount: sum,
currency: currency,
reference: self,
from_kind: :main,
to_kind: :locked,
member_id: member_id
)
end
end
def record_cancel_operations!
transaction do
# Debit locked fiat/crypto Liability account.
# Credit main fiat/crypto Liability account.
Operations::Liability.transfer!(
amount: sum,
currency: currency,
reference: self,
from_kind: :locked,
to_kind: :main,
member_id: member_id
)
end
end
def record_complete_operations!
transaction do
# Debit locked fiat/crypto Liability account.
Operations::Liability.debit!(
amount: sum,
currency: currency,
reference: self,
kind: :locked,
member_id: member_id
)
# Credit main fiat/crypto Revenue account.
# NOTE: Credit amount = fee.
Operations::Revenue.credit!(
amount: fee,
currency: currency,
reference: self,
member_id: member_id
)
# Debit main fiat/crypto Asset account.
# NOTE: Debit amount = sum - fee.
Operations::Asset.debit!(
amount: amount,
currency: currency,
reference: self
)
end
end
def send_coins!
AMQP::Queue.enqueue(:withdraw_coin, id: id) if currency.coin?
end
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: withdraws
#
# id :integer not null, primary key
# member_id :integer not null
# beneficiary_id :bigint
# currency_id :string(10) not null
# amount :decimal(32, 16) not null
# fee :decimal(32, 16) not null
# txid :string(128)
# aasm_state :string(30) not null
# block_number :integer
# sum :decimal(32, 16) not null
# type :string(30) not null
# transfer_type :integer
# tid :string(64) not null
# rid :string(256) not null
# note :string(256)
# metadata :json
# error :json
# created_at :datetime not null
# updated_at :datetime not null
# completed_at :datetime
#
# Indexes
#
# index_withdraws_on_aasm_state (aasm_state)
# index_withdraws_on_currency_id (currency_id)
# index_withdraws_on_currency_id_and_txid (currency_id,txid) UNIQUE
# index_withdraws_on_member_id (member_id)
# index_withdraws_on_tid (tid)
# index_withdraws_on_type (type)
#

View File

@@ -0,0 +1,90 @@
# frozen_string_literal: true
class WithdrawLimit < ApplicationRecord
# Default value for kyc_level, group name and currency_id in WithdrawLimit table;
ANY = 'any'
enum kind: { fiat: 0, crypto: 1, both: 2 }
# == Attributes ===========================================================
# == Extensions ===========================================================
# == Relationships ========================================================
belongs_to :currency, optional: true
# == Validations ==========================================================
validates :kyc_level,
presence: true,
uniqueness: { scope: %i[group kind] }
validates :group,
presence: true
validates :limit_24_hour,
:limit_1_month,
presence: true
# == Scopes ===============================================================
# == Callbacks ============================================================
before_create { self.group = self.group.strip.downcase }
after_commit :wipe_cache
# == Class Methods ========================================================
class << self
# Get withdrawal limit for specific withdraw that based on member kyc_level and group.
# WithdrawLimit record selected with the next priorities:
# 1. kyc_level, group match
# 2. kyc_level match
# 3. group match
# 5. kyc_level, group are 'any'
# 6. default (zero limits)
def for(kyc_level:, group:, kind:)
WithdrawLimit
.where(kyc_level: [kyc_level, ANY], group: [group, ANY], kind: kind)
.max_by(&:weight) || WithdrawLimit.new
end
end
# == Instance Methods =====================================================
# Withdraw limit suitability expressed in weight.
# Withdraw limit with the greatest weight selected.
# Kyc_level has greater weight then group match.
# E.g Withdrawal for member with kyc_level 2, group 'vip-0''
# (kyc_level == 2 && group == 'vip-0') >
# (kyc_level == 2 && group == 'any') >
# (kyc_level == 'any' && group == 'vip-0') >
# (kyc_level == 'any' && group == 'any') >
def weight
(kyc_level == 'any' ? 0 : 10) + (group == 'any' ? 0 : 1)
end
def wipe_cache
::Rails.cache.delete_matched("withdraw_limits_fees*")
end
end
# == Schema Information
# Schema version: 20201125134745
#
# Table name: withdraw_limits
#
# id :bigint not null, primary key
# group :string(32) default("any"), not null
# kyc_level :string(32) default("any"), not null
# limit_24_hour :decimal(32, 16) default(0.0), not null
# limit_1_month :decimal(32, 16) default(0.0), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_withdraw_limits_on_group (group)
# index_withdraw_limits_on_group_and_kyc_level (group,kyc_level) UNIQUE
# index_withdraw_limits_on_kyc_level (kyc_level)
#

View File

@@ -0,0 +1,67 @@
# encoding: UTF-8
# frozen_string_literal: true
# Deprecated
# TODO: Delete this class and update type column
module Withdraws
class Coin < Withdraw
has_one :blockchain, through: :currency
before_validation do
next unless blockchain_api&.supports_cash_addr_format? && rid?
self.rid = CashAddr::Converter.to_cash_address(rid) if CashAddr::Converter.is_valid?(rid)
end
before_validation do
next if blockchain_api&.case_sensitive?
self.rid = rid.try(:downcase)
self.txid = txid.try(:downcase)
end
validate do
if blockchain_api&.supports_cash_addr_format? && rid?
errors.add(:rid, :invalid) unless CashAddr::Converter.is_valid?(rid)
end
end
def as_json_for_event_api
super.merge blockchain_confirmations: confirmations
end
end
end
# == Schema Information
# Schema version: 20210201100941
#
# Table name: withdraws
#
# id :integer not null, primary key
# member_id :integer not null
# beneficiary_id :bigint
# currency_id :string(10) not null
# amount :decimal(32, 16) not null
# fee :decimal(32, 16) not null
# txid :string(128)
# aasm_state :string(30) not null
# block_number :integer
# sum :decimal(32, 16) not null
# type :string(30) not null
# transfer_type :integer
# tid :string(64) not null
# rid :string(256) not null
# note :string(256)
# metadata :json
# error :json
# created_at :datetime not null
# updated_at :datetime not null
# completed_at :datetime
#
# Indexes
#
# index_withdraws_on_aasm_state (aasm_state)
# index_withdraws_on_currency_id (currency_id)
# index_withdraws_on_currency_id_and_txid (currency_id,txid) UNIQUE
# index_withdraws_on_member_id (member_id)
# index_withdraws_on_tid (tid)
# index_withdraws_on_type (type)
#

View File

@@ -0,0 +1,49 @@
# encoding: UTF-8
# frozen_string_literal: true
# Deprecated
# TODO: Delete this class and update type column
module Withdraws
class Fiat < Withdraw
def initialize(*)
super
#verify_limits
end
end
end
# == Schema Information
# Schema version: 20210201100941
#
# Table name: withdraws
#
# id :integer not null, primary key
# member_id :integer not null
# beneficiary_id :bigint
# currency_id :string(10) not null
# amount :decimal(32, 16) not null
# fee :decimal(32, 16) not null
# txid :string(128)
# aasm_state :string(30) not null
# block_number :integer
# sum :decimal(32, 16) not null
# type :string(30) not null
# transfer_type :integer
# tid :string(64) not null
# rid :string(256) not null
# note :string(256)
# metadata :json
# error :json
# created_at :datetime not null
# updated_at :datetime not null
# completed_at :datetime
#
# Indexes
#
# index_withdraws_on_aasm_state (aasm_state)
# index_withdraws_on_currency_id (currency_id)
# index_withdraws_on_currency_id_and_txid (currency_id,txid) UNIQUE
# index_withdraws_on_member_id (member_id)
# index_withdraws_on_tid (tid)
# index_withdraws_on_type (type)
#