adequate_crypto_address 0.1.10 → 0.2.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.
@@ -15,154 +15,183 @@ module AdequateCryptoAddress
15
15
  -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
16
16
  1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1
17
17
  ].freeze
18
+ GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3].freeze
18
19
 
19
20
  class << self
20
21
  def polymod_step(pre)
21
22
  b = pre >> 25
22
- ((pre & 0x1FFFFFF) << 5) ^ \
23
- (-((b >> 0) & 1) & 0x3b6a57b2) ^ \
24
- (-((b >> 1) & 1) & 0x26508e6d) ^ \
25
- (-((b >> 2) & 1) & 0x1ea119fa) ^ \
26
- (-((b >> 3) & 1) & 0x3d4233dd) ^ \
27
- (-((b >> 4) & 1) & 0x2a1462b3)
23
+ GENERATORS.each_with_index.reduce((pre & 0x1FFFFFF) << 5) do |checksum, (generator, index)|
24
+ checksum ^ (-((b >> index) & 1) & generator)
25
+ end
28
26
  end
29
27
 
30
- # def encode(hrp, data)
31
- # buf = []
32
- # chk = 1
28
+ def decode(input, ignore_length: false, include_encoding: false)
29
+ hrp_string, data_string = split_input(input, ignore_length: ignore_length)
30
+ return nil unless hrp_string
33
31
 
34
- # hrp.unpack('C*').each do |ch|
35
- # return nil if ch < 33 || ch > 126
36
- # return nil if ch >= 'A'.ord && ch <= 'Z'.ord
32
+ hrp, checksum, casing = decode_hrp(hrp_string)
33
+ return nil unless hrp
37
34
 
38
- # chk = polymod_step(chk) ^ (ch >> 5)
39
- # end
35
+ data, checksum, casing = decode_data(data_string, checksum, casing)
36
+ return nil unless data
37
+ return nil if casing.values.all?
40
38
 
41
- # return nil if (hrp.bytesize + 7 + data.size) > 90
39
+ encoding = checksum_encoding(checksum)
40
+ return nil unless encoding
42
41
 
43
- # chk = polymod_step(chk)
44
- # hrp.unpack('C*').each do |ch|
45
- # chk = polymod_step(chk) ^ (ch & 0x1f)
46
- # buf << ch
47
- # end
42
+ decoded = [hrp.pack('C*'), data]
43
+ include_encoding ? decoded << encoding : decoded
44
+ end
48
45
 
49
- # buf << '1'.ord
46
+ # Utility for converting bytes of data between bases. These is used for
47
+ # BIP 173 address encoding/decoding to convert between sequences of bytes
48
+ # representing 8-bit values and groups of 5 bits. Conversions may be padded
49
+ # with trailing 0 bits to the nearest byte boundary. Returns nil if
50
+ # conversion requires padding and pad is false.
51
+ #
52
+ # For example:
53
+ #
54
+ # convert_bits("\xFF\xFF", from_bits: 8, to_bits: 5, pad: true)
55
+ # => "\x1F\x1F\x1F\10"
56
+ #
57
+ # See https://github.com/bitcoin/bitcoin/blob/595a7bab23bc21049526229054ea1fff1a29c0bf/src/utilstrencodings.h#L154
58
+ def convert_bits(chunks, from_bits:, to_bits:, pad:)
59
+ return nil unless valid_chunks?(chunks, from_bits)
60
+
61
+ conversion = {
62
+ from_bits: from_bits,
63
+ to_bits: to_bits,
64
+ output_mask: (1 << to_bits) - 1,
65
+ buffer_mask: (1 << (from_bits + to_bits - 1)) - 1
66
+ }
67
+ buffer = 0
68
+ bits = 0
69
+ output = []
50
70
 
51
- # data.each do |i|
52
- # return nil if (i >> 5) != 0
71
+ chunks.each do |chunk|
72
+ buffer, bits, converted = convert_chunk(chunk, buffer, bits, conversion)
73
+ output.concat(converted)
74
+ end
53
75
 
