Initial commit
This commit is contained in:
237
lib/peatio/ethereum/blockchain.rb
Normal file
237
lib/peatio/ethereum/blockchain.rb
Normal file
@@ -0,0 +1,237 @@
|
||||
module Ethereum
|
||||
class Blockchain < Peatio::Blockchain::Abstract
|
||||
|
||||
UndefinedCurrencyError = Class.new(StandardError)
|
||||
|
||||
TOKEN_EVENT_IDENTIFIER = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
|
||||
SUCCESS = '0x1'
|
||||
FAILED = '0x0'
|
||||
|
||||
DEFAULT_FEATURES = { case_sensitive: false, cash_addr_format: false }.freeze
|
||||
|
||||
def initialize(custom_features = {})
|
||||
@features = DEFAULT_FEATURES.merge(custom_features).slice(*SUPPORTED_FEATURES)
|
||||
@settings = {}
|
||||
end
|
||||
|
||||
def configure(settings = {})
|
||||
# Clean client state during configure.
|
||||
@client = nil
|
||||
@erc20 = []; @eth = []
|
||||
@whitelisted_addresses = if settings[:whitelisted_addresses].present?
|
||||
settings[:whitelisted_addresses].pluck(:address).to_set
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
@settings.merge!(settings.slice(*SUPPORTED_SETTINGS))
|
||||
@settings[:currencies]&.each do |c|
|
||||
if c.dig(:options, :erc20_contract_address).present?
|
||||
@erc20 << c
|
||||
else
|
||||
@eth << c
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_block!(block_number)
|
||||
block_json = client.json_rpc(:eth_getBlockByNumber, ["0x#{block_number.to_s(16)}", true])
|
||||
|
||||
if block_json.blank? || block_json['transactions'].blank?
|
||||
return Peatio::Block.new(block_number, [])
|
||||
end
|
||||
block_json.fetch('transactions').each_with_object([]) do |tx, block_arr|
|
||||
if tx.fetch('input').hex <= 0
|
||||
next if invalid_eth_transaction?(tx)
|
||||
else
|
||||
next if @erc20.find do |c|
|
||||
# Check `to` and `input` options to find erc-20 smart contract contract
|
||||
c.dig(:options, :erc20_contract_address) == normalize_address(tx.fetch('to')) ||
|
||||
c.dig(:options, :erc20_contract_address) == '0x' + tx.fetch('input')[34...74].to_s ||
|
||||
# Check if `to` in whitelisted smart contracts
|
||||
@whitelisted_addresses.include?(tx.fetch('to'))
|
||||
end.blank?
|
||||
|
||||
tx = client.json_rpc(:eth_getTransactionReceipt, [normalize_txid(tx.fetch('hash'))])
|
||||
next if tx.nil? || tx.fetch('to').blank?
|
||||
end
|
||||
|
||||
txs = build_transactions(tx).map do |ntx|
|
||||
Peatio::Transaction.new(ntx)
|
||||
end
|
||||
|
||||
block_arr.append(*txs)
|
||||
end.yield_self { |block_arr| Peatio::Block.new(block_number, block_arr) }
|
||||
rescue Ethereum::Client::Error => e
|
||||
raise Peatio::Blockchain::ClientError, e
|
||||
end
|
||||
|
||||
def latest_block_number
|
||||
client.json_rpc(:eth_blockNumber).to_i(16)
|
||||
rescue Ethereum::Client::Error => e
|
||||
raise Peatio::Blockchain::ClientError, e
|
||||
end
|
||||
|
||||
def load_balance_of_address!(address, currency_id)
|
||||
currency = settings[:currencies].find { |c| c[:id] == currency_id.to_s }
|
||||
raise UndefinedCurrencyError unless currency
|
||||
|
||||
if currency.dig(:options, :erc20_contract_address).present?
|
||||
load_erc20_balance(address, currency)
|
||||
else
|
||||
client.json_rpc(:eth_getBalance, [normalize_address(address), 'latest'])
|
||||
.hex
|
||||
.to_d
|
||||
.yield_self { |amount| convert_from_base_unit(amount, currency) }
|
||||
end
|
||||
rescue Ethereum::Client::Error => e
|
||||
raise Peatio::Blockchain::ClientError, e
|
||||
end
|
||||
|
||||
def fetch_transaction(transaction)
|
||||
currency = settings[:currencies].find { |c| c.fetch(:id) == transaction.currency_id }
|
||||
return if currency.blank?
|
||||
txn_receipt = client.json_rpc(:eth_getTransactionReceipt, [transaction.hash])
|
||||
if currency.in?(@eth)
|
||||
txn_json = client.json_rpc(:eth_getTransactionByHash, [transaction.hash])
|
||||
attributes = {
|
||||
amount: convert_from_base_unit(txn_json.fetch('value').hex, currency),
|
||||
to_address: normalize_address(txn_json['to']),
|
||||
txout: txn_json.fetch('transactionIndex').to_i(16),
|
||||
status: transaction_status(txn_receipt)
|
||||
}
|
||||
else
|
||||
if transaction.txout.present?
|
||||
txn_json = txn_receipt.fetch('logs').find { |log| log['logIndex'].to_i(16) == transaction.txout }
|
||||
else
|
||||
txn_json = txn_receipt.fetch('logs').first
|
||||
end
|
||||
attributes = {
|
||||
amount: convert_from_base_unit(txn_json.fetch('data').hex, currency),
|
||||
to_address: normalize_address('0x' + txn_json.fetch('topics').last[-40..-1]),
|
||||
status: transaction_status(txn_receipt)
|
||||
}
|
||||
end
|
||||
transaction.assign_attributes(attributes)
|
||||
transaction
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_erc20_balance(address, currency)
|
||||
data = abi_encode('balanceOf(address)', normalize_address(address))
|
||||
client.json_rpc(:eth_call, [{ to: contract_address(currency), data: data }, 'latest'])
|
||||
.hex
|
||||
.to_d
|
||||
.yield_self { |amount| convert_from_base_unit(amount, currency) }
|
||||
end
|
||||
|
||||
def client
|
||||
@client ||= Ethereum::Client.new(settings_fetch(:server))
|
||||
end
|
||||
|
||||
def settings_fetch(key)
|
||||
@settings.fetch(key) { raise Peatio::Blockchain::MissingSettingError, key.to_s }
|
||||
end
|
||||
|
||||
def normalize_txid(txid)
|
||||
txid.try(:downcase)
|
||||
end
|
||||
|
||||
def normalize_address(address)
|
||||
address.try(:downcase)
|
||||
end
|
||||
|
||||
def build_transactions(tx_hash)
|
||||
if tx_hash.has_key?('logs')
|
||||
build_erc20_transactions(tx_hash)
|
||||
else
|
||||
build_eth_transactions(tx_hash)
|
||||
end
|
||||
end
|
||||
|
||||
def build_eth_transactions(block_txn)
|
||||
@eth.map do |currency|
|
||||
{ hash: normalize_txid(block_txn.fetch('hash')),
|
||||
amount: convert_from_base_unit(block_txn.fetch('value').hex, currency),
|
||||
from_addresses: [normalize_address(block_txn['from'])],
|
||||
to_address: normalize_address(block_txn['to']),
|
||||
txout: block_txn.fetch('transactionIndex').to_i(16),
|
||||
block_number: block_txn.fetch('blockNumber').to_i(16),
|
||||
currency_id: currency.fetch(:id),
|
||||
status: transaction_status(block_txn) }
|
||||
end
|
||||
end
|
||||
|
||||
def build_erc20_transactions(txn_receipt)
|
||||
# Build invalid transaction for failed withdrawals
|
||||
if transaction_status(txn_receipt) == 'fail' && txn_receipt.fetch('logs').blank?
|
||||
return build_invalid_erc20_transaction(txn_receipt)
|
||||
end
|
||||
|
||||
txn_receipt.fetch('logs').each_with_object([]) do |log, formatted_txs|
|
||||
|
||||
next if log['blockHash'].blank? && log['blockNumber'].blank?
|
||||
next if log.fetch('topics').blank? || log.fetch('topics')[0] != TOKEN_EVENT_IDENTIFIER
|
||||
|
||||
# Skip if ERC20 contract address doesn't match.
|
||||
currencies = @erc20.select { |c| c.dig(:options, :erc20_contract_address) == log.fetch('address') }
|
||||
next if currencies.blank?
|
||||
|
||||
destination_address = normalize_address('0x' + log.fetch('topics').last[-40..-1])
|
||||
|
||||
currencies.each do |currency|
|
||||
formatted_txs << { hash: normalize_txid(txn_receipt.fetch('transactionHash')),
|
||||
amount: convert_from_base_unit(log.fetch('data').hex, currency),
|
||||
from_addresses: [normalize_address(txn_receipt['from'])],
|
||||
to_address: destination_address,
|
||||
txout: log['logIndex'].to_i(16),
|
||||
block_number: txn_receipt.fetch('blockNumber').to_i(16),
|
||||
currency_id: currency.fetch(:id),
|
||||
status: transaction_status(txn_receipt) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def build_invalid_erc20_transaction(txn_receipt)
|
||||
currencies = @erc20.select { |c| c.dig(:options, :erc20_contract_address) == txn_receipt.fetch('to') }
|
||||
return if currencies.blank?
|
||||
|
||||
currencies.each_with_object([]) do |currency, invalid_txs|
|
||||
invalid_txs << { hash: normalize_txid(txn_receipt.fetch('transactionHash')),
|
||||
block_number: txn_receipt.fetch('blockNumber').to_i(16),
|
||||
currency_id: currency.fetch(:id),
|
||||
status: transaction_status(txn_receipt) }
|
||||
end
|
||||
end
|
||||
|
||||
def transaction_status(block_txn)
|
||||
if block_txn.dig('status') == SUCCESS
|
||||
'success'
|
||||
elsif block_txn.dig('status') == FAILED
|
||||
'failed'
|
||||
else
|
||||
'pending'
|
||||
end
|
||||
end
|
||||
|
||||
def invalid_eth_transaction?(block_txn)
|
||||
block_txn.fetch('to').blank? \
|
||||
|| block_txn.fetch('value').hex.to_d <= 0 && block_txn.fetch('input').hex <= 0
|
||||
end
|
||||
|
||||
def contract_address(currency)
|
||||
normalize_address(currency.dig(:options, :erc20_contract_address))
|
||||
end
|
||||
|
||||
def abi_encode(method, *args)
|
||||
'0x' + args.each_with_object(Digest::SHA3.hexdigest(method, 256)[0...8]) do |arg, data|
|
||||
data.concat(arg.gsub(/\A0x/, '').rjust(64, '0'))
|
||||
end
|
||||
end
|
||||
|
||||
def convert_from_base_unit(value, currency)
|
||||
value.to_d / currency.fetch(:base_factor).to_d
|
||||
end
|
||||
end
|
||||
end
|
||||
54
lib/peatio/ethereum/client.rb
Normal file
54
lib/peatio/ethereum/client.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
module Ethereum
|
||||
class Client
|
||||
Error = Class.new(StandardError)
|
||||
|
||||
class ConnectionError < Error; end
|
||||
|
||||
class ResponseError < Error
|
||||
def initialize(code, msg)
|
||||
super "#{msg} (#{code})"
|
||||
end
|
||||
end
|
||||
|
||||
extend Memoist
|
||||
|
||||
def initialize(endpoint, idle_timeout: 5)
|
||||
@json_rpc_endpoint = URI.parse(endpoint)
|
||||
@json_rpc_call_id = 0
|
||||
@path = @json_rpc_endpoint.path.empty? ? "/" : @json_rpc_endpoint.path
|
||||
@idle_timeout = idle_timeout
|
||||
end
|
||||
|
||||
def json_rpc(method, params = [])
|
||||
response = connection.post \
|
||||
@path,
|
||||
{jsonrpc: '2.0', id: rpc_call_id, method: method, params: params}.to_json,
|
||||
{'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json'}
|
||||
response.assert_success!
|
||||
response = JSON.parse(response.body)
|
||||
response['error'].tap { |error| raise ResponseError.new(error['code'], error['message']) if error }
|
||||
response.fetch('result')
|
||||
rescue Faraday::Error => e
|
||||
raise ConnectionError, e
|
||||
rescue StandardError => e
|
||||
raise Error, e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def rpc_call_id
|
||||
@json_rpc_call_id += 1
|
||||
end
|
||||
|
||||
def connection
|
||||
@connection ||= Faraday.new(@json_rpc_endpoint) do |f|
|
||||
f.adapter :net_http_persistent, pool_size: 5, idle_timeout: @idle_timeout
|
||||
end.tap do |connection|
|
||||
unless @json_rpc_endpoint.user.blank?
|
||||
connection.basic_auth(@json_rpc_endpoint.user, @json_rpc_endpoint.password)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
219
lib/peatio/ethereum/wallet.rb
Normal file
219
lib/peatio/ethereum/wallet.rb
Normal file
@@ -0,0 +1,219 @@
|
||||
module Ethereum
|
||||
class Wallet < Peatio::Wallet::Abstract
|
||||
|
||||
DEFAULT_ETH_FEE = { gas_limit: 21_000, gas_price: :standard }.freeze
|
||||
|
||||
DEFAULT_ERC20_FEE = { gas_limit: 90_000, gas_price: :standard }.freeze
|
||||
|
||||
DEFAULT_FEATURES = { skip_deposit_collection: false }.freeze
|
||||
|
||||
GAS_PRICE_THRESHOLDS = { standard: 1, safelow: 0.9, fast: 1.1 }.freeze
|
||||
|
||||
def initialize(custom_features = {})
|
||||
@features = DEFAULT_FEATURES.merge(custom_features).slice(*SUPPORTED_FEATURES)
|
||||
@settings = {}
|
||||
end
|
||||
|
||||
def configure(settings = {})
|
||||
# Clean client state during configure.
|
||||
@client = nil
|
||||
|
||||
@settings.merge!(settings.slice(*SUPPORTED_SETTINGS))
|
||||
|
||||
@wallet = @settings.fetch(:wallet) do
|
||||
raise Peatio::Wallet::MissingSettingError, :wallet
|
||||
end.slice(:uri, :address, :secret)
|
||||
|
||||
@currency = @settings.fetch(:currency) do
|
||||
raise Peatio::Wallet::MissingSettingError, :currency
|
||||
end.slice(:id, :base_factor, :options)
|
||||
end
|
||||
|
||||
def create_address!(options = {})
|
||||
secret = options.fetch(:secret) { PasswordGenerator.generate(64) }
|
||||
secret.yield_self do |password|
|
||||
{ address: normalize_address(client.json_rpc(:personal_newAccount, [password])),
|
||||
secret: password }
|
||||
end
|
||||
rescue Ethereum::Client::Error => e
|
||||
raise Peatio::Wallet::ClientError, e
|
||||
end
|
||||
|
||||
def create_transaction!(transaction, options = {})
|
||||
if @currency.dig(:options, :erc20_contract_address).present?
|
||||
create_erc20_transaction!(transaction)
|
||||
else
|
||||
create_eth_transaction!(transaction, options)
|
||||
end
|
||||
rescue Ethereum::Client::Error => e
|
||||
raise Peatio::Wallet::ClientError, e
|
||||
end
|
||||
|
||||
def prepare_deposit_collection!(transaction, deposit_spread, deposit_currency)
|
||||
# Don't prepare for deposit_collection in case of eth deposit.
|
||||
return [] if deposit_currency.dig(:options, :erc20_contract_address).blank?
|
||||
return [] if deposit_spread.blank?
|
||||
|
||||
options = DEFAULT_ERC20_FEE.merge(deposit_currency.fetch(:options).slice(:gas_limit, :gas_price))
|
||||
|
||||
# options[:gas_price] = calculate_gas_price(options)
|
||||
options[:gas_price] = transaction.currency.optioins['gas_price']
|
||||
|
||||
# We collect fees depending on the number of spread deposit size
|
||||
# Example: if deposit spreads on three wallets need to collect eth fee for 3 transactions
|
||||
fees = convert_from_base_unit(options.fetch(:gas_limit).to_i * options.fetch(:gas_price).to_i)
|
||||
transaction.amount = fees * deposit_spread.size
|
||||
transaction.options = options
|
||||
|
||||
[create_eth_transaction!(transaction)]
|
||||
rescue Ethereum::Client::Error => e
|
||||
raise Peatio::Wallet::ClientError, e
|
||||
end
|
||||
|
||||
def load_balance!
|
||||
if @currency.dig(:options, :erc20_contract_address).present?
|
||||
load_erc20_balance(@wallet.fetch(:address))
|
||||
else
|
||||
client.json_rpc(:eth_getBalance, [normalize_address(@wallet.fetch(:address)), 'latest'])
|
||||
.hex
|
||||
.to_d
|
||||
.yield_self { |amount| convert_from_base_unit(amount) }
|
||||
end
|
||||
rescue Ethereum::Client::Error => e
|
||||
raise Peatio::Wallet::ClientError, e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_erc20_balance(address)
|
||||
data = abi_encode('balanceOf(address)', normalize_address(address))
|
||||
client.json_rpc(:eth_call, [{ to: contract_address, data: data }, 'latest'])
|
||||
.hex
|
||||
.to_d
|
||||
.yield_self { |amount| convert_from_base_unit(amount) }
|
||||
end
|
||||
|
||||
def create_eth_transaction!(transaction, options = {})
|
||||
currency_options = @currency.fetch(:options).slice(:gas_limit, :gas_price)
|
||||
options.merge!(DEFAULT_ETH_FEE, currency_options)
|
||||
|
||||
amount = convert_to_base_unit(transaction.amount)
|
||||
#TODO we should make a logic for fee calculation
|
||||
|
||||
# if transaction.options.present?
|
||||
# options[:gas_price] = transaction.options[:gas_price]
|
||||
# else
|
||||
# options[:gas_price] = calculate_gas_price(options)
|
||||
# end
|
||||
options[:gas_price] = currency_options[:gas_price]
|
||||
|
||||
# Subtract fees from initial deposit amount in case of deposit collection
|
||||
amount -= options.fetch(:gas_limit).to_i * options.fetch(:gas_price).to_i if options.dig(:subtract_fee)
|
||||
|
||||
txid = client.json_rpc(:personal_sendTransaction,
|
||||
[{
|
||||
from: normalize_address(@wallet.fetch(:address)),
|
||||
to: normalize_address(transaction.to_address),
|
||||
value: '0x' + amount.to_s(16),
|
||||
gas: '0x' + options.fetch(:gas_limit).to_i.to_s(16),
|
||||
gasPrice: '0x' + options.fetch(:gas_price).to_i.to_s(16)
|
||||
}.compact, @wallet.fetch(:secret)])
|
||||
|
||||
unless valid_txid?(normalize_txid(txid))
|
||||
raise Ethereum::Client::Error, \
|
||||
"Withdrawal from #{@wallet.fetch(:address)} to #{transaction.to_address} failed."
|
||||
end
|
||||
# Make sure that we return currency_id
|
||||
transaction.currency_id = 'eth' if transaction.currency_id.blank?
|
||||
transaction.amount = convert_from_base_unit(amount)
|
||||
transaction.hash = normalize_txid(txid)
|
||||
transaction.options = options
|
||||
transaction
|
||||
end
|
||||
|
||||
def create_erc20_transaction!(transaction, options = {})
|
||||
currency_options = @currency.fetch(:options).slice(:gas_limit, :gas_price, :erc20_contract_address)
|
||||
options.merge!(DEFAULT_ERC20_FEE, currency_options)
|
||||
|
||||
amount = convert_to_base_unit(transaction.amount)
|
||||
data = abi_encode('transfer(address,uint256)',
|
||||
normalize_address(transaction.to_address),
|
||||
'0x' + amount.to_s(16))
|
||||
#TODO we should make a logic for fee calculation
|
||||
|
||||
# if transaction.options.present?
|
||||
# options[:gas_price] = transaction.options[:gas_price]
|
||||
# else
|
||||
# options[:gas_price] = calculate_gas_price(options)
|
||||
# end
|
||||
options[:gas_price] = currency_options[:gas_price]
|
||||
|
||||
txid = client.json_rpc(:personal_sendTransaction,
|
||||
[{
|
||||
from: normalize_address(@wallet.fetch(:address)),
|
||||
to: options.fetch(:erc20_contract_address),
|
||||
data: data,
|
||||
gas: '0x' + options.fetch(:gas_limit).to_i.to_s(16),
|
||||
gasPrice: '0x' + options.fetch(:gas_price).to_i.to_s(16)
|
||||
}.compact, @wallet.fetch(:secret)])
|
||||
|
||||
unless valid_txid?(normalize_txid(txid))
|
||||
raise Ethereum::Client::Error, \
|
||||
"Withdrawal from #{@wallet.fetch(:address)} to #{transaction.to_address} failed."
|
||||
end
|
||||
transaction.hash = normalize_txid(txid)
|
||||
transaction.options = options
|
||||
transaction
|
||||
end
|
||||
|
||||
def normalize_address(address)
|
||||
address.downcase
|
||||
end
|
||||
|
||||
def normalize_txid(txid)
|
||||
txid.downcase
|
||||
end
|
||||
|
||||
def contract_address
|
||||
normalize_address(@currency.dig(:options, :erc20_contract_address))
|
||||
end
|
||||
|
||||
def valid_txid?(txid)
|
||||
txid.to_s.match?(/\A0x[A-F0-9]{64}\z/i)
|
||||
end
|
||||
|
||||
def abi_encode(method, *args)
|
||||
'0x' + args.each_with_object(Digest::SHA3.hexdigest(method, 256)[0...8]) do |arg, data|
|
||||
data.concat(arg.gsub(/\A0x/, '').rjust(64, '0'))
|
||||
end
|
||||
end
|
||||
|
||||
def convert_from_base_unit(value)
|
||||
value.to_d / @currency.fetch(:base_factor)
|
||||
end
|
||||
|
||||
def convert_to_base_unit(value)
|
||||
x = value.to_d * @currency.fetch(:base_factor)
|
||||
unless (x % 1).zero?
|
||||
raise Peatio::Wallet::ClientError,
|
||||
"Failed to convert value to base (smallest) unit because it exceeds the maximum precision: " \
|
||||
"#{value.to_d} - #{x.to_d} must be equal to zero."
|
||||
end
|
||||
x.to_i
|
||||
end
|
||||
|
||||
def calculate_gas_price(options = { gas_price: :standard })
|
||||
# Get current gas price
|
||||
gas_price = client.json_rpc(:eth_gasPrice, [])
|
||||
Rails.logger.info { "Current gas price #{gas_price.to_i(16)}" }
|
||||
|
||||
# Apply thresholds depending on currency configs by default it will be standard
|
||||
(gas_price.to_i(16) * GAS_PRICE_THRESHOLDS.fetch(options[:gas_price].try(:to_sym), 1)).to_i
|
||||
end
|
||||
|
||||
def client
|
||||
uri = @wallet.fetch(:uri) { raise Peatio::Wallet::MissingSettingError, :uri }
|
||||
@client ||= Client.new(uri, idle_timeout: 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user