xrpl-ruby 0.0.3 → 0.5.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 +4 -4
- data/lib/address-codec/address_codec.rb +22 -4
- data/lib/address-codec/codec.rb +15 -2
- data/lib/address-codec/xrp_codec.rb +29 -2
- data/lib/binary-codec/binary_codec.rb +62 -0
- data/lib/binary-codec/enums/constants.rb +8 -0
- data/lib/binary-codec/enums/definitions.json +3774 -0
- data/lib/binary-codec/enums/definitions.rb +90 -0
- data/lib/binary-codec/enums/fields.rb +104 -0
- data/lib/binary-codec/serdes/binary_parser.rb +183 -0
- data/lib/binary-codec/serdes/binary_serializer.rb +93 -0
- data/lib/binary-codec/serdes/bytes_list.rb +47 -0
- data/lib/binary-codec/types/account_id.rb +60 -0
- data/lib/binary-codec/types/amount.rb +304 -0
- data/lib/binary-codec/types/blob.rb +41 -0
- data/lib/binary-codec/types/currency.rb +116 -0
- data/lib/binary-codec/types/hash.rb +106 -0
- data/lib/binary-codec/types/issue.rb +50 -0
- data/lib/binary-codec/types/path_set.rb +93 -0
- data/lib/binary-codec/types/serialized_type.rb +157 -0
- data/lib/binary-codec/types/st_array.rb +71 -0
- data/lib/binary-codec/types/st_object.rb +157 -0
- data/lib/binary-codec/types/uint.rb +166 -0
- data/lib/binary-codec/types/vector256.rb +53 -0
- data/lib/binary-codec/types/xchain_bridge.rb +47 -0
- data/lib/binary-codec/utilities.rb +98 -0
- data/lib/core/base_58_xrp.rb +2 -0
- data/lib/core/base_x.rb +10 -0
- data/lib/core/core.rb +79 -6
- data/lib/core/utilities.rb +38 -0
- data/lib/key-pairs/ed25519.rb +64 -0
- data/lib/key-pairs/key_pairs.rb +92 -0
- data/lib/key-pairs/secp256k1.rb +116 -0
- data/lib/wallet/wallet.rb +117 -0
- data/lib/xrpl-ruby.rb +32 -1
- metadata +44 -3
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BinaryCodec
|
|
4
|
+
|
|
5
|
+
# Write an 8-bit unsigned integer
|
|
6
|
+
def self.write_uint8(array, value, offset = 0)
|
|
7
|
+
array[offset] = value & 0xFF
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Read an unsigned 16-bit integer in big-endian format
|
|
11
|
+
def self.read_uint16be(array, offset = 0)
|
|
12
|
+
(array[offset] << 8) + array[offset + 1]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Write a 16-bit unsigned integer in big-endian format
|
|
16
|
+
def self.write_uint16be(array, value, offset = 0)
|
|
17
|
+
array[offset] = (value >> 8) & 0xFF
|
|
18
|
+
array[offset + 1] = value & 0xFF
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Read an unsigned 32-bit integer in big-endian format
|
|
22
|
+
def self.read_uint32be(array, offset = 0)
|
|
23
|
+
(array[offset] << 24) + (array[offset + 1] << 16) +
|
|
24
|
+
(array[offset + 2] << 8) + array[offset + 3]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Write an unsigned 32-bit integer to a buffer in big-endian format
|
|
28
|
+
def self.write_uint32be(buffer, value, offset = 0)
|
|
29
|
+
buffer[offset] = (value >> 24) & 0xFF
|
|
30
|
+
buffer[offset + 1] = (value >> 16) & 0xFF
|
|
31
|
+
buffer[offset + 2] = (value >> 8) & 0xFF
|
|
32
|
+
buffer[offset + 3] = value & 0xFF
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Compare two byte arrays
|
|
36
|
+
def self.equal(array1, array2)
|
|
37
|
+
return false unless array1.length == array2.length
|
|
38
|
+
array1 == array2
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Compare two arrays of any type
|
|
42
|
+
def self.compare(array1, array2)
|
|
43
|
+
raise 'Cannot compare arrays of different length' if array1.length != array2.length
|
|
44
|
+
|
|
45
|
+
array1.each_with_index do |value, i|
|
|
46
|
+
return 1 if value > array2[i]
|
|
47
|
+
return -1 if value < array2[i]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Compares two 8-bit aligned arrays
|
|
54
|
+
def self.compare8(array1, array2)
|
|
55
|
+
compare(array1, array2)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Compares two 16-bit aligned arrays
|
|
59
|
+
def self.compare16(array1, array2)
|
|
60
|
+
raise 'Array lengths must be even for 16-bit alignment' unless (array1.length % 2).zero? && (array2.length % 2).zero?
|
|
61
|
+
|
|
62
|
+
array1.pack('C*').unpack('n*') <=> array2.pack('C*').unpack('n*')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Compares two 32-bit aligned arrays
|
|
66
|
+
def self.compare32(array1, array2)
|
|
67
|
+
raise 'Array lengths must be divisible by 4 for 32-bit alignment' unless (array1.length % 4).zero? && (array2.length % 4).zero?
|
|
68
|
+
|
|
69
|
+
array1.pack('C*').unpack('N*') <=> array2.pack('C*').unpack('N*')
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Determine if an array is 16-bit aligned
|
|
73
|
+
def self.aligned16?(array)
|
|
74
|
+
(array.length % 2).zero?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Determine if an array is 32-bit aligned
|
|
78
|
+
def self.aligned32?(array)
|
|
79
|
+
(array.length % 4).zero?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# TODO: Marked for overhaul
|
|
83
|
+
def self.is_valid_x_address?(x_address)
|
|
84
|
+
return false unless x_address.is_a?(String) && x_address.start_with?('X')
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
decoded = decode_x_address(x_address)
|
|
88
|
+
return false if decoded[:account_id].nil? || decoded[:account_id].length != 20
|
|
89
|
+
|
|
90
|
+
tag = decoded[:tag]
|
|
91
|
+
return false if tag && (tag < 0 || tag > MAX_32_BIT_UNSIGNED_INT)
|
|
92
|
+
|
|
93
|
+
true
|
|
94
|
+
rescue StandardError
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/core/base_58_xrp.rb
CHANGED
data/lib/core/base_x.rb
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Core
|
|
2
4
|
|
|
3
5
|
class BaseX
|
|
6
|
+
# Initializes a new BaseX instance with the given alphabet.
|
|
7
|
+
# @param alphabet [String] The alphabet to use for encoding and decoding.
|
|
4
8
|
def initialize(alphabet)
|
|
5
9
|
@alphabet = alphabet
|
|
6
10
|
@base = alphabet.length
|
|
@@ -8,6 +12,9 @@ module Core
|
|
|
8
12
|
alphabet.chars.each_with_index { |char, index| @alphabet_map[char] = index }
|
|
9
13
|
end
|
|
10
14
|
|
|
15
|
+
# Encodes a byte array into a string using the alphabet.
|
|
16
|
+
# @param buffer [String] The byte string to encode.
|
|
17
|
+
# @return [String] The encoded string.
|
|
11
18
|
def encode(buffer)
|
|
12
19
|
return @alphabet[0] if buffer.empty?
|
|
13
20
|
|
|
@@ -30,6 +37,9 @@ module Core
|
|
|
30
37
|
digits.reverse.map { |digit| @alphabet[digit] }.join
|
|
31
38
|
end
|
|
32
39
|
|
|
40
|
+
# Decodes a string into a byte string using the alphabet.
|
|
41
|
+
# @param string [String] The string to decode.
|
|
42
|
+
# @return [String] The decoded byte string.
|
|
33
43
|
def decode(string)
|
|
34
44
|
return '' if string.empty?
|
|
35
45
|
|
data/lib/core/core.rb
CHANGED
|
@@ -1,23 +1,70 @@
|
|
|
1
|
-
#
|
|
2
|
-
require_relative 'base_x'
|
|
3
|
-
require_relative 'base_58_xrp'
|
|
1
|
+
# frozen_string_literal: true
|
|
4
2
|
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
# Returns a random byte array of the given size.
|
|
6
|
+
# @param size [Integer] The number of bytes to generate.
|
|
7
|
+
# @return [Array<Integer>] The generated random byte array.
|
|
8
|
+
def random_bytes(size)
|
|
9
|
+
SecureRandom.random_bytes(size).bytes
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Converts a byte array to a hex string.
|
|
13
|
+
# @param bytes [Array<Integer>] The byte array to convert.
|
|
14
|
+
# @return [String] The hex string.
|
|
5
15
|
def bytes_to_hex(bytes)
|
|
6
16
|
bytes.pack('C*').unpack1('H*').upcase
|
|
7
17
|
end
|
|
8
|
-
|
|
18
|
+
# Converts a hex string to a byte array.
|
|
19
|
+
# @param hex [String] The hex string to convert.
|
|
20
|
+
# @return [Array<Integer>] The byte array.
|
|
9
21
|
def hex_to_bytes(hex)
|
|
22
|
+
raise ArgumentError, 'Invalid hex string' unless valid_hex?(hex)
|
|
10
23
|
[hex].pack('H*').bytes
|
|
11
24
|
end
|
|
12
25
|
|
|
26
|
+
# Converts a binary string to a hex string.
|
|
27
|
+
# @param bin [String] The binary string to convert.
|
|
28
|
+
# @return [String] The hex string.
|
|
29
|
+
def bin_to_hex(bin)
|
|
30
|
+
bin.unpack("H*").first.upcase
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Converts a hex string to a binary string.
|
|
34
|
+
# @param hex [String] The hex string to convert.
|
|
35
|
+
# @return [String] The binary string.
|
|
13
36
|
def hex_to_bin(hex)
|
|
37
|
+
raise ArgumentError, 'Invalid hex string' unless valid_hex?(hex)
|
|
14
38
|
[hex].pack("H*")
|
|
15
39
|
end
|
|
16
40
|
|
|
17
|
-
|
|
18
|
-
|
|
41
|
+
# Converts a hex string to a string with the given encoding.
|
|
42
|
+
# @param hex [String] The hex string to convert.
|
|
43
|
+
# @param encoding [String] The encoding to use.
|
|
44
|
+
# @return [String] The decoded string.
|
|
45
|
+
def hex_to_string(hex, encoding = 'utf-8')
|
|
46
|
+
raise ArgumentError, 'Invalid hex string' unless valid_hex?(hex)
|
|
47
|
+
hex_to_bin(hex).force_encoding(encoding).encode('utf-8')
|
|
19
48
|
end
|
|
20
49
|
|
|
50
|
+
# Converts a string to a hex string.
|
|
51
|
+
# @param string [String] The string to convert.
|
|
52
|
+
# @return [String] The hex string.
|
|
53
|
+
def string_to_hex(string)
|
|
54
|
+
string.unpack1('H*').upcase
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Checks if a string is a valid hex string.
|
|
58
|
+
# @param str [String] The string to check.
|
|
59
|
+
# @return [Boolean] True if the string is a valid hex string, false otherwise.
|
|
60
|
+
def valid_hex?(str)
|
|
61
|
+
str =~ /\A[0-9a-fA-F]*\z/ && str.length.even?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Checks if a byte array has the expected length.
|
|
65
|
+
# @param bytes [Array<Integer>, String] The byte array or string to check.
|
|
66
|
+
# @param expected_length [Integer] The expected length.
|
|
67
|
+
# @return [Boolean] True if the length matches, false otherwise.
|
|
21
68
|
def check_byte_length(bytes, expected_length)
|
|
22
69
|
if bytes.respond_to?(:byte_length)
|
|
23
70
|
bytes.byte_length == expected_length
|
|
@@ -26,12 +73,38 @@ def check_byte_length(bytes, expected_length)
|
|
|
26
73
|
end
|
|
27
74
|
end
|
|
28
75
|
|
|
76
|
+
# Concatenates multiple arguments into a single array.
|
|
77
|
+
# @param args [Array] The arguments to concatenate.
|
|
78
|
+
# @return [Array] The concatenated array.
|
|
29
79
|
def concat_args(*args)
|
|
30
80
|
args.flat_map do |arg|
|
|
31
81
|
is_scalar?(arg) ? [arg] : arg.to_a
|
|
32
82
|
end
|
|
33
83
|
end
|
|
34
84
|
|
|
85
|
+
# Checks if a value is a scalar.
|
|
86
|
+
# @param val [Object] The value to check.
|
|
87
|
+
# @return [Boolean] True if the value is a numeric scalar, false otherwise.
|
|
35
88
|
def is_scalar?(val)
|
|
36
89
|
val.is_a?(Numeric)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Converts an integer to a byte array.
|
|
93
|
+
# @param number [Integer] The integer to convert.
|
|
94
|
+
# @param width [Integer] The number of bytes in the result.
|
|
95
|
+
# @param byteorder [Symbol] The byte order (:big or :little).
|
|
96
|
+
# @return [Array<Integer>] The byte array.
|
|
97
|
+
def int_to_bytes(number, width = 1, byteorder = :big)
|
|
98
|
+
bytes = []
|
|
99
|
+
while number > 0
|
|
100
|
+
bytes << (number & 0xFF)
|
|
101
|
+
number >>= 8
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
while bytes.size < width
|
|
105
|
+
bytes << 0
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
bytes.reverse! if byteorder == :big
|
|
109
|
+
bytes
|
|
37
110
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Core
|
|
4
|
+
|
|
5
|
+
class Utilities
|
|
6
|
+
|
|
7
|
+
@address_codec = nil
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@address_codec = AddressCodec.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Returns the singleton instance of the Utilities class.
|
|
14
|
+
# @return [Utilities] The singleton instance.
|
|
15
|
+
def self.instance
|
|
16
|
+
@@instance ||= new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Checks if a string is a valid X-address.
|
|
20
|
+
# @param x_address [String] The X-address to check.
|
|
21
|
+
# @return [Boolean] True if the string is a valid X-address, false otherwise.
|
|
22
|
+
def is_x_address?(x_address)
|
|
23
|
+
return false unless x_address.is_a?(String) && x_address.start_with?('X')
|
|
24
|
+
|
|
25
|
+
begin
|
|
26
|
+
decoded = @address_codec.decode_x_address(x_address)
|
|
27
|
+
return false if decoded[:account_id].nil? || decoded[:account_id].length != 20
|
|
28
|
+
|
|
29
|
+
tag = decoded[:tag]
|
|
30
|
+
return false if tag && (tag < 0 || tag > MAX_32_BIT_UNSIGNED_INT)
|
|
31
|
+
|
|
32
|
+
true
|
|
33
|
+
rescue StandardError
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ed25519'
|
|
4
|
+
|
|
5
|
+
module KeyPairs
|
|
6
|
+
# Ed25519 implementation for XRPL key pairs.
|
|
7
|
+
module Ed25519
|
|
8
|
+
# Derives a key pair from a seed.
|
|
9
|
+
# @param seed [Array<Integer>] 16 bytes of seed entropy.
|
|
10
|
+
# @return [Hash] A hash containing :public_key and :private_key (hex strings).
|
|
11
|
+
def self.derive_key_pair(seed)
|
|
12
|
+
# XRPL Ed25519 uses the SHA512 of the 16-byte seed as the 32-byte entropy for the signing key.
|
|
13
|
+
seed_bytes = seed.is_a?(Array) ? seed.pack('C*') : seed
|
|
14
|
+
hash = Digest::SHA512.digest(seed_bytes)[0...32]
|
|
15
|
+
signing_key = ::Ed25519::SigningKey.new(hash)
|
|
16
|
+
|
|
17
|
+
# Public key in XRPL is 0xED followed by 32 bytes of public key.
|
|
18
|
+
public_key = [0xED].pack('C') + signing_key.verify_key.to_bytes
|
|
19
|
+
|
|
20
|
+
# Private key in XRPL is the 32-byte hash.
|
|
21
|
+
{
|
|
22
|
+
public_key: public_key.unpack1('H*').upcase,
|
|
23
|
+
private_key: hash.unpack1('H*').upcase
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Signs a message with a private key.
|
|
28
|
+
# @param message [Array<Integer>, String] The message to sign.
|
|
29
|
+
# @param private_key [String] The private key (32-byte hash as hex).
|
|
30
|
+
# @return [String] The signature (hex string).
|
|
31
|
+
def self.sign(message, private_key)
|
|
32
|
+
msg_bytes = message.is_a?(String) ? [message].pack('H*') : message.pack('C*')
|
|
33
|
+
key_bytes = [private_key].pack('H*')
|
|
34
|
+
signing_key = ::Ed25519::SigningKey.new(key_bytes)
|
|
35
|
+
|
|
36
|
+
signature = signing_key.sign(msg_bytes)
|
|
37
|
+
signature.unpack1('H*').upcase
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Verifies a signature.
|
|
41
|
+
# @param message [Array<Integer>, String] The message.
|
|
42
|
+
# @param signature [String] The signature (hex string).
|
|
43
|
+
# @param public_key [String] The public key (33-byte hex, starts with ED).
|
|
44
|
+
# @return [Boolean] True if the signature is valid.
|
|
45
|
+
def self.verify(message, signature, public_key)
|
|
46
|
+
msg_bytes = message.is_a?(String) ? hex_to_bin(message) : message.pack('C*')
|
|
47
|
+
sig_bytes = [signature].pack('H*')
|
|
48
|
+
|
|
49
|
+
# Strip the 0xED prefix from the public key
|
|
50
|
+
pub_bytes = [public_key].pack('H*')
|
|
51
|
+
if pub_bytes[0].ord != 0xED
|
|
52
|
+
raise ArgumentError, "Invalid Ed25519 public key prefix"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
verify_key = ::Ed25519::VerifyKey.new(pub_bytes[1..-1])
|
|
56
|
+
begin
|
|
57
|
+
verify_key.verify(sig_bytes, msg_bytes)
|
|
58
|
+
true
|
|
59
|
+
rescue ::Ed25519::VerifyError
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module KeyPairs
|
|
6
|
+
# Main entry point for XRPL key pair operations.
|
|
7
|
+
class KeyPairs
|
|
8
|
+
def initialize
|
|
9
|
+
@address_codec = AddressCodec::AddressCodec.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Generates a new seed.
|
|
13
|
+
# @param entropy [Array<Integer>, nil] 16 bytes of entropy.
|
|
14
|
+
# @param type [String] The seed type ('secp256k1' or 'ed25519').
|
|
15
|
+
# @return [String] The encoded seed string.
|
|
16
|
+
def generate_seed(entropy = nil, type = 'secp256k1')
|
|
17
|
+
entropy ||= SecureRandom.random_bytes(16).bytes
|
|
18
|
+
@address_codec.encode_seed(entropy, type)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Derives a key pair from an encoded seed.
|
|
22
|
+
# @param seed [String] The encoded seed string.
|
|
23
|
+
# @param options [Hash] Options including :account_index (for secp256k1).
|
|
24
|
+
# @return [Hash] A hash containing :public_key and :private_key (hex strings).
|
|
25
|
+
def derive_key_pair(seed, options = {})
|
|
26
|
+
decoded = @address_codec.decode_seed(seed)
|
|
27
|
+
type = decoded[:type]
|
|
28
|
+
entropy = decoded[:bytes]
|
|
29
|
+
|
|
30
|
+
if type == 'ed25519'
|
|
31
|
+
Ed25519.derive_key_pair(entropy)
|
|
32
|
+
else
|
|
33
|
+
# For secp256k1, we use the entropy as seed
|
|
34
|
+
Secp256k1.derive_key_pair(entropy)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Signs a message with a private key.
|
|
39
|
+
# @param message [String] The message to sign as hex.
|
|
40
|
+
# @param private_key [String] The private key as hex.
|
|
41
|
+
# @param algorithm [String, nil] The algorithm to use ('secp256k1' or 'ed25519').
|
|
42
|
+
# @return [String] The signature as hex.
|
|
43
|
+
def sign(message, private_key, algorithm = nil)
|
|
44
|
+
if algorithm == 'ed25519' || (algorithm.nil? && private_key.length == 64)
|
|
45
|
+
# Heuristic: Ed25519 private keys in our lib are 32 bytes (64 hex chars).
|
|
46
|
+
# Secp256k1 are also 32 bytes, but Ed25519 is often explicitly requested.
|
|
47
|
+
# Actually, let's look at the prefix of the public key if we had it.
|
|
48
|
+
# Since we don't have the public key here, we rely on the caller or length.
|
|
49
|
+
# In XRPL-Ruby, Ed25519 private keys are 64 hex chars (32 bytes).
|
|
50
|
+
# Secp256k1 are also 64 hex chars. This is ambiguous!
|
|
51
|
+
# Let's try to see if it's explicitly 'ed25519'.
|
|
52
|
+
begin
|
|
53
|
+
return Ed25519.sign(message, private_key) if algorithm == 'ed25519'
|
|
54
|
+
return Secp256k1.sign(message, private_key)
|
|
55
|
+
rescue => e
|
|
56
|
+
# If secp fails and we didn't specify, maybe it was ed?
|
|
57
|
+
# But that's dangerous.
|
|
58
|
+
raise e
|
|
59
|
+
end
|
|
60
|
+
else
|
|
61
|
+
Secp256k1.sign(message, private_key)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Verifies a signature.
|
|
66
|
+
# @param message [String] The message as hex.
|
|
67
|
+
# @param signature [String] The signature as hex.
|
|
68
|
+
# @param public_key [String] The public key as hex.
|
|
69
|
+
# @return [Boolean] True if the signature is valid.
|
|
70
|
+
def verify(message, signature, public_key)
|
|
71
|
+
if public_key.start_with?('ED')
|
|
72
|
+
Ed25519.verify(message, signature, public_key)
|
|
73
|
+
else
|
|
74
|
+
Secp256k1.verify(message, signature, public_key)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Derives an XRP address from a public key.
|
|
79
|
+
# @param public_key [String] The public key as hex.
|
|
80
|
+
# @return [String] The XRP address.
|
|
81
|
+
def derive_address(public_key)
|
|
82
|
+
public_key_bytes = [public_key].pack('H*')
|
|
83
|
+
# Account ID is RIPEMD160(SHA256(public_key))
|
|
84
|
+
sha256 = Digest::SHA256.digest(public_key_bytes)
|
|
85
|
+
# Ruby doesn't have RIPEMD160 in Digest by default sometimes,
|
|
86
|
+
# but OpenSSL has it.
|
|
87
|
+
ripemd160 = OpenSSL::Digest.new('RIPEMD160').digest(sha256)
|
|
88
|
+
|
|
89
|
+
@address_codec.encode_account_id(ripemd160.unpack('C*'))
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
module KeyPairs
|
|
6
|
+
# Secp256k1 implementation for XRPL key pairs.
|
|
7
|
+
module Secp256k1
|
|
8
|
+
# Derives a key pair from a seed.
|
|
9
|
+
# @param seed [Array<Integer>] 16 bytes of seed entropy.
|
|
10
|
+
# @return [Hash] A hash containing :public_key and :private_key (hex strings).
|
|
11
|
+
def self.derive_key_pair(seed)
|
|
12
|
+
# XRPL Secp256k1 uses a specific derivation algorithm (seed -> family seed -> sequence 0 key).
|
|
13
|
+
# Seed is passed as a 16-byte array.
|
|
14
|
+
private_key = derive_private_key(seed)
|
|
15
|
+
public_key = derive_public_key(private_key)
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
public_key: public_key.unpack1('H*').upcase,
|
|
19
|
+
private_key: private_key.unpack1('H*').upcase
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Signs a message with a private key.
|
|
24
|
+
# @param message [Array<Integer>, String] The message to sign.
|
|
25
|
+
# @param private_key [String] The private key (hex string).
|
|
26
|
+
# @return [String] The signature (hex string).
|
|
27
|
+
def self.sign(message, private_key)
|
|
28
|
+
msg_hash = message.is_a?(String) ? [message].pack('H*') : message.pack('C*')
|
|
29
|
+
|
|
30
|
+
# Use the dsa_sign_asn1 method directly on a PKey::EC object.
|
|
31
|
+
# In OpenSSL 3.0, setting private_key might be blocked if not done carefully.
|
|
32
|
+
# We use a fresh object and set the key.
|
|
33
|
+
ec = OpenSSL::PKey::EC.new('secp256k1')
|
|
34
|
+
begin
|
|
35
|
+
# Use send to bypass potential visibility or immutability checks if any,
|
|
36
|
+
# but usually private_key= is public.
|
|
37
|
+
ec.private_key = OpenSSL::BN.new(private_key, 16)
|
|
38
|
+
ec.public_key = ec.group.generator.mul(ec.private_key)
|
|
39
|
+
|
|
40
|
+
signature = ec.dsa_sign_asn1(msg_hash)
|
|
41
|
+
signature.unpack1('H*').upcase
|
|
42
|
+
rescue OpenSSL::PKey::PKeyError => e
|
|
43
|
+
# If OpenSSL 3.0 is too strict, we might be unable to sign without a dedicated gem.
|
|
44
|
+
# However, for the sake of completion, we'll try a fallback if it exists.
|
|
45
|
+
raise "Secp256k1 signing failed: #{e.message}. This might be due to OpenSSL 3.0 immutability."
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Verifies a signature.
|
|
50
|
+
# @param message [Array<Integer>, String] The message.
|
|
51
|
+
# @param signature [String] The signature (hex string).
|
|
52
|
+
# @param public_key [String] The public key (hex string).
|
|
53
|
+
# @return [Boolean] True if the signature is valid.
|
|
54
|
+
def self.verify(message, signature, public_key)
|
|
55
|
+
msg_hash = message.is_a?(String) ? [message].pack('H*') : message.pack('C*')
|
|
56
|
+
sig_bytes = [signature].pack('H*')
|
|
57
|
+
|
|
58
|
+
ec = OpenSSL::PKey::EC.new('secp256k1')
|
|
59
|
+
ec.public_key = OpenSSL::PKey::EC::Point.new(ec.group, OpenSSL::BN.new(public_key, 16))
|
|
60
|
+
|
|
61
|
+
ec.dsa_verify_asn1(msg_hash, sig_bytes)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Derives the 32-byte private key from the 16-byte seed.
|
|
67
|
+
# This follows the Ripple seed derivation algorithm.
|
|
68
|
+
def self.derive_private_key(seed, account_index = 0)
|
|
69
|
+
ec_key = OpenSSL::PKey::EC.new('secp256k1')
|
|
70
|
+
order = ec_key.group.order
|
|
71
|
+
|
|
72
|
+
# 1. Derive root private key from seed
|
|
73
|
+
root_private_key = derive_scalar(seed)
|
|
74
|
+
|
|
75
|
+
# 2. Derive root public key from root private key
|
|
76
|
+
root_public_key = derive_root_public_key(root_private_key)
|
|
77
|
+
|
|
78
|
+
# 3. Derive child scalar from root public key and account_index
|
|
79
|
+
child_scalar = derive_scalar(root_public_key, account_index)
|
|
80
|
+
|
|
81
|
+
# 4. child_private_key = (root_private_key + child_scalar) % order
|
|
82
|
+
child_private_key = (OpenSSL::BN.new(root_private_key, 16) + OpenSSL::BN.new(child_scalar, 16)) % order
|
|
83
|
+
child_private_key.to_s(16).rjust(64, '0')
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.derive_scalar(seed, sequence = nil)
|
|
87
|
+
seed_bytes = seed.is_a?(Array) ? seed.pack('C*') : seed
|
|
88
|
+
ec_key = OpenSSL::PKey::EC.new('secp256k1')
|
|
89
|
+
order = ec_key.group.order
|
|
90
|
+
loop_count = 0
|
|
91
|
+
loop do
|
|
92
|
+
data = sequence ? seed_bytes + [sequence].pack('N') : seed_bytes
|
|
93
|
+
data += [loop_count].pack('N')
|
|
94
|
+
hash = Digest::SHA512.digest(data)[0...32]
|
|
95
|
+
scalar = OpenSSL::BN.new(hash.unpack1('H*'), 16)
|
|
96
|
+
|
|
97
|
+
if scalar > 0 && scalar < order
|
|
98
|
+
return scalar.to_s(16).rjust(64, '0')
|
|
99
|
+
end
|
|
100
|
+
loop_count += 1
|
|
101
|
+
raise "Too many loops" if loop_count > 100
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.derive_root_public_key(private_key_hex)
|
|
106
|
+
group = OpenSSL::PKey::EC::Group.new('secp256k1')
|
|
107
|
+
key_bn = OpenSSL::BN.new(private_key_hex, 16)
|
|
108
|
+
pub_key_point = group.generator.mul(key_bn)
|
|
109
|
+
pub_key_point.to_octet_string(:compressed)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.derive_public_key(private_key_hex)
|
|
113
|
+
derive_root_public_key(private_key_hex)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
|
|
6
|
+
module Wallet
|
|
7
|
+
# Represents an XRPL wallet, providing methods for signing and address derivation.
|
|
8
|
+
class Wallet
|
|
9
|
+
# @return [String] The public key as hex.
|
|
10
|
+
attr_reader :public_key
|
|
11
|
+
# @return [String] The private key as hex.
|
|
12
|
+
attr_reader :private_key
|
|
13
|
+
# @return [String] The encoded seed.
|
|
14
|
+
attr_reader :seed
|
|
15
|
+
# @return [String] The classic address.
|
|
16
|
+
attr_reader :classic_address
|
|
17
|
+
# @return [String] The algorithm used ('secp256k1' or 'ed25519').
|
|
18
|
+
attr_reader :algorithm
|
|
19
|
+
|
|
20
|
+
# Initializes a new Wallet instance.
|
|
21
|
+
# @param public_key [String] The public key as hex.
|
|
22
|
+
# @param private_key [String] The private key as hex.
|
|
23
|
+
# @param seed [String, nil] The encoded seed.
|
|
24
|
+
# @param classic_address [String, nil] The classic address (optional, derived if not provided).
|
|
25
|
+
def initialize(public_key, private_key, seed: nil, classic_address: nil)
|
|
26
|
+
@public_key = public_key
|
|
27
|
+
@private_key = private_key
|
|
28
|
+
@seed = seed
|
|
29
|
+
@algorithm = public_key.start_with?('ED') ? 'ed25519' : 'secp256k1'
|
|
30
|
+
@key_pairs = KeyPairs::KeyPairs.new
|
|
31
|
+
@classic_address = classic_address || @key_pairs.derive_address(public_key)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Generates a new random wallet.
|
|
35
|
+
# @param algorithm [String] The algorithm to use ('secp256k1' or 'ed25519').
|
|
36
|
+
# @return [Wallet] A new Wallet instance.
|
|
37
|
+
def self.generate(algorithm = 'secp256k1')
|
|
38
|
+
kp = KeyPairs::KeyPairs.new
|
|
39
|
+
seed = kp.generate_seed(nil, algorithm)
|
|
40
|
+
from_seed(seed)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Creates a wallet from a seed.
|
|
44
|
+
# @param seed [String] The encoded seed.
|
|
45
|
+
# @param options [Hash] Options for key derivation.
|
|
46
|
+
# @return [Wallet] A new Wallet instance.
|
|
47
|
+
def self.from_seed(seed, options = {})
|
|
48
|
+
kp = KeyPairs::KeyPairs.new
|
|
49
|
+
keys = kp.derive_key_pair(seed, options)
|
|
50
|
+
new(keys[:public_key], keys[:private_key], seed: seed)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Creates a wallet from entropy.
|
|
54
|
+
# @param entropy [Array<Integer>] 16 bytes of entropy.
|
|
55
|
+
# @param algorithm [String] The algorithm to use ('secp256k1' or 'ed25519').
|
|
56
|
+
# @return [Wallet] A new Wallet instance.
|
|
57
|
+
def self.from_entropy(entropy, algorithm = 'secp256k1')
|
|
58
|
+
kp = KeyPairs::KeyPairs.new
|
|
59
|
+
seed = kp.generate_seed(entropy, algorithm)
|
|
60
|
+
from_seed(seed)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Signs a message (hex string) with the wallet's private key.
|
|
64
|
+
# @param message [String, Hash] The message (hex string) or transaction (Hash) to sign.
|
|
65
|
+
# @param multisign [Boolean] Whether to sign for a multisigned transaction.
|
|
66
|
+
# @return [String] The signature as a hex string.
|
|
67
|
+
def sign(message, multisign: false)
|
|
68
|
+
algorithm = @algorithm
|
|
69
|
+
# Check if message is a Hash (transaction)
|
|
70
|
+
if message.is_a?(Hash)
|
|
71
|
+
prefix = multisign ? BinaryCodec::HASH_PREFIX[:transaction_multi_sig] : BinaryCodec::HASH_PREFIX[:transaction_sig]
|
|
72
|
+
signing_data = BinaryCodec.signing_data(message, prefix, signing_fields_only: true)
|
|
73
|
+
message = bytes_to_hex(signing_data)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@key_pairs.sign(message, @private_key, algorithm)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Verifies a signature for a message.
|
|
80
|
+
# @param message [String] The message as a hex string.
|
|
81
|
+
# @param signature [String] The signature as a hex string.
|
|
82
|
+
# @return [Boolean] True if the signature is valid.
|
|
83
|
+
def verify(message, signature)
|
|
84
|
+
@key_pairs.verify(message, signature, @public_key)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Verifies a signed transaction blob.
|
|
88
|
+
# @param signed_transaction [String] The signed transaction blob as a hex string.
|
|
89
|
+
# @return [Boolean] True if the transaction signature is valid.
|
|
90
|
+
def verify_transaction(signed_transaction)
|
|
91
|
+
decoded = BinaryCodec.binary_to_json(signed_transaction)
|
|
92
|
+
# The signing data is the transaction without the TxnSignature field,
|
|
93
|
+
# prefixed by 0x53545800 (STX\0).
|
|
94
|
+
|
|
95
|
+
tx_for_signing = decoded.dup
|
|
96
|
+
signature = tx_for_signing.delete('TxnSignature')
|
|
97
|
+
return false unless signature
|
|
98
|
+
|
|
99
|
+
signing_data = BinaryCodec.signing_data(tx_for_signing)
|
|
100
|
+
@key_pairs.verify(bytes_to_hex(signing_data), signature, @public_key)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Derives the X-address for this wallet.
|
|
104
|
+
# @param tag [Integer, false, nil] The destination tag.
|
|
105
|
+
# @param test_network [Boolean] Whether the address is for a test network.
|
|
106
|
+
# @return [String] The encoded X-address.
|
|
107
|
+
def get_x_address(tag: nil, test_network: false)
|
|
108
|
+
address_codec = AddressCodec::AddressCodec.new
|
|
109
|
+
address_codec.classic_address_to_x_address(@classic_address, tag, test_network)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [String] String representation of the wallet.
|
|
113
|
+
def to_s
|
|
114
|
+
"Wallet(address: #{@classic_address}, public_key: #{@public_key})"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|