54
- # chk = polymod_step(chk) ^ i
55
- # buf << CHARSET[i]
56
- # end
76
+ remainder = conversion_remainder(buffer, bits, conversion, pad)
77
+ return nil if remainder == :invalid
57
78
 
58
- # 6.times do
59
- # chk = polymod_step(chk)
60
- # end
79
+ output << remainder if remainder
61
80
 
62
- # chk ^= 1
81
+ output
82
+ end
63
83
 
64
- # 6.times do |i|
65
- # buf << CHARSET[(chk >> ((5 - i) * 5)) & 0x1f]
66
- # end
84
+ private
67
85
 
68
- # buf.pack('C*')
69
- # end
86
+ def split_input(input, ignore_length:)
87
+ input_length = input.bytesize
88
+ return if input_length < 8
89
+ return if input_length > 90 && !ignore_length
70
90
 
71
- # rubocop:disable CyclomaticComplexity,PerceivedComplexity
72
- def decode(input)
73
- chk = 1
74
- input_len = input.bytesize
75
- have_lower = false
76
- have_upper = false
91
+ separator = input.rindex('1')
92
+ return unless separator&.positive?
93
+ return if (input_length - separator - 1) < 6
77
94
 
78
- return nil if input_len < 8 || input_len > 90
95
+ [input[0...separator], input[(separator + 1)..]]
96
+ end
79
97
 
80
- data_len = 0
81
- data_len += 1 while data_len < input_len && input[(input_len - 1) - data_len] != '1'
98
+ def decode_hrp(hrp_string)
99
+ hrp, casing = normalize_hrp(hrp_string)
100
+ return unless hrp
82
101
 
83
- hrp_len = input_len - (1 + data_len)
84
- return nil if hrp_len < 1 || data_len < 6
102
+ [hrp, hrp_checksum(hrp), casing]
103
+ end
85
104
 
105
+ def normalize_hrp(hrp_string)
86
106
  hrp = []
87
- hrp_len.times do |i|
88
- ch = input[i].ord
89
- return nil if ch < 33 || ch > 126
90
-
91
- if ch >= 'a'.ord && ch <= 'z'.ord
92
- have_lower = true
93
- elsif ch >= 'A'.ord && ch <= 'Z'.ord
94
- have_upper = true
95
- ch = (ch - 'A'.ord) + 'a'.ord
96
- end
97
-
98
- hrp << ch
99
- chk = polymod_step(chk) ^ (ch >> 5)
107
+ casing = { lower: false, upper: false }
108
+
109
+ index = 0
110
+ while index < hrp_string.bytesize
111
+ character = hrp_string.getbyte(index)
112
+ character, character_case = normalize_hrp_character(character)
113
+ return unless character
114
+
115
+ casing[character_case] = true if character_case
116
+ hrp << character
117
+ index += 1
100
118
  end
101
119
 
102
- chk = polymod_step(chk)
120
+ [hrp, casing]
121
+ end
103
122
 
104
- hrp_len.times do |i|
105
- chk = polymod_step(chk) ^ (input[i].ord & 0x1f)
123
+ def hrp_checksum(hrp)
124
+ checksum = hrp.reduce(1) do |value, character|
125
+ polymod_step(value) ^ (character >> 5)
106
126
  end
127
+ checksum = polymod_step(checksum)
128
+ hrp.each { |character| checksum = polymod_step(checksum) ^ (character & 0x1f) }
129
+ checksum
130
+ end
107
131
 
132
+ def decode_data(data_string, checksum, casing)
108
133
  data = []
