solana-ruby-web3js 1.0.1.beta3 → 2.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 769dd8aff42953255243d337b811ab9bd61e27e19e6957d235e9e015300f925b
4
- data.tar.gz: e940aa048c15af34c5f06bf5de1db3716e8f68f1f83c41fe480a6de748082310
3
+ metadata.gz: 9f614bab0b21438c2820d0035a0aa33567a11fedd3ecb998666b39e5ab28681d
4
+ data.tar.gz: c63fd671d6328732c39994032f94aff219ff77eda8cd899c5d1f0c29f86f4524
5
5
  SHA512:
6
- metadata.gz: 7cd8eb6a1ff5a1c749cb1ded759323bd1a7a43dea78e6d19444f1d1e8dc77ca24f757c5fc53412431020f09960db0cef8f2bd519641a0bdc5790c4dc8e60a581
7
- data.tar.gz: d6c68d6c97ae798d5b79b4fcaae962ac3e34e7e2d28d61b80124b4725b3926803c49f20bd055bc7c6af9b46229558111a3ea0dae932d15750f726484a0c6b460
6
+ metadata.gz: 4015bdc6179d57105ea1da82daa9d6ec19f623098d636271fb0e7ab9210d563414eae364271e58be82a2ce9ed6dc0804de53db7e7a15c5d353da4ff30faf5124
7
+ data.tar.gz: 8fca9ec2ccdcc5b3ea72f54fa6fad27fee3fe1aef20b145107382c523a0a70df90812f269fdcdee3d9d62b732e6ebe7ce6ad3e1514257d379db7088de55941d1
data/.DS_Store CHANGED
Binary file
data/Gemfile.lock CHANGED
@@ -1,9 +1,11 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- solana-ruby-web3js (1.0.1.beta1)
4
+ solana-ruby-web3js (1.0.1.beta3)
5
5
  base58 (~> 0.2.3)
6
6
  base64 (~> 0.2.0)
7
+ ed25519
8
+ rbnacl (~> 6.0)
7
9
  websocket-client-simple (~> 0.8.0)
8
10
 
9
11
  GEM
@@ -64,6 +66,8 @@ GEM
64
66
  ed25519 (1.3.0)
65
67
  erubi (1.13.0)
66
68
  event_emitter (0.2.6)
69
+ ffi (1.17.0)
70
+ ffi (1.17.0-arm64-darwin)
67
71
  flay (2.13.3)
68
72
  erubi (~> 1.10)
69
73
  path_expander (~> 1.0)
@@ -93,6 +97,8 @@ GEM
93
97
  public_suffix (6.0.1)
94
98
  racc (1.8.1)
95
99
  rainbow (3.1.1)
100
+ rbnacl (6.0.1)
101
+ ffi
96
102
  reek (6.3.0)
97
103
  dry-schema (~> 1.13.0)
98
104
  parser (~> 3.3.0)
@@ -0,0 +1,30 @@
1
+ module SolanaRuby
2
+ module DataTypes
3
+ class Blob
4
+ attr_reader :size
5
+
6
+ # Constructor to initialize size of the blob
7
+ def initialize(size)
8
+ raise ArgumentError, "Size must be a positive integer" unless size.is_a?(Integer) && size > 0
9
+ @size = size
10
+ end
11
+
12
+ # Serialize the given object to a byte array
13
+ def serialize(obj)
14
+ # Ensure obj is an array and then convert to byte array
15
+ obj = [obj] unless obj.is_a?(Array)
16
+ raise ArgumentError, "Object must be an array of bytes" unless obj.all? { |e| e.is_a?(Integer) && e.between?(0, 255) }
17
+
18
+ obj.pack('C*').bytes
19
+ end
20
+
21
+ # Deserialize a byte array into the original object format
22
+ def deserialize(bytes)
23
+ # Ensure the byte array is of the correct size
24
+ raise ArgumentError, "Byte array size must match the expected size" unless bytes.length == @size
25
+
26
+ bytes.pack('C*')
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,27 @@
1
+ module SolanaRuby
2
+ module DataTypes
3
+ class Layout
4
+ attr_reader :fields
5
+
6
+ def initialize(fields)
7
+ @fields = fields
8
+ end
9
+
10
+ def serialize(params)
11
+ fields.flat_map do |field, type|
12
+ data_type = type.is_a?(Symbol) ? SolanaRuby::DataTypes.send(type) : type
13
+ data_type.serialize(params[field])
14
+ end
15
+ end
16
+
17
+ def deserialize(bytes)
18
+ result = {}
19
+ fields.map do |field, type|
20
+ data_type = type.is_a?(Symbol) ? SolanaRuby::DataTypes.send(type) : type
21
+ result[field] = data_type.deserialize(bytes.shift(data_type.size))
22
+ end
23
+ result
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ module SolanaRuby
2
+ module DataTypes
3
+ class NearInt64
4
+ attr_reader :size
5
+
6
+ V2E32 = 2.pow(32)
7
+
8
+ def initialize
9
+ @size = 8
10
+ end
11
+
12
+ def serialize(obj)
13
+ uint = UnsignedInt.new(32)
14
+ numbers = divmod_int64(obj)
15
+ numbers.map{|x| uint.serialize(x)}.flatten
16
+ end
17
+
18
+ def deserialize(bytes)
19
+ raise "Invalid serialization (wrong size)" if @size && bytes.size != @size
20
+ uint = UnsignedInt.new(32)
21
+ half_size = @size/2
22
+
23
+ lo, hi = [bytes[0..half_size-1], bytes[half_size..-1]].map{|x| uint.deserialize(x)}
24
+
25
+ rounded_int64(hi, lo)
26
+ end
27
+
28
+ def divmod_int64(obj)
29
+ obj = obj * 1.0
30
+ hi32 = (obj / V2E32).floor
31
+ lo32 = (obj - (hi32 * V2E32)).floor
32
+ [lo32, hi32]
33
+ end
34
+
35
+ def rounded_int64(hi32, lo32)
36
+ hi32 * V2E32 + lo32
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,23 @@
1
+ module SolanaRuby
2
+ module DataTypes
3
+ class Sequence
4
+ def initialize count, type
5
+ @count = count
6
+ @type = type
7
+ end
8
+
9
+ def serialize items
10
+ items.map do |item|
11
+ @type.serialize(item)
12
+ end.flatten
13
+ end
14
+
15
+ def deserialize bytes
16
+ @count.times.map do
17
+ current_bytes = bytes.shift(@type.size)
18
+ @type.deserialize(current_bytes)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,40 @@
1
+ module SolanaRuby
2
+ module DataTypes
3
+ class UnsignedInt
4
+ attr_reader :size
5
+
6
+ BITS = {
7
+ 8 => { directive: 'C*', size: 1 },
8
+ 32 => { directive: 'L*', size: 4 },
9
+ 64 => { directive: 'Q*', size: 8 }
10
+ }
11
+
12
+ def initialize(bits)
13
+ @bits = bits
14
+ type = BITS[@bits]
15
+ raise "Can only fit #{BITS.keys}" unless type
16
+ @size = type[:size]
17
+ @directive = type[:directive]
18
+ end
19
+
20
+ # Serialize the unsigned integer into bytes
21
+ def serialize(obj)
22
+ raise "Can only serialize integers" unless obj.is_a?(Integer)
23
+ raise "Cannot serialize negative integers" if obj < 0
24
+
25
+ if obj >= 256**@size
26
+ raise "Integer too large (does not fit in #{@size} bytes)"
27
+ end
28
+
29
+ [obj].pack(@directive).bytes
30
+ end
31
+
32
+ # Deserialize bytes into the unsigned integer
33
+ def deserialize(bytes)
34
+ raise "Invalid serialization (wrong size)" if bytes.size != @size
35
+
36
+ bytes.pack('C*').unpack(@directive).first
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,25 @@
1
+ module SolanaRuby
2
+ module DataTypes
3
+ extend self
4
+
5
+ def uint8
6
+ UnsignedInt.new(8)
7
+ end
8
+
9
+ def uint32
10
+ UnsignedInt.new(32)
11
+ end
12
+
13
+ def uint64
14
+ UnsignedInt.new(64)
15
+ end
16
+
17
+ def near_int64
18
+ NearInt64.new
19
+ end
20
+
21
+ def blob1
22
+ Blob.new(1)
23
+ end
24
+ end
25
+ end
@@ -23,7 +23,8 @@ module SolanaRuby
23
23
 
