Initial commit
This commit is contained in:
23
app/models/ability.rb
Normal file
23
app/models/ability.rb
Normal 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
155
app/models/account.rb
Normal 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
161
app/models/adjustment.rb
Normal 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)
|
||||
#
|
||||
24
app/models/admin_ability.rb
Normal file
24
app/models/admin_ability.rb
Normal 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
|
||||
26
app/models/application_record.rb
Normal file
26
app/models/application_record.rb
Normal 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
216
app/models/beneficiary.rb
Normal 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
71
app/models/blockchain.rb
Normal 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
11
app/models/bonus.rb
Normal 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
|
||||
52
app/models/concerns/fee_chargeable.rb
Normal file
52
app/models/concerns/fee_chargeable.rb
Normal 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
|
||||
23
app/models/concerns/model_caching.rb
Normal file
23
app/models/concerns/model_caching.rb
Normal 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
|
||||
23
app/models/concerns/precision_validator.rb
Normal file
23
app/models/concerns/precision_validator.rb
Normal 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
|
||||
17
app/models/concerns/tid_identifiable.rb
Normal file
17
app/models/concerns/tid_identifiable.rb
Normal 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
249
app/models/currency.rb
Normal 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)
|
||||
#
|
||||
24
app/models/currency_wallet.rb
Normal file
24
app/models/currency_wallet.rb
Normal 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
294
app/models/deposit.rb
Normal 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)
|
||||
#
|
||||
67
app/models/deposit_limit.rb
Normal file
67
app/models/deposit_limit.rb
Normal 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
|
||||
|
||||
|
||||
#
|
||||
63
app/models/deposits/coin.rb
Normal file
63
app/models/deposits/coin.rb
Normal 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)
|
||||
#
|
||||
55
app/models/deposits/fiat.rb
Normal file
55
app/models/deposits/fiat.rb
Normal 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
62
app/models/engine.rb
Normal 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
|
||||
#
|
||||
91
app/models/helpers/reorder_position.rb
Normal file
91
app/models/helpers/reorder_position.rb
Normal 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
|
||||
55
app/models/internal_transfer.rb
Normal file
55
app/models/internal_transfer.rb
Normal 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
34
app/models/job.rb
Normal 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
235
app/models/market.rb
Normal 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
327
app/models/member.rb
Normal 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
96
app/models/operation.rb
Normal 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
67
app/models/operations.rb
Normal 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
|
||||
61
app/models/operations/account.rb
Normal file
61
app/models/operations/account.rb
Normal 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
|
||||
#
|
||||
29
app/models/operations/asset.rb
Normal file
29
app/models/operations/asset.rb
Normal 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)
|
||||
#
|
||||
29
app/models/operations/expense.rb
Normal file
29
app/models/operations/expense.rb
Normal 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)
|
||||
#
|
||||
56
app/models/operations/liability.rb
Normal file
56
app/models/operations/liability.rb
Normal 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)
|
||||
#
|
||||
32
app/models/operations/revenue.rb
Normal file
32
app/models/operations/revenue.rb
Normal 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
415
app/models/order.rb
Normal 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
100
app/models/order_ask.rb
Normal 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
104
app/models/order_bid.rb
Normal 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
|
||||
#
|
||||
78
app/models/payment_address.rb
Normal file
78
app/models/payment_address.rb
Normal 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
50
app/models/refund.rb
Normal 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)
|
||||
#
|
||||
58
app/models/stats_member_pnl.rb
Normal file
58
app/models/stats_member_pnl.rb
Normal 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
389
app/models/trade.rb
Normal 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
137
app/models/trading_fee.rb
Normal 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
66
app/models/transaction.rb
Normal 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
73
app/models/transfer.rb
Normal 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
82
app/models/trigger.rb
Normal 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)
|
||||
#
|
||||
28
app/models/user_ability.rb
Normal file
28
app/models/user_ability.rb
Normal 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
168
app/models/wallet.rb
Normal 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)
|
||||
#
|
||||
51
app/models/whitelisted_smart_contract.rb
Normal file
51
app/models/whitelisted_smart_contract.rb
Normal 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
360
app/models/withdraw.rb
Normal 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)
|
||||
#
|
||||
90
app/models/withdraw_limit.rb
Normal file
90
app/models/withdraw_limit.rb
Normal 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)
|
||||
#
|
||||
67
app/models/withdraws/coin.rb
Normal file
67
app/models/withdraws/coin.rb
Normal 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)
|
||||
#
|
||||
49
app/models/withdraws/fiat.rb
Normal file
49
app/models/withdraws/fiat.rb
Normal 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)
|
||||
#
|
||||
Reference in New Issue
Block a user