109
- i = hrp_len + 1
110
- while i < input_len
111
- ch = input[i].ord
112
- v = (ch & 0x80) != 0 ? -1 : CHARSET_REV[ch]
113
-
114
- have_lower = true if ch >= 'a'.ord && ch <= 'z'.ord
115
- have_upper = true if ch >= 'A'.ord && ch <= 'Z'.ord
116
- return nil if v == -1
117
-
118
- chk = polymod_step(chk) ^ v
119
- data << v if (i + 6) < input_len
120
- i += 1
134
+ payload_length = data_string.bytesize - 6
135
+
136
+ index = 0
137
+ while index < data_string.bytesize
138
+ character = data_string.getbyte(index)
139
+ value = character.nobits?(0x80) ? CHARSET_REV[character] : -1
140
+ return if value == -1
141
+
142
+ character_case = letter_case(character)
143
+ casing[character_case] = true if character_case
144
+ checksum = polymod_step(checksum) ^ value
145
+ data << value if index < payload_length
146
+ index += 1
121
147
  end
122
148
 
123
- return nil if have_lower && have_upper
124
-
125
- # Accept both Bech32 (chk == 1) and Bech32m (chk == 0x2bc830a3)
126
- return nil unless chk == 1 || chk == 0x2bc830a3
149
+ [data, checksum, casing]
150
+ end
151
+
152
+ def normalize_hrp_character(character)
153
+ return unless character.between?(33, 126)
154
+ return [character, :lower] if character.between?('a'.ord, 'z'.ord)
155
+ return [(character - 'A'.ord) + 'a'.ord, :upper] if character.between?('A'.ord, 'Z'.ord)
127
156
 
128
- [hrp.pack('C*'), data]
157
+ [character, nil]
129
158
  end
130
- # rubocop:enable CyclomaticComplexity,PerceivedComplexity
131
159
 
132
- # Utility for converting bytes of data between bases. These is used for
133
- # BIP 173 address encoding/decoding to convert between sequences of bytes
134
- # representing 8-bit values and groups of 5 bits. Conversions may be padded
135
- # with trailing 0 bits to the nearest byte boundary. Returns nil if
136
- # conversion requires padding and pad is false.
137
- #
138
- # For example:
139
- #
140
- # convert_bits("\xFF\xFF", from_bits: 8, to_bits: 5, pad: true)
141
- # => "\x1F\x1F\x1F\10"
142
- #
143
- # See https://github.com/bitcoin/bitcoin/blob/595a7bab23bc21049526229054ea1fff1a29c0bf/src/utilstrencodings.h#L154
144
- def convert_bits(chunks, from_bits:, to_bits:, pad:)
145
- output_mask = (1 << to_bits) - 1
146
- buffer_mask = (1 << (from_bits + to_bits - 1)) - 1
160
+ def letter_case(character)
161
+ return :lower if character.between?('a'.ord, 'z'.ord)
147
162
 
148
- buffer = 0
149
- bits = 0
163
+ :upper if character.between?('A'.ord, 'Z'.ord)
164
+ end
165
+
166
+ def checksum_encoding(checksum)
167
+ return :bech32 if checksum == 1
168
+
169
+ :bech32m if checksum == 0x2bc830a3
170
+ end
150
171
 
172
+ def valid_chunks?(chunks, from_bits)
173
+ chunks.all? { |chunk| chunk.between?(0, (1 << from_bits) - 1) }
174
+ end
175
+
176
+ def convert_chunk(chunk, buffer, bits, conversion)
177
+ buffer = ((buffer << conversion[:from_bits]) | chunk) & conversion[:buffer_mask]
178
+ bits += conversion[:from_bits]
151
179
  output = []
152
- chunks.each do |chunk|
153
- buffer = ((buffer << from_bits) | chunk) & buffer_mask
154
- bits += from_bits
155
- while bits >= to_bits
156
- bits -= to_bits
157
- output << ((buffer >> bits) & output_mask)
158
- end
180
+
181
+ while bits >= conversion[:to_bits]
182
+ bits -= conversion[:to_bits]
183
+ output << ((buffer >> bits) & conversion[:output_mask])
159
184
  end
160
185
 
161
- output << ((buffer << (to_bits - bits)) & output_mask) if pad && bits > 0
186
+ [buffer, bits, output]
187
+ end
162
188
 