24
24
  def request(method, params = [])
25
25
  http = Net::HTTP.new(@uri.host, @uri.port)
26
- http.use_ssl = true
26
+ local_hosts = ['localhost', '127.0.0.1', '[::1]']
27
+ http.use_ssl = true unless local_hosts.include?(@uri.host.downcase)
27
28
 
28
29
  request = Net::HTTP::Post.new(@uri.request_uri, {'Content-Type' => 'application/json'})
29
30
  request.body = {
@@ -0,0 +1,42 @@
1
+ module SolanaRuby
2
+ class Keypair
3
+ require 'rbnacl'
4
+ require 'base58'
5
+
6
+ # Generates a new Ed25519 keypair
7
+ def self.generate
8
+ signing_key = RbNaCl::Signatures::Ed25519::SigningKey.generate
9
+ public_key_bytes = signing_key.verify_key.to_bytes # Binary format for public key
10
+ private_key_bytes = signing_key.to_bytes
11
+ private_key_hex = private_key_bytes.unpack1('H*') # Hex format for private key
12
+
13
+ # Convert public key binary to Base58 for readability and compatibility
14
+ {
15
+ public_key: Base58.binary_to_base58(public_key_bytes, :bitcoin),
16
+ private_key: private_key_hex,
17
+ full_private_key: Base58.binary_to_base58((private_key_bytes + public_key_bytes), :bitcoin)
18
+ }
19
+ end
20
+
21
+ # Restores a keypair from a private key in hex format
22
+ def self.from_private_key(private_key_hex)
23
+ raise ArgumentError, "Invalid private key length" unless private_key_hex.size == 64
24
+
25
+ # Convert hex private key to binary format for signing key
26
+ private_key_bytes = [private_key_hex].pack('H*')
27
+
28
+ # Initialize signing key
29
+ signing_key = RbNaCl::Signatures::Ed25519::SigningKey.new(private_key_bytes)
30
+
31
+ # Extract public key in binary format
32
+ public_key_bytes = signing_key.verify_key.to_bytes
33
+
34
+ # Return public key in Base58 format and private key in hex format
35
+ {
36
+ public_key: Base58.binary_to_base58(public_key_bytes, :bitcoin),
37
+ private_key: private_key_hex,
38
+ full_private_key: Base58.binary_to_base58((private_key_bytes + public_key_bytes), :bitcoin)
39
+ }
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,107 @@
1
+ module SolanaRuby
2
+ class Message
3
+ PUBKEY_LENGTH = 32
4
+
5
+ attr_reader :header, :account_keys, :recent_blockhash, :instructions
6
+
7
+ def initialize(header:, account_keys:, recent_blockhash:, instructions:)
8
+ @header = header
9
+ @account_keys = account_keys
10
+ @recent_blockhash = recent_blockhash
11
+ @instructions = instructions
12
+ end
13
+
14
+ def self.from(bytes)
15
+ bytes = bytes.dup
16
+ num_required_signatures = bytes.shift
17
+ num_readonly_signed_accounts = bytes.shift
18
+ num_readonly_unsigned_accounts = bytes.shift
19
+ account_count = Utils.decode_length(bytes)
20
+
21
+ account_keys = account_count.times.map do
22
+ account_bytes = bytes.slice!(0, PUBKEY_LENGTH)
23
+ Utils.bytes_to_base58(account_bytes)
24
+ end
25
+
26
+ recent_blockhash_bytes = bytes.slice!(0, PUBKEY_LENGTH)
27
+ recent_blockhash = Utils.bytes_to_base58(recent_blockhash_bytes)
28
+
29
+ instruction_count = Utils.decode_length(bytes)
30
+ instructions = instruction_count.times.map do
31
+ program_id_index = bytes.shift
32
+ account_count = Utils.decode_length(bytes)
33
+
34
+ accounts = bytes.slice!(0, account_count)
35
+
36
+ data_length = Utils.decode_length(bytes)
37
+ data_bytes = bytes.slice!(0, data_length)
38
+ {program_id_index: program_id_index, accounts: accounts, data: data_bytes}
39
+ end
40
+ self.new({
41
+ header: {
42
+ num_required_signatures: num_required_signatures,
43
+ num_readonly_signed_accounts:num_readonly_signed_accounts,
44
+ num_readonly_unsigned_accounts:num_readonly_unsigned_accounts,
45
+ },
46
+ account_keys: account_keys,
47
+ recent_blockhash: recent_blockhash,
48
+ instructions: instructions
49
+ })
50
+ end
51
+
52
+ def serialize
53
+ num_keys = account_keys.length
54
+ key_count = Utils.encode_length(num_keys)
55
+
56
+ layout = SolanaRuby::DataTypes::Layout.new({
57
+ num_required_signatures: :blob1,
58
+ num_readonly_signed_accounts: :blob1,
59
+ num_readonly_unsigned_accounts: :blob1,
60
+ key_count: SolanaRuby::DataTypes::Blob.new(key_count.length),
61
+ keys: SolanaRuby::DataTypes::Sequence.new(num_keys, SolanaRuby::DataTypes::Blob.new(32)),
62
+ recent_blockhash: SolanaRuby::DataTypes::Blob.new(32)
63
+ })
64
+
65
+ sign_data = layout.serialize({
66
+ num_required_signatures: header[:num_required_signatures],
67
+ num_readonly_signed_accounts: header[:num_readonly_signed_accounts],
68
+ num_readonly_unsigned_accounts: header[:num_readonly_unsigned_accounts],
69
+ key_count: key_count,
70
+ keys: account_keys.map{|x| Utils.base58_to_bytes(x)},
71
+ recent_blockhash: Utils.base58_to_bytes(recent_blockhash)
72
+ })
73
+
74
+ instruction_count = Utils.encode_length(@instructions.length)
75
+ sign_data += instruction_count
76
+
77
+ data = @instructions.map do |instruction|
78
+ instruction_layout = SolanaRuby::DataTypes::Layout.new({
79
+ program_id_index: :uint8,
80
+ key_indices_count: SolanaRuby::DataTypes::Blob.new(key_count.length),
81
+ key_indices: SolanaRuby::DataTypes::Sequence.new(num_keys, SolanaRuby::DataTypes::Blob.new(8)),
82
+ data_length: SolanaRuby::DataTypes::Blob.new(key_count.length),
83
+ data: SolanaRuby::DataTypes::Sequence.new(num_keys, SolanaRuby::DataTypes::UnsignedInt.new(8)),
84
+ })
85
+
86
+ key_indices_count = Utils.encode_length(instruction[:accounts].length)
87
+ data_count = Utils.encode_length(instruction[:data].length)
88
+
89
+ instruction_layout.serialize({
90
+ program_id_index: instruction[:program_id_index],
91
+ key_indices_count: key_indices_count,
92
+ key_indices: instruction[:accounts],
93
+ data_length: data_count,
94
+ data: instruction[:data]
95
+ })
96
+ end.flatten
97
+
98
+ sign_data += data
99
+ sign_data
100
+ end
101
+
102
+ def is_account_writable(index)
103
+ index < header[:num_required_signatures] - header[:num_readonly_signed_accounts] ||
104
+ (index >= header[:num_required_signatures] && index < account_keys.length - header[:num_readonly_unsigned_accounts])
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,281 @@
1
+ module SolanaRuby
2
+ class Transaction
3
+ require 'rbnacl'
4
+ SIGNATURE_LENGTH = 64
5
+ PACKET_DATA_SIZE = 1280 - 40 - 8
6
+ DEFAULT_SIGNATURE = Array.new(64, 0)
7
+
8
+ attr_accessor :instructions, :signatures, :fee_payer, :recent_blockhash, :message
9
+
10
+ def initialize(recent_blockhash: nil, signatures: [], instructions: [], fee_payer: nil)
11
+ @recent_blockhash = recent_blockhash
12
+ @signatures = signatures
13
+ @instructions = instructions
14
+ @fee_payer = fee_payer
15
+ end
16
+
17
+ def add_instruction(instruction)
18
+ @instructions << instruction
19
+ end
20
+
21
+ def set_fee_payer(pubkey)
22
+ puts "Setting fee payer: #{pubkey.inspect}" # Debugging output
23
+ @fee_payer = pubkey # Store as-is since Base58 gem can handle encoding/decoding
24
+ end
25
+
26
+ def set_recent_blockhash(blockhash)
27
+ # raise "Invalid Base58 blockhash" unless Base58.valid?(blockhash)
28
+ @recent_blockhash = blockhash # Store as-is for similar reasons
29
+ end
30
+
31
+ def self.from(base64_string)
32
+ bytes = Base64.decode64(base64_string).bytes
33
+ signature_count = Utils.decode_length(bytes)
34
+ signatures = signature_count.times.map do
35
+ signature_bytes = bytes.slice!(0, SIGNATURE_LENGTH)
36
+ Utils.bytes_to_base58(signature_bytes)
37
+ end
38
+ msg = Message.from(bytes)
39
+ self.populate(msg, signatures)
40
+ end
41
+
42
+ def serialize
43
+ sign_data = serialize_message
44
+
45
+ signature_count = Utils.encode_length(signatures.length)
46
+ raise 'invalid length!' if signatures.length > 256
47
+
48
+ wire_transaction = signature_count
49
+
50
+ signatures.each do |signature|
51
+ if signature
52
+ signature_bytes = signature[:signature]
53
+ raise 'signature is empty' unless (signature_bytes)
54
+ raise 'signature has invalid length' unless (signature_bytes.length == 64)
55
+ wire_transaction += signature_bytes
56
+ raise "Transaction too large: #{wire_transaction.length} > #{PACKET_DATA_SIZE}" unless wire_transaction.length <= PACKET_DATA_SIZE
57
+ wire_transaction
58
+ end
59
+ end
60
+
61
+ wire_transaction += sign_data
62
+ wire_transaction
63
+ end
64
+
65
+ def to_base64
66
+ Base64.strict_encode64(serialize.pack('C*'))
67
+ end
68
+
69
+ def add(item)
70
+ instructions.push(item)
71
+ end
72
+
73
+ def sign(keys)
74
+ raise 'No signers' unless keys.any?
75
+
76
+ keys = keys.uniq{ |k| key[:public_key] }
77
+ @signatures = keys.map do |key|
78
+ {
79
+ signature: nil,
80
+ public_key: key[:public_key]
81
+ }
82
+ end
83
+
84
+ message = compile_message
85
+ partial_sign(message, keys)
86
+ true
87
+ end
88
+
89
+ private
90
+
91
+ def serialize_message
92
+ compile.serialize
93
+ end
94
+
95
+ def compile
96
+ message = compile_message
97
+ signed_keys = message.account_keys.slice(0, message.header[:num_required_signatures])
98
+
99
+ if signatures.length == signed_keys.length
100
+ valid = signatures.each_with_index.all?{|pair, i| signed_keys[i] == pair[:public_key]}
101
+ return message if valid
102
+ end
103
+
104
+ @signatures = signed_keys.map do |public_key|
105
+ {
106
+ signature: nil,
107
+ public_key: public_key
108
+ }
109
+ end
110
+
111
+ message
112
+ end
113
+
114
+ def compile_message
115
+ check_for_errors
116
+ fetch_message_data
117
+ message = Message.new(
118
+ header: {
119
+ num_required_signatures: @num_required_signatures,
120
+ num_readonly_signed_accounts: @num_readonly_signed_accounts,
121
+ num_readonly_unsigned_accounts: @num_readonly_unsigned_accounts,
122
+ },
123
+ account_keys: @account_keys, recent_blockhash: recent_blockhash, instructions: @instructs
124
+ )
125
+ message
126
+ end
127
+
128
+ def check_for_errors
129
+ raise 'Transaction recent_blockhash required' unless recent_blockhash
130
+
131
+ puts 'No instructions provided' if instructions.length < 1
132
+
133
+ if fee_payer.nil? && signatures.length > 0 && signatures[0][:public_key]
134
+ @fee_payer = signatures[0][:public_key] if (signatures.length > 0 && signatures[0][:public_key])
135
+ end
136
+
137
+ raise('Transaction fee payer required') if @fee_payer.nil?
138
+
139
+ instructions.each_with_index do |instruction, i|
140
+ raise("Transaction instruction index #{i} has undefined program id") unless instruction.program_id
141
+ end
142
+ end
143
+
144
+ def fetch_message_data
145
+ program_ids = []
146
+ account_metas= []
147
+
148
+ instructions.each do |instruction|
149
+ account_metas += instruction.keys
150
+ program_ids.push(instruction.program_id) unless program_ids.include?(instruction.program_id)
151
+ end
152
+
153
+ # Append programID account metas
154
+ append_program_id(program_ids, account_metas)
155
+
156
+ # Sort. Prioritizing first by signer, then by writable
157
+ signer_order(account_metas)
158
+
159
+ # Cull duplicate account metas
160
+ unique_metas = []
161
+ add_unique_meta_data(unique_metas, account_metas)
162
+
163
+ add_fee_payer_meta(unique_metas)
164
+
165
+ # Disallow unknown signers
166
+ disallow_signers(signatures, unique_metas)
167
+
168
+ # Split out signing from non-signing keys and count header values
169
+ signed_keys = []
170
+ unsigned_keys = []
171
+ header_params = split_keys(unique_metas, signed_keys, unsigned_keys)
172
+ @account_keys = signed_keys + unsigned_keys
173
+
174
+ # add instruction structure
175
+ @instructs = add_instructs
176
+ end
177
+
178
+ def append_program_id(program_ids, account_metas)
179
+ program_ids.each do |programId|
180
+ account_metas.push({
181
+ pubkey: programId,
182
+ is_signer: false,
183
+ is_writable: false,
184
+ })
185
+ end
186
+ end
187
+
188
+ def signer_order(account_metas)
189
+ account_metas.sort! do |x, y|
190
+ check_signer = x[:is_signer] == y[:is_signer] ? nil : x[:is_signer] ? -1 : 1
191
+ check_writable = x[:is_writable] == y[:is_writable] ? nil : (x[:is_writable] ? -1 : 1)
192
+ (check_signer || check_writable) || 0
193
+ end
194
+ end
195
+
196
+ def add_unique_meta_data(unique_metas, account_metas)
197
+ account_metas.each do |account_meta|
198
+ pubkey_string = account_meta[:pubkey]
199
+ unique_index = unique_metas.find_index{|x| x[:pubkey] == pubkey_string }
200
+ if unique_index
201
+ unique_metas[unique_index][:is_writable] = unique_metas[unique_index][:is_writable] || account_meta[:is_writable]
202
+ else
203
+ unique_metas.push(account_meta);
204
+ end
205
+ end
206
+ end
207
+
208
+ def add_fee_payer_meta(unique_metas)
209
+ # Move fee payer to the front
210
+ fee_payer_index = unique_metas.find_index { |x| x[:pubkey] == fee_payer }
211
+ if fee_payer_index
212
+ payer_meta = unique_metas.delete_at(fee_payer_index)
213
+ payer_meta[:is_signer] = true
214
+ payer_meta[:is_writable] = true
215
+ unique_metas.unshift(payer_meta)
216
+ else
217
+ unique_metas.unshift({
218
+ pubkey: fee_payer,
219
+ is_signer: true,
220
+ is_writable: true,
221
+ })
222
+ end
223
+ end
224
+
225
+ def disallow_signers(signatures, unique_metas)
226
+ signatures.each do |signature|
227
+ unique_index = unique_metas.find_index{ |x| x[:pubkey] == signature[:public_key] }
228
+
229
+ if unique_index
230
+ unique_metas[unique_index][:is_signer] = true unless unique_metas[unique_index][:is_signer]
231
+ else
232
+ raise "unknown signer: #{signature[:public_key]}"
233
+ end
234
+ end
235
+ end
236
+
237
+ def add_instructs
238
+ instructions.map do |instruction|
239
+ {
240
+ program_id_index: @account_keys.index(instruction.program_id),
241
+ accounts: instruction.keys.map { |meta| @account_keys.index(meta[:pubkey]) },
242
+ data: instruction.data
243
+ }
244
+ end
245
+ end
246
+
247
+ def split_keys(unique_metas, signed_keys, unsigned_keys)
248
+ @num_required_signatures = 0
249
+ @num_readonly_signed_accounts = 0
250
+ @num_readonly_unsigned_accounts = 0
251
+ unique_metas.each do |meta|
252
+ if meta[:is_signer]
253
+ signed_keys.push(meta[:pubkey])
254
+ @num_required_signatures += 1
255
+ @num_readonly_signed_accounts += 1 if (!meta[:is_writable])
256
+ else
257
+ unsigned_keys.push(meta[:pubkey])
258
+ @num_readonly_unsigned_accounts += 1 if (!meta[:is_writable])
259
+ end
260
+ end
261
+ end
262
+
263
+ def partial_sign(message, keys)
264
+ sign_data = message.serialize
265
+ keys.each do |key|
266
+ private_key_bytes = [key[:private_key]].pack('H*')
267
+ signing_key = RbNaCl::Signatures::Ed25519::SigningKey.new(private_key_bytes)
268
+ signature = signing_key.sign(sign_data.pack('C*')).bytes
269
+ add_signature(key[:public_key], signature)
270
+ end
271
+ end
272
+
273
+ def add_signature(pubkey, signature)
274
+ raise 'error' unless signature.length === 64
275
+ index = signatures.find_index{|s| s[:public_key] == pubkey}
276
+ raise "unknown signer: #{pubkey}" unless index
277
+
278
+ @signatures[index][:signature] = signature
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,138 @@
1
+ module SolanaRuby
2
+ class TransactionHelper
3
+ require 'base58'
4
+ require 'pry'
5
+
6
+ # Constants for program IDs
7
+ SYSTEM_PROGRAM_ID = '11111111111111111111111111111111'
8
+ TOKEN_PROGRAM_ID = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
9
+ ASSOCIATED_TOKEN_PROGRAM_ID = 'ATokenGP3evbxxpQ7bYPLNNaxD2c4bqtvWjpKbmz6HjH'
10
+
11
+ INSTRUCTION_LAYOUTS = {
12
+ # Native SOL transfer
13
+ sol_transfer: {
14
+ instruction: :uint32,
15
+ lamports: :near_int64
16
+ },
17
+ # SPL token transfer
18
+ spl_transfer: {
19
+ instruction: :uint8,
20
+ amount: :uint64
21
+ },
22
+ # Create account layout
23
+ create_account: {
24
+ instruction: :uint8,
25
+ lamports: :uint64,
26
+ space: :uint64
27
+ }
28
+ }
29
+
30
+ # Method to create a system account (e.g., for SPL token or SOL)
31
+ def self.create_account(from_pubkey, new_account_pubkey, lamports, space, owner_pubkey = SYSTEM_PROGRAM_ID)
32
+ instruction_data = encode_data(INSTRUCTION_LAYOUTS[:create_account], { instruction: 0, lamports: lamports, space: space })
33
+ create_account_instruction = TransactionInstruction.new(
34
+ keys: [
35
+ { pubkey: from_pubkey, is_signer: true, is_writable: true },
36
+ { pubkey: new_account_pubkey, is_signer: false, is_writable: true },
37
+ { pubkey: owner_pubkey, is_signer: false, is_writable: false }
38
+ ],
39
+ program_id: owner_pubkey,
40
+ data: instruction_data.bytes
41
+ )
42
+ create_account_instruction
43
+ end
44
+
45
+ def self.create_and_sign_transaction(from_pubkey, new_account_pubkey, lamports, space, recent_blockhash)
46
+ # Create the transaction
47
+ transaction = Transaction.new
48
+ transaction.set_fee_payer(from_pubkey)
49
+ transaction.set_recent_blockhash(recent_blockhash)
50
+
51
+ # Add the create account instruction to the transaction
52
+ create_account_instruction = create_account(from_pubkey, new_account_pubkey, lamports, space)
53
+ transaction.add_instruction(create_account_instruction)
54
+
55
+ # You would then sign the transaction and send it as needed
56
+ # Example: signing and sending the transaction
57
+ transaction
58
+ end
59
+
60
+ # Method to create a SOL transfer instruction
61
+ def self.transfer_sol_transaction(from_pubkey, to_pubkey, lamports)
62
+ fields = INSTRUCTION_LAYOUTS[:sol_transfer]
63
+ data = encode_data(fields, { instruction: 2, lamports: lamports })
64
+ TransactionInstruction.new(
65
+ keys: [
66
+ { pubkey: from_pubkey, is_signer: true, is_writable: true },
67
+ { pubkey: to_pubkey, is_signer: false, is_writable: true }
68
+ ],
69
+ program_id: SYSTEM_PROGRAM_ID,
70
+ data: data
71
+ )
72
+ end
73
+
74
+ # Helper to create a new transaction for SOL transfer
75
+ def self.new_sol_transaction(from_pubkey, to_pubkey, lamports, recent_blockhash)
76
+ transaction = Transaction.new
77
+ transaction.set_fee_payer(from_pubkey)
78
+ transaction.set_recent_blockhash(recent_blockhash)
79
+ transfer_instruction = transfer_sol_transaction(from_pubkey, to_pubkey, lamports)
80
+ transaction.add_instruction(transfer_instruction)
81
+ transaction
82
+ end
83
+
84
+ # Method to create an SPL token transfer instruction
85
+ def self.transfer_spl_token(source, destination, owner, amount)
86
+ fields = INSTRUCTION_LAYOUTS[:spl_transfer]
87
+ data = encode_data(fields, { instruction: 3, amount: amount }) # Instruction type 3: Transfer tokens
88
+ TransactionInstruction.new(
89
+ keys: [
90
+ { pubkey: source, is_signer: false, is_writable: true },
91
+ { pubkey: destination, is_signer: false, is_writable: true },
92
+ { pubkey: owner, is_signer: true, is_writable: false }
93
+ ],
94
+ program_id: TOKEN_PROGRAM_ID,
95
+ data: data
96
+ )
97
+ end
98
+
99
+ # Helper to create a new transaction for SPL token transfer
100
+ def self.new_spl_token_transaction(source, destination, owner, amount, recent_blockhash)
101
+ transaction = Transaction.new
102
+ transaction.set_fee_payer(owner)
103
+ transaction.set_recent_blockhash(recent_blockhash)
104
+ transfer_instruction = transfer_spl_token(source, destination, owner, amount)
105
+ transaction.add_instruction(transfer_instruction)
106
+ transaction
107
+ end
108
+
109
+ # Method to create an associated token account for a given token mint
110
+ def self.create_associated_token_account(from_pubkey, token_mint, owner_pubkey)
111
+ data = [0, 0, 0, 0] # No data required for account creation
112
+ create_account_instruction = TransactionInstruction.new(
113
+ keys: [
114
+ { pubkey: from_pubkey, is_signer: true, is_writable: true },
115
+ { pubkey: owner_pubkey, is_signer: false, is_writable: true },
116
+ { pubkey: token_mint, is_signer: false, is_writable: false },
117
+ { pubkey: ASSOCIATED_TOKEN_PROGRAM_ID, is_signer: false, is_writable: false },
118
+ { pubkey: SYSTEM_PROGRAM_ID, is_signer: false, is_writable: false }
119
+ ],
120
+ program_id: ASSOCIATED_TOKEN_PROGRAM_ID,
121
+ data: data
122
+ )
123
+ create_account_instruction
124
+ end
125
+
126
+ # Utility to encode data using predefined layouts
127
+ def self.encode_data(fields, data)
128
+ layout = SolanaRuby::DataTypes::Layout.new(fields)
129
+ layout.serialize(data)
130
+ end
131
+
132
+ # Utility to decode data using predefined layouts
133
+ def self.decode_data(fields, data)
134
+ layout = SolanaRuby::DataTypes::Layout.new(fields)
135
+ layout.deserialize(data)
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,42 @@
1
+ module SolanaRuby
2
+ class TransactionInstruction
3
+ require 'base58'
4
+
5
+ attr_accessor :keys, :program_id, :data
6
+
7
+ def initialize(keys:, program_id:, data:)
8
+ @keys = keys # Array of account metadata hashes
9
+ @program_id = program_id # Program ID in Base58
10
+ @data = data # Binary data for the instruction
11
+ end
12
+
13
+ def serialize
14
+ serialized_instruction = ""
15
+
16
+ # Convert and serialize the program ID from Base58 to binary
17
+ program_id_binary = Base58.base58_to_binary(@program_id)
18
+ serialized_instruction << program_id_binary
19
+
20
+ # Serialize the number of keys
21
+ serialized_instruction << [@keys.length].pack("C")
22
+
23
+ # Serialize each key (pubkey in binary, is_signer, is_writable flags)
24
+ @keys.each do |key_meta|
25
+ # Convert public key to binary and serialize it
26
+ pubkey_binary = Base58.base58_to_binary(key_meta[:pubkey])
27
+ serialized_instruction << pubkey_binary
28
+
29
+ # Serialize meta flags (is_signer and is_writable)
30
+ meta_flags = (key_meta[:is_signer] ? 1 : 0) | (key_meta[:is_writable] ? 2 : 0)
31
+ serialized_instruction << [meta_flags].pack("C")
32
+ end
33
+
34
+ # Serialize data length (encoded as a single byte, can adjust with C, S, and L accordingly if data is larger)
35
+ serialized_instruction << [@data.length].pack("C")
36
+
37
+ # Serialize the actual data in binary format
38
+ serialized_instruction << @data
39
+ serialized_instruction
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,66 @@
1
+ require 'base58'
2
+ require 'digest/sha2'
3
+
4
+ module SolanaRuby
5
+ class Utils
6
+ class << self
7
+ # Decodes a length-prefixed byte array using a variable-length encoding.
8
+ def decode_length(bytes)
9
+ raise ArgumentError, "Input must be an array of bytes" unless bytes.is_a?(Array)
10
+
11
+ length = 0
12
+ size = 0
13
+ loop do
14
+ raise "Unexpected end of bytes during length decoding" if bytes.empty?
15
+
16
+ byte = bytes.shift
17
+ length |= (byte & 0x7F) << (size * 7)
18
+ size += 1
19
+ break if (byte & 0x80).zero?
20
+ end
21
+ length
22
+ end
23
+
24
+ # Encodes a length as a variable-length byte array.
25
+ def encode_length(length)
26
+ raise ArgumentError, "Length must be a non-negative integer" unless length.is_a?(Integer) && length >= 0
27
+
28
+ bytes = []
29
+ loop do
30
+ byte = length & 0x7F
31
+ length >>= 7
32
+ if length.zero?
33
+ bytes << byte
34
+ break
35
+ else
36
+ bytes << (byte | 0x80)
37
+ end
38
+ end
39
+ bytes
40
+ end
41
+
42
+ # Converts a byte array to a Base58-encoded string.
43
+ def bytes_to_base58(bytes)
44
+ raise ArgumentError, "Input must be an array of bytes" unless bytes.is_a?(Array)
45
+
46
+ Base58.binary_to_base58(bytes.pack('C*'), :bitcoin)
47
+ end
48
+
49
+ # Converts a Base58-encoded string to a byte array.
50
+ def base58_to_bytes(base58_string)
51
+ raise ArgumentError, "Input must be a non-empty string" unless base58_string.is_a?(String) && !base58_string.empty?
52
+
53
+ Base58.base58_to_binary(base58_string, :bitcoin).bytes
54
+ rescue ArgumentError
55
+ raise "Invalid Base58 string: #{base58_string}"
56
+ end
57
+
58
+ # Computes the SHA-256 hash of the given data and returns it as a hexadecimal string.
59
+ def sha256(data)
60
+ raise ArgumentError, "Data must be a string" unless data.is_a?(String)
61
+
62
+ Digest::SHA256.hexdigest(data)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolanaRuby
4
- VERSION = "1.0.1.beta3"
4
+ VERSION = "2.0.0"
5
5
  end
data/lib/solana_ruby.rb CHANGED
@@ -1,10 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "solana_ruby/version"
4
- require_relative "solana_ruby/http_client"
5
- require_relative "solana_ruby/web_socket_client"
3
+ Dir[File.join(__dir__, 'solana_ruby', '*.rb')].each { |file| require file }
6
4
  # Dir["solana_ruby/*.rb"].each { |f| require_relative f.delete(".rb") }
7
-
5
+ require 'pry'
8
6
  module SolanaRuby
9
7
  class Error < StandardError; end
10
8
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir[File.join(File.dirname(__dir__), 'lib/solana_ruby/*.rb')].each { |file| require file }
4
+ Dir[File.join(File.dirname(__dir__), 'lib/solana_ruby/**/*.rb')].each { |file| require file }
5
+
6
+ # Testing Script
7
+
8
+ client = SolanaRuby::HttpClient.new('http://127.0.0.1:8899')
9
+
10
+ # Fetch the recent blockhash
11
+ recent_blockhash = client.get_latest_blockhash["blockhash"]
12
+
13
+ # Generate a sender keypair and public key
14
+ sender_keypair = SolanaRuby::Keypair.from_private_key("d22867a84ee1d91485a52c587793002dcaa7ce79a58bb605b3af2682099bb778")
15
+ sender_pubkey = sender_keypair[:public_key]
16
+ lamports = 1 * 1_000_000_000
17
+ space = 165
18
+ balance = client.get_balance(sender_pubkey)
19
+ puts "sender account balance: #{balance}, wait for few seconds to update the balance in solana when the balance 0"
20
+
21
+
22
+ # Generate a receiver keypair and public key
23
+ new_account = SolanaRuby::Keypair.generate
24
+ new_account_pubkey = new_account[:public_key]
25
+
26
+ # create a transaction instruction
27
+ transaction = SolanaRuby::TransactionHelper.create_and_sign_transaction(sender_pubkey, new_account_pubkey, lamports, space, recent_blockhash)
28
+
29
+ signed_transaction = transaction.sign([sender_keypair])
30
+ sleep(5)
31
+ response = client.send_transaction(transaction.to_base64, { encoding: 'base64' })
32
+ puts "Response: #{response}"
@@ -0,0 +1,56 @@
1
+ Dir[File.join(File.dirname(__dir__), 'lib/solana_ruby/*.rb')].each { |file| require file }
2
+ Dir[File.join(File.dirname(__dir__), 'lib/solana_ruby/**/*.rb')].each { |file| require file }
3
+ require 'pry'
4
+
5
+ # SOL Transfer Testing Script
6
+
7
+ # Initialize the Solana client
8
+ client = SolanaRuby::HttpClient.new('http://127.0.0.1:8899')
9
+
10
+ # Fetch the recent blockhash
11
+ recent_blockhash = client.get_latest_blockhash["blockhash"]
12
+
13
+ # Generate a sender keypair and public key or Fetch payers keypair using private key
14
+ # sender_keypair = SolanaRuby::Keypair.from_private_key("InsertPrivateKeyHere")
15
+ sender_keypair = SolanaRuby::Keypair.generate
16
+ sender_pubkey = sender_keypair[:public_key]
17
+
18
+
19
+ # Airdrop some lamports to the sender's account
20
+ lamports = 10 * 1_000_000_000
21
+ sleep(1)
22
+ result = client.request_airdrop(sender_pubkey, lamports)
23
+ puts "Solana Balance #{lamports} lamports added sucessfully for the public key: #{sender_pubkey}"
24
+ sleep(10)
25
+
26
+
27
+ # Generate or existing receiver keypair and public key
28
+ keypair = SolanaRuby::Keypair.generate # generate receiver keypair
29
+ receiver_pubkey = keypair[:public_key]
30
+ # receiver_pubkey = 'InsertExistingPublicKeyHere'
31
+
32
+ transfer_lamports = 1 * 1_000_000
33
+ puts "Payer's full private key: #{sender_keypair[:full_private_key]}"
34
+ puts "Receiver's full private key: #{keypair[:full_private_key]}"
35
+ puts "Receiver's Public Key: #{keypair[:public_key]}"
36
+
37
+ # Create a new transaction
38
+ transaction = SolanaRuby::TransactionHelper.new_sol_transaction(
39
+ sender_pubkey,
40
+ receiver_pubkey,
41
+ transfer_lamports,
42
+ recent_blockhash
43
+ )
44
+
45
+ # Get the sender's private key (ensure it's a string)
46
+ private_key = sender_keypair[:private_key]
47
+ puts "Private key type: #{private_key.class}, Value: #{private_key.inspect}"
48
+
49
+ # Sign the transaction
50
+ signed_transaction = transaction.sign([sender_keypair])
51
+
52
+ # Send the transaction to the Solana network
53
+ sleep(5)
54
+ response = client.send_transaction(transaction.to_base64, { encoding: 'base64' })
55
+ puts "Response: #{response}"
56
+
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir[File.join(File.dirname(__dir__), 'lib/solana_ruby/*.rb')].each { |file| require file }
4
+ Dir[File.join(File.dirname(__dir__), 'lib/solana_ruby/**/*.rb')].each { |file| require file }
5
+ # Dir["solana_ruby/*.rb"].each { |f| require_relative f.delete(".rb") }
6
+
7
+
8
+ # Testing Script
9
+
10
+ client = SolanaRuby::HttpClient.new('http://127.0.0.1:8899')
11
+
12
+ # Fetch the recent blockhash
13
+ recent_blockhash = client.get_latest_blockhash["blockhash"]
14
+
15
+ # Generate a sender keypair and public key
16
+ fee_payer = SolanaRuby::Keypair.from_private_key("d22867a84ee1d91485a52c587793002dcaa7ce79a58bb605b3af2682099bb778")
17
+ fee_payer_pubkey = fee_payer[:public_key]
18
+ lamports = 10 * 1_000_000_000
19
+ space = 165
20
+
21
+ # get balance for the fee payer
22
+ balance = client.get_balance(fee_payer_pubkey)
23
+ puts "sender account balance: #{balance}, wait for few seconds to update the balance in solana when the balance 0"
24
+
25
+
26
+ # # Generate a receiver keypair and public key
27
+ keypair = SolanaRuby::Keypair.generate
28
+ receiver_pubkey = keypair[:public_key]
29
+ transfer_lamports = 1 * 1_000_000
30
+ # puts "Payer's full private key: #{sender_keypair[:full_private_key]}"
31
+ # # puts "Receiver's full private key: #{keypair[:full_private_key]}"
32
+ # # puts "Receiver's Public Key: #{keypair[:public_key]}"
33
+ mint_address = '9BvJGQC5FkLJzUC2TmYpi1iU8n9vt2388GLT5zvu8S1G'
34
+ token_program_id = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
35
+
36
+ # Create a new transaction
37
+ transaction = SolanaRuby::TransactionHelper.new_spl_token_transaction(
38
+ "9BvJGQC5FkLJzUC2TmYpi1iU8n9vt2388GLT5zvu8S1G",
39
+ receiver_pubkey,
40
+ fee_payer_pubkey,
41
+ transfer_lamports,
42
+ recent_blockhash
43
+ )
44
+ # # Get the sender's private key (ensure it's a string)
45
+ private_key = fee_payer[:private_key]
46
+ puts "Private key type: #{private_key.class}, Value: #{private_key.inspect}"
47
+
48
+ # Sign the transaction
49
+ signed_transaction = transaction.sign([fee_payer])
50
+
51
+ # Send the transaction to the Solana network
52
+ sleep(5)
53
+ response = client.send_transaction(transaction.to_base64, { encoding: 'base64' })
54
+ puts "Response: #{response}"
55
+
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solana-ruby-web3js
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1.beta3
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - BuildSquad
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-10-03 00:00:00.000000000 Z
11
+ date: 2024-11-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: websocket-client-simple
@@ -52,6 +52,34 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: 0.2.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: rbnacl
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '6.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '6.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: ed25519
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
55
83
  - !ruby/object:Gem::Dependency
56
84
  name: brakeman
57
85
  requirement: !ruby/object:Gem::Requirement
@@ -201,6 +229,12 @@ files:
201
229
  - lib/solana_ruby.rb
202
230
  - lib/solana_ruby/.DS_Store
203
231
  - lib/solana_ruby/base_client.rb
232
+ - lib/solana_ruby/data_types.rb
233
+ - lib/solana_ruby/data_types/blob.rb
234
+ - lib/solana_ruby/data_types/layout.rb
235
+ - lib/solana_ruby/data_types/near_int64.rb
236
+ - lib/solana_ruby/data_types/sequence.rb
237
+ - lib/solana_ruby/data_types/unsigned_int.rb
204
238
  - lib/solana_ruby/http_client.rb
205
239
  - lib/solana_ruby/http_methods/account_methods.rb
206
240
  - lib/solana_ruby/http_methods/basic_methods.rb
@@ -211,6 +245,12 @@ files:
211
245
  - lib/solana_ruby/http_methods/slot_methods.rb
212
246
  - lib/solana_ruby/http_methods/token_methods.rb
213
247
  - lib/solana_ruby/http_methods/transaction_methods.rb
248
+ - lib/solana_ruby/keypair.rb
249
+ - lib/solana_ruby/message.rb
250
+ - lib/solana_ruby/transaction.rb
251
+ - lib/solana_ruby/transaction_helper.rb
252
+ - lib/solana_ruby/transaction_instruction.rb
253
+ - lib/solana_ruby/utils.rb
214
254
  - lib/solana_ruby/version.rb
215
255
  - lib/solana_ruby/web_socket_client.rb
216
256
  - lib/solana_ruby/web_socket_handlers.rb
@@ -219,6 +259,9 @@ files:
219
259
  - lib/solana_ruby/web_socket_methods/root_methods.rb
220
260
  - lib/solana_ruby/web_socket_methods/signature_methods.rb
221
261
  - lib/solana_ruby/web_socket_methods/slot_methods.rb
262
+ - transaction_testing/create_account.rb
263
+ - transaction_testing/sol_transfer.rb
264
+ - transaction_testing/spl_token_transfer.rb
222
265
  homepage: https://github.com/Build-Squad/solana-ruby
223
266
  licenses:
224
267
  - MIT
@@ -241,7 +284,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
241
284
  - !ruby/object:Gem::Version
242
285
  version: '0'
243
286
  requirements: []
244
- rubygems_version: 3.5.20
287
+ rubygems_version: 3.5.23
245
288
  signing_key:
246
289
  specification_version: 4
247
290
  summary: Solana Ruby SDK