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 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: []