163
- return nil if !pad && (bits >= from_bits || ((buffer << (to_bits - bits)) & output_mask) != 0)
189
+ def conversion_remainder(buffer, bits, conversion, pad)
190
+ return (buffer << (conversion[:to_bits] - bits)) & conversion[:output_mask] if pad && bits.positive?
191
+ return if pad
192
+ return :invalid if bits >= conversion[:from_bits]
164
193
 
165
- output
194
+ :invalid if (buffer << (conversion[:to_bits] - bits)).anybits?(conversion[:output_mask])
166
195
  end
167
196
  end
168
197
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdequateCryptoAddress
4
+ module Utils
5
+ # Monero's Base58 encodes fixed 8-byte blocks into 11 characters (shorter
6
+ # trailing blocks use fewer characters). Reference:
7
+ # https://github.com/monero-project/monero/blob/master/src/common/base58.cpp
8
+ module MoneroBase58
9
+ ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
10
+ FULL_BLOCK_SIZE = 8
11
+ FULL_ENCODED_BLOCK_SIZE = 11
12
+ # Decoded byte count produced by an encoded block of each valid length.
13
+ ENCODED_BLOCK_SIZES = [0, 2, 3, 5, 6, 7, 9, 10, 11].freeze
14
+
15
+ module_function
16
+
17
+ def decode(string)
18
+ return nil if string.nil? || string.empty?
19
+
20
+ full_blocks = string.length / FULL_ENCODED_BLOCK_SIZE
21
+ last_size = string.length % FULL_ENCODED_BLOCK_SIZE
22
+ return nil unless ENCODED_BLOCK_SIZES.include?(last_size)
23
+
24
+ out = []
25
+ full_blocks.times do |i|
26
+ block = decode_block(string[i * FULL_ENCODED_BLOCK_SIZE, FULL_ENCODED_BLOCK_SIZE], FULL_BLOCK_SIZE)
27
+ return nil unless block
28
+
29
+ out.concat(block)
30
+ end
31
+ decode_tail(string, full_blocks, last_size, out)
32
+ end
33
+
34
+ def decode_tail(string, full_blocks, last_size, out)
35
+ return out if last_size.zero?
36
+
37
+ tail = string[full_blocks * FULL_ENCODED_BLOCK_SIZE, last_size]
38
+ block = decode_block(tail, ENCODED_BLOCK_SIZES.index(last_size))
39
+ return nil unless block
40
+
41
+ out.concat(block)
42
+ end
43
+
44
+ # Decodes one Base58 block into exactly `size` big-endian bytes, rejecting
45
+ # unknown characters and values that overflow the target byte width.
46
+ def decode_block(chars, size)
47
+ num = 0
48
+ chars.each_char do |char|
49
+ index = ALPHABET.index(char)
50
+ return nil unless index
51
+
52
+ num = (num * 58) + index
53
+ end
54
+ return nil if num >= (1 << (size * 8))
55
+
56
+ Array.new(size) { |i| (num >> (8 * (size - 1 - i))) & 0xff }
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdequateCryptoAddress
4
+ module Utils
5
+ module Xlm
6
+ CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
7
+
8
+ module_function
9
+
10
+ def decode(input)
11
+ values = input.chars.map { |c| CHARSET.index(c) }
12
+ return nil if values.include?(nil)
13
+
14
+ bytes = convertbits(values, 5, 8, pad: false)
15
+ return nil unless bytes
16
+
17
+ bytes.pack('C*')
18
+ end
19
+
20
+ def convertbits(data, frombits, tobits, pad: true)
21
+ Bech32.convert_bits(data, from_bits: frombits, to_bits: tobits, pad: pad)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AdequateCryptoAddress
4
- VERSION = '0.1.10'
4
+ VERSION = '0.2.0'
5
5
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdequateCryptoAddress
4
+ class Xlm
5
+ attr_reader :address
6
+
7
+ ACCOUNT_VERSION_BYTE = 0x30
8
+ MUXED_VERSION_BYTE = 0x60
9
+
10
+ ACCOUNT_SIZE = 35
11
+ MUXED_SIZE = 43
12
+ MAX_LENGTH = 90 # G-addresses are 56 chars, M-addresses 69
13
+
14
+ def initialize(address)
15
+ @address = address
16
+ end
17
+
18
+ def valid?(_type = nil)
19
+ !address_type.nil?
20
+ end
21
+
22
+ # :account (G...) or :muxed (M...) when valid, otherwise nil.
23
+ def address_type
24
+ decoded = decode_strkey
25
+ return nil unless decoded
26
+
27
+ case decoded.getbyte(0)
28
+ when ACCOUNT_VERSION_BYTE
29
+ :account if valid_strkey?(decoded, ACCOUNT_SIZE)
30
+ when MUXED_VERSION_BYTE
31
+ :muxed if valid_strkey?(decoded, MUXED_SIZE)
32
+ end
33
+ rescue StandardError
34
+ nil
35
+ end
36
+
37
+ private
38
+
39
+ def valid_strkey?(decoded, expected_size)
40
+ return false unless decoded.bytesize == expected_size
41
+
42
+ payload = decoded[0...-2]
43
+ checksum = decoded[-2..]
44
+
45
+ crc16(payload) == checksum
46
+ end
47
+
48
+ def decode_strkey
49
+ return nil if address.to_s.length > MAX_LENGTH
50
+
51
+ Utils::Xlm.decode(address)
52
+ end
53
+
54
+ def crc16(data)
55
+ crc = 0
56
+
57
+ data.each_byte do |byte|
58
+ crc ^= byte << 8
59
+
60
+ 8.times do
61
+ crc =
62
+ if crc.anybits?(0x8000)
63
+ (crc << 1) ^ 0x1021
64
+ else
65
+ crc << 1
66
+ end
67
+ end
68
+ end
69
+
70
+ [crc & 0xffff].pack('v')
71
+ end
72
+ end
73
+
74
+ Stellar = Xlm
75
+ end
@@ -1,35 +1,83 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+
3
5
  module AdequateCryptoAddress
