rails-crypto-payment 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +51 -0
- data/lib/rails_crypto_payment/evm_rpc.rb +84 -0
- data/lib/rails_crypto_payment/exchange_clients.rb +86 -0
- data/lib/rails_crypto_payment.rb +36 -0
- metadata +72 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3dbe29bd6b1411bb17d8a0d3862169736a663f69ad3f899a8f1f871994fd4903
|
|
4
|
+
data.tar.gz: 246d0ccab0c059f4a9cd9ece86fb60edfc897f3073404e7c151a48328c834dec
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6bad088f7a69036c8c0ae6b090c31563487cf731cea777c4aed3a3776c5dbde30b62038e6a130cb2a626d194f45a9f7f184d0d6988571797f678089c3e4595e9
|
|
7
|
+
data.tar.gz: 239495c620c9dbac8b6987ae694dbde16e66caa50383534db278e17dcc0ae27fd73e7557e72aec6b0322343596c5ceebc14be71489f613dd0cef8ee5f36dde10
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Azmi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# rails-crypto-payment
|
|
2
|
+
|
|
3
|
+
Standalone Ruby library for exact USDT payment amounts and deposit retrieval. No Rails or database dependency. Host application owns credentials, wallet configuration, payment records, polling cursor, expiration, settlement, transaction locking, and jobs.
|
|
4
|
+
|
|
5
|
+
Install dependencies with `bundle install`. Build release with `gem build rails-crypto-payment.gemspec`. Malformed Bybit result data raises `RailsCryptoPayment::Error`, like transport and API failures.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require "rails_crypto_payment"
|
|
9
|
+
|
|
10
|
+
base = RailsCryptoPayment.base_amount(idr: 50_000, rate: "16000") # BigDecimal("3.13")
|
|
11
|
+
reserved = RailsCryptoPayment.available_codes(base: base, reserved_amounts: ["3.130001"])
|
|
12
|
+
code = reserved.sample(random: Random::DEFAULT)
|
|
13
|
+
amount = RailsCryptoPayment.amount(base: base, code: code) # exact six-decimal BigDecimal
|
|
14
|
+
# Persist reservation under database uniqueness constraint; retry another code on conflict.
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`base_amount` rounds quotient upward to two decimals; codes 1..9999 add increments of 0.000001 USDT. `available_codes` excludes only exact amount matches within this range. Application must scope reserved amounts by payment gateway and enforce uniqueness atomically.
|
|
18
|
+
|
|
19
|
+
## EVM transfers (BSC, Plasma, testnets)
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
rpc = RailsCryptoPayment::EvmRpc.new(url: "https://rpc.example", username: ENV["RPC_USER"], password: ENV["RPC_PASS"])
|
|
23
|
+
latest = Integer(rpc.call("eth_blockNumber", []), 16)
|
|
24
|
+
logs = rpc.transfer_logs(from_block: latest - 100, to_block: latest - 12,
|
|
25
|
+
token: "0x55d398326f99059ff775485246999027b3197955", wallets: ["0xYourWallet"])
|
|
26
|
+
logs.each do |log|
|
|
27
|
+
next unless rpc.valid_transfer?(log: log, token: "0x55d398326f99059ff775485246999027b3197955")
|
|
28
|
+
amount = rpc.transfer_amount(log, decimals: 18)
|
|
29
|
+
time = rpc.block_time(log)
|
|
30
|
+
# Match recipient ("0x#{log.fetch('topics').fetch(2).last(40)}"), amount, time and gateway
|
|
31
|
+
# against an unpaid reservation before transactional settlement.
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`transfer_logs` filters token, ERC-20 Transfer topic and recipient wallets. RPC `-32005` range errors split block ranges; single-block errors propagate. `valid_transfer?` confirms successful transaction receipt and matching log. `transfer_amount` uses token decimals (BSC USDT uses 18; app test tokens may use 6). Credentialed RPC requires HTTPS. Transport, HTTP, and malformed-response failures raise `RailsCryptoPayment::Error`; JSON-RPC failures raise `RailsCryptoPayment::EvmRpc::RpcError` with `code`.
|
|
36
|
+
|
|
37
|
+
## Exchange internal deposits
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
binance = RailsCryptoPayment::BinanceDepositClient.new(key: ENV.fetch("BINANCE_KEY"), secret: ENV.fetch("BINANCE_SECRET"))
|
|
41
|
+
row = binance.find_internal_usdt(tx_id: submitted_reference, start_time: from_ms, end_time: to_ms)
|
|
42
|
+
# Check row status, coin, amount and timestamp against reservation before settlement.
|
|
43
|
+
|
|
44
|
+
bybit = RailsCryptoPayment::BybitDepositClient.new(key: ENV.fetch("BYBIT_KEY"), secret: ENV.fetch("BYBIT_SECRET"))
|
|
45
|
+
rows = bybit.internal_deposits(start_time: from_ms)
|
|
46
|
+
# Check each row status, coin, amount and timestamp against unpaid reservations.
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Binance returns matching internal-transfer deposit or `nil`; Bybit paginates and returns all rows. Clients sign requests with provided credentials, never read Rails credentials. Transport, HTTP, malformed-response, and API failures raise `RailsCryptoPayment::Error`.
|
|
50
|
+
|
|
51
|
+
Run all focused checks with `bundle exec ruby -Ilib:test -e 'Dir["test/*_test.rb"].each { |file| require_relative file }'`.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module RailsCryptoPayment
|
|
8
|
+
class EvmRpc
|
|
9
|
+
TRANSFER = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
|
10
|
+
|
|
11
|
+
class RpcError < Error
|
|
12
|
+
attr_reader :code
|
|
13
|
+
|
|
14
|
+
def initialize(code, message)
|
|
15
|
+
@code = code
|
|
16
|
+
super("RPC error #{code}: #{message}")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(url:, username: nil, password: nil)
|
|
21
|
+
@uri = URI(url.to_s)
|
|
22
|
+
raise ArgumentError, "RPC URL must use HTTP or HTTPS" unless %w[http https].include?(@uri.scheme)
|
|
23
|
+
raise ArgumentError, "RPC username and password must be provided together" if username.to_s.empty? != password.to_s.empty?
|
|
24
|
+
raise ArgumentError, "Credentialed RPC URL must use HTTPS" if @uri.scheme != "https" && !username.to_s.empty?
|
|
25
|
+
|
|
26
|
+
@username = username
|
|
27
|
+
@password = password
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(method, params)
|
|
31
|
+
request = Net::HTTP::Post.new(@uri, "Content-Type" => "application/json")
|
|
32
|
+
request.basic_auth(@username, @password) if @username && @password
|
|
33
|
+
request.body = { jsonrpc: "2.0", id: 1, method: method, params: params }.to_json
|
|
34
|
+
response = Net::HTTP.start(@uri.host, @uri.port, use_ssl: @uri.scheme == "https", open_timeout: 5, read_timeout: 15) { |http| http.request(request) }
|
|
35
|
+
raise Error, "RPC HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
36
|
+
|
|
37
|
+
body = JSON.parse(response.body)
|
|
38
|
+
raise RpcError.new(body["error"]["code"], body["error"]["message"]) if body["error"]
|
|
39
|
+
|
|
40
|
+
body.fetch("result")
|
|
41
|
+
rescue Error
|
|
42
|
+
raise
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
raise Error, "RPC request failed: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def transfer_logs(from_block:, to_block:, token:, wallets:)
|
|
48
|
+
raise ArgumentError, "from_block must not exceed to_block" if from_block > to_block
|
|
49
|
+
|
|
50
|
+
topics = wallets.map { |wallet| "0x#{wallet.delete_prefix('0x').downcase.rjust(64, '0')}" }
|
|
51
|
+
call("eth_getLogs", [{ fromBlock: "0x#{from_block.to_s(16)}", toBlock: "0x#{to_block.to_s(16)}",
|
|
52
|
+
address: token, topics: [TRANSFER, nil, topics] }])
|
|
53
|
+
rescue RpcError => e
|
|
54
|
+
raise unless e.code == -32_005 && from_block < to_block
|
|
55
|
+
|
|
56
|
+
middle = (from_block + to_block) / 2
|
|
57
|
+
transfer_logs(from_block: from_block, to_block: middle, token: token, wallets: wallets) +
|
|
58
|
+
transfer_logs(from_block: middle + 1, to_block: to_block, token: token, wallets: wallets)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def valid_transfer?(log:, token:)
|
|
62
|
+
return false if log["removed"] || !log.fetch("address").casecmp?(token) ||
|
|
63
|
+
!log.fetch("topics").first.casecmp?(TRANSFER)
|
|
64
|
+
|
|
65
|
+
receipt = call("eth_getTransactionReceipt", [log.fetch("transactionHash")])
|
|
66
|
+
receipt && receipt["status"] == "0x1" && receipt.fetch("logs").any? do |entry|
|
|
67
|
+
entry["logIndex"] == log["logIndex"] && entry.fetch("address").casecmp?(token) &&
|
|
68
|
+
entry["topics"] == log["topics"] && entry["data"] == log["data"]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def transfer_amount(log, decimals:)
|
|
73
|
+
scale = Integer(decimals)
|
|
74
|
+
raise ArgumentError, "decimals must be nonnegative" if scale.negative?
|
|
75
|
+
|
|
76
|
+
BigDecimal(Integer(log.fetch("data"), 16).to_s) / 10**scale
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def block_time(log)
|
|
80
|
+
block = call("eth_getBlockByNumber", [log.fetch("blockNumber"), false])
|
|
81
|
+
Time.at(Integer(block.fetch("timestamp"), 16)).utc
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module RailsCryptoPayment
|
|
9
|
+
class BinanceDepositClient
|
|
10
|
+
API = URI("https://api.binance.com")
|
|
11
|
+
|
|
12
|
+
def initialize(key:, secret:)
|
|
13
|
+
@key = key.to_s
|
|
14
|
+
@secret = secret.to_s
|
|
15
|
+
raise Error, "Binance API credentials missing" if @key.empty? || @secret.empty?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def find_internal_usdt(tx_id:, start_time:, end_time:)
|
|
19
|
+
params = { coin: "USDT", status: 1, startTime: start_time, endTime: end_time,
|
|
20
|
+
includeSource: true, recvWindow: 5000, timestamp: (Time.now.to_f * 1000).to_i }
|
|
21
|
+
query = URI.encode_www_form(params)
|
|
22
|
+
signature = OpenSSL::HMAC.hexdigest("SHA256", @secret, query)
|
|
23
|
+
uri = URI("#{API}/sapi/v1/capital/deposit/hisrec?#{query}&signature=#{signature}")
|
|
24
|
+
request = Net::HTTP::Get.new(uri, "X-MBX-APIKEY" => @key)
|
|
25
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 15) { |http| http.request(request) }
|
|
26
|
+
raise Error, "Binance API HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
27
|
+
|
|
28
|
+
JSON.parse(response.body).find { |row| row["txId"].to_s.casecmp?(tx_id.to_s) && row["transferType"].to_i == 1 }
|
|
29
|
+
rescue Error
|
|
30
|
+
raise
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
raise Error, "Binance API request failed: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class BybitDepositClient
|
|
37
|
+
API = URI("https://api.bybit.com")
|
|
38
|
+
RECV_WINDOW = "5000"
|
|
39
|
+
|
|
40
|
+
def initialize(key:, secret:)
|
|
41
|
+
@key = key.to_s
|
|
42
|
+
@secret = secret.to_s
|
|
43
|
+
raise Error, "Bybit API credentials missing" if @key.empty? || @secret.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def internal_deposits(start_time:)
|
|
47
|
+
rows = []
|
|
48
|
+
cursor = nil
|
|
49
|
+
loop do
|
|
50
|
+
params = { coin: "USDT", startTime: start_time, limit: 50 }
|
|
51
|
+
params[:cursor] = cursor unless cursor.nil? || cursor.empty?
|
|
52
|
+
result = get("/v5/asset/deposit/query-internal-record", params).fetch("result")
|
|
53
|
+
rows.concat(result.fetch("rows", []))
|
|
54
|
+
cursor = result["nextPageCursor"]
|
|
55
|
+
break if cursor.nil? || cursor.empty?
|
|
56
|
+
end
|
|
57
|
+
rows
|
|
58
|
+
rescue Error
|
|
59
|
+
raise
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
raise Error, "Bybit API response invalid: #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def get(path, params)
|
|
67
|
+
query = URI.encode_www_form(params)
|
|
68
|
+
timestamp = (Time.now.to_f * 1000).to_i.to_s
|
|
69
|
+
signature = OpenSSL::HMAC.hexdigest("SHA256", @secret, "#{timestamp}#{@key}#{RECV_WINDOW}#{query}")
|
|
70
|
+
uri = URI("#{API}#{path}?#{query}")
|
|
71
|
+
request = Net::HTTP::Get.new(uri, "X-BAPI-API-KEY" => @key, "X-BAPI-TIMESTAMP" => timestamp,
|
|
72
|
+
"X-BAPI-RECV-WINDOW" => RECV_WINDOW, "X-BAPI-SIGN" => signature)
|
|
73
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 15) { |http| http.request(request) }
|
|
74
|
+
raise Error, "Bybit API HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
75
|
+
|
|
76
|
+
body = JSON.parse(response.body)
|
|
77
|
+
raise Error, "Bybit API error #{body['retCode']}: #{body['retMsg']}" unless body["retCode"] == 0
|
|
78
|
+
|
|
79
|
+
body
|
|
80
|
+
rescue Error
|
|
81
|
+
raise
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
raise Error, "Bybit API request failed: #{e.message}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module RailsCryptoPayment
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
MAX_UNIQUE_CODE = 9999
|
|
9
|
+
CODE_UNIT = BigDecimal("0.000001")
|
|
10
|
+
|
|
11
|
+
def self.base_amount(idr:, rate:)
|
|
12
|
+
exchange_rate = BigDecimal(rate.to_s)
|
|
13
|
+
raise ArgumentError, "rate must be positive" unless exchange_rate.positive?
|
|
14
|
+
|
|
15
|
+
(BigDecimal(idr.to_s) / exchange_rate).ceil(2)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.amount(base:, code:)
|
|
19
|
+
number = Integer(code)
|
|
20
|
+
raise ArgumentError, "code must be between 1 and #{MAX_UNIQUE_CODE}" unless (1..MAX_UNIQUE_CODE).cover?(number)
|
|
21
|
+
|
|
22
|
+
BigDecimal(base.to_s) + number * CODE_UNIT
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.available_codes(base:, reserved_amounts:)
|
|
26
|
+
baseline = BigDecimal(base.to_s)
|
|
27
|
+
reserved = reserved_amounts.each_with_object({}) do |value, codes|
|
|
28
|
+
code = (BigDecimal(value.to_s) - baseline) / CODE_UNIT
|
|
29
|
+
codes[code.to_i] = true if code.frac.zero? && (1..MAX_UNIQUE_CODE).cover?(code.to_i)
|
|
30
|
+
end
|
|
31
|
+
(1..MAX_UNIQUE_CODE).reject { |code| reserved.key?(code) }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
require_relative "rails_crypto_payment/evm_rpc"
|
|
36
|
+
require_relative "rails_crypto_payment/exchange_clients"
|
metadata
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rails-crypto-payment
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Azmi
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: bigdecimal
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.0'
|
|
40
|
+
description: Framework-independent USDT amount reservation and deposit retrieval for
|
|
41
|
+
Rails payment integrations.
|
|
42
|
+
executables: []
|
|
43
|
+
extensions: []
|
|
44
|
+
extra_rdoc_files: []
|
|
45
|
+
files:
|
|
46
|
+
- LICENSE.txt
|
|
47
|
+
- README.md
|
|
48
|
+
- lib/rails_crypto_payment.rb
|
|
49
|
+
- lib/rails_crypto_payment/evm_rpc.rb
|
|
50
|
+
- lib/rails_crypto_payment/exchange_clients.rb
|
|
51
|
+
homepage: https://github.com/azmi2409/rails-crypto-payment
|
|
52
|
+
licenses:
|
|
53
|
+
- MIT
|
|
54
|
+
metadata: {}
|
|
55
|
+
rdoc_options: []
|
|
56
|
+
require_paths:
|
|
57
|
+
- lib
|
|
58
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '3.1'
|
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
requirements: []
|
|
69
|
+
rubygems_version: 4.0.4
|
|
70
|
+
specification_version: 4
|
|
71
|
+
summary: Exact crypto amounts and exchange/chain deposit clients
|
|
72
|
+
test_files: []
|