6
+ # Validates Monero addresses by decoding the Monero Base58 payload and
7
+ # verifying the network byte, structural length, and Keccak-256 checksum.
8
+ # References: https://docs.getmonero.org/public-address/standard-address/
9
+ # and https://docs.getmonero.org/public-address/integrated-address/
4
10
  class Xmr
5
- attr_reader :address, :type
11
+ STANDARD_LENGTH = 69 # prefix(1) + spend key(32) + view key(32) + checksum(4)
12
+ INTEGRATED_LENGTH = 77 # standard + payment id(8)
13
+ CHECKSUM_LENGTH = 4
14
+ ENCODED_LENGTHS = [95, 106].freeze
15
+
16
+ NETWORK_PREFIXES = {
17
+ standard: { 18 => :mainnet, 53 => :testnet, 24 => :stagenet },
18
+ integrated: { 19 => :mainnet, 54 => :testnet, 25 => :stagenet },
19
+ subaddress: { 42 => :mainnet, 63 => :testnet, 36 => :stagenet }
20
+ }.freeze
21
+
22
+ attr_reader :address, :type, :network
6
23
 
7
24
  def initialize(address)
8
25
  @address = address
9
- @type = address_type
26
+ @type = detect_type
10
27
  end
11
28
 
12
- def valid?(type = nil)
13
- if type
14
- address_type == type.to_sym
15
- else
16
- !address_type.nil?
17
- end
29
+ def valid?(validated_type = nil)
30
+ return !type.nil? unless validated_type
31
+
32
+ type == validated_type.to_sym
33
+ end
34
+
35
+ # :standard, :integrated, or :subaddress when valid, otherwise nil.
36
+ def address_type
37
+ type
18
38
  end
19
39
 
20
40
  private
21
41
 
22
- def address_type
23
- return nil unless [95, 106].include?(address.size)
42
+ def detect_type
43
+ bytes = decode
44
+ return nil unless bytes
24
45
 
25
- return :monero if pattern_valid?
46
+ category, @network = classify(bytes.first)
47
+ return nil unless category
48
+ return nil unless bytes.length == expected_length(category)
49
+ return nil unless valid_checksum?(bytes)
50
+
51
+ category
26
52
  end
27
53
 
28
- def pattern_valid?
29
- address.size == 95 ?
30
- address.match(/^4|8[0-9AB][1-9A-HJ-NP-Za-km-z]{93}$/) :
31
- address.match(/^4|8[1-9A-HJ-NP-Za-km-z]{105}(?:[1-9A-HJ-NP-Za-km-z]{30})?$/)
54
+ def decode
55
+ return nil unless ENCODED_LENGTHS.include?(address.to_s.length)
56
+
57
+ Utils::MoneroBase58.decode(address)
58
+ end
59
+
60
+ def classify(prefix)
61
+ NETWORK_PREFIXES.each do |category, prefixes|
62
+ network = prefixes[prefix]
63
+ return [category, network] if network
64
+ end
65
+ [nil, nil]
66
+ end
67
+
68
+ def expected_length(category)
69
+ category == :integrated ? INTEGRATED_LENGTH : STANDARD_LENGTH
70
+ end
71
+
72
+ def valid_checksum?(bytes)
73
+ body = bytes[0...-CHECKSUM_LENGTH]
74
+ keccak256(body.pack('C*')).bytes[0, CHECKSUM_LENGTH] == bytes[-CHECKSUM_LENGTH..]
75
+ end
76
+
77
+ def keccak256(data)
78
+ Digest::Keccak.new(256).digest(data)
32
79
  end
33
80
  end
81
+
34
82
  Monero = Xmr
35
83
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'adequate_crypto_address/utils/bech32'
4
4
  require 'adequate_crypto_address/utils/bch'
5
+ require 'adequate_crypto_address/utils/xlm'
6
+ require 'adequate_crypto_address/utils/monero_base58'
5
7
 
6
8
  require 'adequate_crypto_address/altcoin'
7
9
  require 'adequate_crypto_address/bch'
@@ -13,6 +15,10 @@ require 'adequate_crypto_address/zec'
13
15
  require 'adequate_crypto_address/ltc'
14
16
  require 'adequate_crypto_address/ton'
15
17
  require 'adequate_crypto_address/xmr'
18
+ require 'adequate_crypto_address/doge'
19
+ require 'adequate_crypto_address/sol'
20
+ require 'adequate_crypto_address/xlm'
21
+ require 'adequate_crypto_address/ada'
16
22
 
17
23
  module AdequateCryptoAddress
18
24
  class UnknownCurrency < StandardError; end
@@ -23,12 +29,29 @@ module AdequateCryptoAddress
23
29
  end
24
30
 
25
31
  def address(address, currency)
26
- AdequateCryptoAddress.const_get(currency.capitalize).new(address)
27
- rescue NameError
28
- raise UnknownCurrency, "Wrong currency #{currency}"
32
+ klass = currency_class(currency)
33
+ raise UnknownCurrency, "Wrong currency #{currency}" unless klass
34
+
35
+ klass.new(address)
29
36
  end
30
37
 
38
+ # Public contract: returns the detected address type as a Symbol when the
39
+ # address is valid for the currency, or nil when it is not.
31
40
  def address_type(address, currency)
32
- AdequateCryptoAddress.const_get(currency.capitalize).new(address).address_type
41
+ address(address, currency).address_type
42
+ end
43
+
44
+ # Resolve a currency name (string or symbol, any case) to its validator class.
45
+ # Only classes defined directly on this module that implement the validator
46
+ # interface are eligible, so malformed input can never be mistaken for a
47
+ # currency and NoMethodError is never swallowed as UnknownCurrency.
48
+ def currency_class(currency)
49
+ const_name = currency.to_s.capitalize
50
+ return nil unless const_defined?(const_name, false)
51
+
52
+ klass = const_get(const_name)
53
+ return nil unless klass.is_a?(Class) && klass.method_defined?(:valid?)
54
+
55
+ klass
33
56
  end
34
57
  end