tron.rb 1.0.9 → 1.1.3

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.
@@ -5,7 +5,11 @@ require_relative '../utils/rate_limiter'
5
5
 
6
6
  module Tron
7
7
  module Services
8
+ # The Price service handles retrieving token price information for TRON tokens
8
9
  class Price
10
+ # Creates a new instance of the Price service
11
+ #
12
+ # @param config [Tron::Configuration] the configuration object
9
13
  def initialize(config)
10
14
  @config = config
11
15
  @cache = Utils::Cache.new(max_age: config.cache_ttl) if config.cache_enabled
@@ -14,6 +18,10 @@ module Tron
14
18
  @cache_misses = 0
15
19
  end
16
20
 
21
+ # Gets the price information for a specific token
22
+ #
23
+ # @param token [String] the token symbol (default: 'trx')
24
+ # @return [Hash] the token price information
17
25
  def get_token_price(token = 'trx')
18
26
  cache_key = cache_key_for(token)
19
27
 
@@ -57,6 +65,9 @@ module Tron
57
65
  end
58
66
  end
59
67
 
68
+ # Gets price information for all available tokens
69
+ #
70
+ # @return [Hash] the price information for all tokens
60
71
  def get_all_prices
61
72
  url = "#{@config.tronscan_base_url}/api/getAssetWithPriceList"
62
73
  headers = tronscan_headers
@@ -78,6 +89,10 @@ module Tron
78
89
  end
79
90
  end
80
91
 
92
+ # Gets the USD price for a specific token
93
+ #
94
+ # @param token [String] the token symbol
95
+ # @return [Float] the token price in USD, or nil if unavailable
81
96
  def get_token_price_usd(token)
82
97
  cache_key = "#{cache_key_for(token)}:usd"
83
98
 
@@ -105,6 +120,11 @@ module Tron
105
120
  result
106
121
  end
107
122
 
123
+ # Calculates the USD value for a given token balance
124
+ #
125
+ # @param balance [String, Numeric] the token balance
126
+ # @param token [String] the token symbol
127
+ # @return [Float] the USD value, or nil if unavailable
108
128
  def get_token_value_usd(balance, token)
109
129
  price = get_token_price_usd(token)
110
130
  if price
@@ -115,6 +135,10 @@ module Tron
115
135
  end
116
136
  end
117
137
 
138
+ # Gets prices for multiple tokens at once
139
+ #
140
+ # @param tokens [Array<String>] an array of token symbols
141
+ # @return [Hash] a hash mapping token symbols to their prices
118
142
  def get_multiple_token_prices(tokens)
119
143
  prices = {}
120
144
  tokens.each_with_index do |token, index|
@@ -134,6 +158,11 @@ module Tron
134
158
  prices
135
159
  end
136
160
 
161
+ # Formats a price value for display
162
+ #
163
+ # @param price [Float, nil] the price value
164
+ # @param currency [String] the currency symbol (default: 'USD')
165
+ # @return [String] the formatted price
137
166
  def format_price(price, currency = 'USD')
138
167
  if price.nil?
139
168
  '(price unavailable)'
@@ -146,6 +175,9 @@ module Tron
146
175
  end
147
176
  end
148
177
 
178
+ # Gets cache statistics for the price service
179
+ #
180
+ # @return [Hash] a hash containing cache statistics
149
181
  def cache_stats
150
182
  total = @cache_hits + @cache_misses
151
183
  {
@@ -156,6 +188,7 @@ module Tron
156
188
  }
157
189
  end
158
190
 
191
+ # Clears the cache for the price service
159
192
  def clear_cache
160
193
  @cache&.clear
161
194
  @cache_hits = 0
@@ -164,10 +197,17 @@ module Tron
164
197
 
165
198
  private
166
199
 
200
+ # Generates a cache key for a specific token
201
+ #
202
+ # @param token [String] the token symbol
203
+ # @return [String] the cache key
167
204
  def cache_key_for(token)
168
205
  "price:#{token.downcase}:#{@config.network}"
169
206
  end
170
207
 
208
+ # Creates headers for the Tronscan API
209
+ #
210
+ # @return [Hash] the Tronscan API headers
171
211
  def tronscan_headers
172
212
  headers = { 'accept' => 'application/json' }
173
213
  headers['TRON-PRO-API-KEY'] = @config.tronscan_api_key if @config.tronscan_api_key
@@ -4,11 +4,20 @@ require_relative '../utils/address'
4
4
 
5
5
  module Tron
6
6
  module Services
7
+ # The Resources service handles retrieving account resources (bandwidth, energy, storage) for TRON addresses
7
8
  class Resources
9
+ # Creates a new instance of the Resources service
10
+ #
11
+ # @param config [Tron::Configuration] the configuration object
8
12
  def initialize(config)
9
13
  @config = config
10
14
  end
11
15
 
16
+ # Gets the account resources for a given address
17
+ #
18
+ # @param address [String] the TRON address to check
19
+ # @return [Hash] a hash containing resource information
20
+ # @raise [ArgumentError] if the address is invalid
12
21
  def get(address)
13
22
  validate_address!(address)
14
23
 
@@ -23,6 +32,10 @@ module Tron
23
32
 
24
33
  private
25
34
 
35
+ # Gets account resources using the getaccountresource endpoint
36
+ #
37
+ # @param address [String] the TRON address to check
38
+ # @return [Hash] a hash containing resource information
26
39
  def get_account_resources(address)
27
40
  url = "#{@config.base_url}/wallet/getaccountresource"
28
41
  headers = api_headers
@@ -68,6 +81,10 @@ module Tron
68
81
  }
69
82
  end
70
83
 
84
+ # Gets account resources using the getaccount endpoint as a fallback
85
+ #
86
+ # @param address [String] the TRON address to check
87
+ # @return [Hash] a hash containing resource information
71
88
  def get_account_resources_fallback(address)
72
89
  url = "#{@config.base_url}/wallet/getaccount"
73
90
  headers = api_headers
@@ -100,16 +117,26 @@ module Tron
100
117
  }
101
118
  end
102
119
 
120
+ # Validates a TRON address
121
+ #
122
+ # @param address [String] the address to validate
123
+ # @raise [ArgumentError] if the address is invalid
103
124
  def validate_address!(address)
104
125
  raise ArgumentError, "Invalid TRON address: #{address}" unless Utils::Address.validate(address)
105
126
  end
106
127
 
128
+ # Creates headers for the TRON API
129
+ #
130
+ # @return [Hash] the API headers
107
131
  def api_headers
108
132
  headers = { 'accept' => 'application/json', 'Content-Type' => 'application/json' }
109
133
  headers['TRON-PRO-API-KEY'] = @config.api_key if @config.api_key
110
134
  headers
111
135
  end
112
136
 
137
+ # Returns default resource values
138
+ #
139
+ # @return [Hash] a hash with zero resource values
113
140
  def default_resources
114
141
  {
115
142
  bandwidth: 0,
@@ -0,0 +1,104 @@
1
+ require_relative '../utils/http'
2
+ require_relative '../key'
3
+ require 'json'
4
+
5
+ module Tron
6
+ module Services
7
+ # The Transaction service handles signing and broadcasting of TRON transactions
8
+ class Transaction
9
+ # Creates a new instance of the Transaction service
10
+ #
11
+ # @param configuration [Tron::Configuration] the configuration object
12
+ def initialize(configuration)
13
+ @configuration = configuration
14
+ @base_url = configuration.base_url
15
+ end
16
+
17
+ # Signs and broadcasts a transaction
18
+ #
19
+ # @param transaction [Hash] the transaction to sign and broadcast
20
+ # @param private_key [String] the private key to sign the transaction with
21
+ # @param local_signing [Boolean] whether to sign locally (default: true)
22
+ # @return [Hash] the response from the broadcast
23
+ def sign_and_broadcast(transaction, private_key, local_signing: true)
24
+ if local_signing
25
+ signed_tx = sign_transaction_locally(transaction, private_key)
26
+ else
27
+ signed_tx = sign_transaction_via_api(transaction, private_key)
28
+ end
29
+
30
+ broadcast_transaction(signed_tx)
31
+ end
32
+
33
+ private
34
+
35
+ # Signs a transaction locally using the private key
36
+ #
37
+ # IMPORTANT: TRON uses SHA256 for transaction hashing, NOT Keccak256
38
+ # The txID provided by TronGrid API is already the correct SHA256 hash
39
+ # of the properly protobuf-serialized raw_data
40
+ #
41
+ # @param transaction [Hash] the transaction to sign (must have 'txID' field)
42
+ # @param private_key [String] the private key in hex format
43
+ # @return [Hash] the signed transaction
44
+ # @raise [ArgumentError] if transaction doesn't have txID field
45
+ def sign_transaction_locally(transaction, private_key)
46
+ # Create a key instance with the provided private key
47
+ key = Key.new(priv: private_key)
48
+
49
+ # Ensure transaction has txID from TronGrid API
50
+ unless transaction['txID']
51
+ raise ArgumentError, "Transaction must have 'txID' field. Create transaction via TronGrid API first."
52
+ end
53
+
54
+ # The txID is the SHA256 hash of the protobuf-serialized raw_data
55
+ # Convert from hex string to binary for signing
56
+ tx_hash = Tron::Utils::Crypto.hex_to_bin(transaction['txID'])
57
+
58
+ # Sign the transaction hash locally
59
+ # SECURITY: Private key never leaves this machine!
60
+ signature = key.sign(tx_hash)
61
+
62
+ # Add the signature to the transaction
63
+ transaction['signature'] = [signature]
64
+
65
+ transaction
66
+ end
67
+
68
+ # Signs a transaction via the API (legacy method)
69
+ #
70
+ # @param transaction [Hash] the transaction to sign
71
+ # @param private_key [String] the private key in hex format
72
+ # @return [Hash] the signed transaction from the API
73
+ def sign_transaction_via_api(transaction, private_key)
74
+ # Legacy API-based signing
75
+ endpoint = "#{@base_url}/wallet/gettransactionsign"
76
+ payload = {
77
+ transaction: transaction,
78
+ private_key: private_key
79
+ }
80
+
81
+ Utils::HTTP.post(endpoint, payload)
82
+ end
83
+
84
+ # Broadcasts a signed transaction to the TRON network
85
+ #
86
+ # @param signed_transaction [Hash] the signed transaction to broadcast
87
+ # @return [Hash] the response from the broadcast
88
+ # @raise [RuntimeError] if the transaction fails to broadcast
89
+ def broadcast_transaction(signed_transaction)
90
+ endpoint = "#{@base_url}/wallet/broadcasttransaction"
91
+
92
+ response = Utils::HTTP.post(endpoint, signed_transaction)
93
+
94
+ # Check if the transaction was successful
95
+ unless response['result']
96
+ error = response['Error'] || response['error'] || 'Unknown error'
97
+ raise "Transaction failed: #{error}"
98
+ end
99
+
100
+ response
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tron
4
+ # Module for handling TRON signature operations
5
+ module Signature
6
+ # Custom error class for signature-related errors
7
+ class SignatureError < StandardError; end
8
+
9
+ # Prefix byte for TRON signed messages
10
+ PREFIX_BYTE = "\x19".freeze
11
+
12
+ # Prefixes a message according to the TRON signed message format
13
+ # This format is used in personal_sign operations
14
+ #
15
+ # @param message [String] the message to prefix
16
+ # @return [String] the prefixed message ready for signing
17
+ def self.prefix_message(message)
18
+ "#{PREFIX_BYTE}Tron Signed Message:\n#{message.size}#{message}"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,321 @@
1
+ require 'digest'
2
+
3
+ module Tron
4
+ module Utils
5
+ # Utility class for handling TRON contract ABIs
6
+ class ABI
7
+ # Type mappings for Solidity to Ruby
8
+ SOLIDITY_TYPES = {
9
+ 'address' => :address,
10
+ 'uint256' => :uint256,
11
+ 'uint' => :uint256,
12
+ 'bool' => :bool,
13
+ 'bytes16' => :bytes16,
14
+ 'bytes32' => :bytes32,
15
+ 'string' => :string
16
+ }.freeze
17
+
18
+ # Parse function signature
19
+ #
20
+ # @param signature [String] the function signature to parse
21
+ # @return [Hash] a hash with :name and :params keys
22
+ # @raise [ArgumentError] if the signature is invalid
23
+ def self.parse_signature(signature)
24
+ match = signature.match(/^(\w+)\((.*)\)$/)
25
+ raise ArgumentError, "Invalid function signature: #{signature}" unless match
26
+
27
+ {
28
+ name: match[1],
29
+ params: match[2].empty? ? [] : match[2].split(',').map(&:strip)
30
+ }
31
+ end
32
+
33
+ # Encode function call
34
+ #
35
+ # @param signature [String] the function signature
36
+ # @param parameters [Array] the parameter values
37
+ # @return [String] the encoded function call
38
+ def self.encode_function_call(signature, parameters = [])
39
+ parsed = parse_signature(signature)
40
+
41
+ # Get function selector (first 4 bytes of keccak256 hash)
42
+ selector = function_selector(signature)
43
+
44
+ # Encode parameters
45
+ encoded_params = encode_parameters(parsed[:params], parameters)
46
+
47
+ selector + encoded_params
48
+ end
49
+
50
+ # Function selector (4-byte hash)
51
+ #
52
+ # @param signature [String] the function signature
53
+ # @return [String] the 4-byte function selector as hex
54
+ def self.function_selector(signature)
55
+ require_relative 'crypto'
56
+ # Note: TRON uses same ABI as Ethereum
57
+ # Using Keccak256 for hash
58
+ hash = Crypto.keccak256(signature)
59
+ # Take only the first 4 bytes (8 hex chars)
60
+ Crypto.bin_to_hex(hash[0, 4])
61
+ end
62
+
63
+ # Encode parameters
64
+ #
65
+ # @param types [Array<String>] the parameter types
66
+ # @param values [Array] the parameter values
67
+ # @return [String] the encoded parameters as hex
68
+ # @raise [ArgumentError] if there's a mismatch between types and values
69
+ def self.encode_parameters(types, values)
70
+ raise ArgumentError, "Types and values length mismatch" if types.length != values.length
71
+
72
+ encoded_parts = []
73
+ dynamic_params = []
74
+ dynamic_offset = types.length * 32 # Each static param takes 32 bytes (64 hex chars)
75
+
76
+ types.each_with_index do |type, index|
77
+ value = values[index]
78
+
79
+ case type
80
+ when 'address'
81
+ # Address is padded to 32 bytes (64 hex chars)
82
+ padded_address = encode_address(value)
83
+ encoded_parts << padded_address
84
+ when 'uint256', 'uint'
85
+ # Convert to hex and pad to 32 bytes (64 hex chars)
86
+ hex_value = encode_uint256(value)
87
+ encoded_parts << hex_value
88
+ when 'bool'
89
+ # Boolean to uint256 (1 for true, 0 for false)
90
+ encoded_parts << encode_bool(value)
91
+ when /^bytes(\d+)$/
92
+ # Static, fixed-size bytes array, pad to 32 bytes
93
+ encoded_parts << encode_bytes($1.to_i, value)
94
+ when 'string', 'bytes'
95
+ # For dynamic types, add offset and store actual data separately
96
+ encoded_parts << dynamic_offset.to_s(16).rjust(64, '0') # offset
97
+ # Calculate the actual data for later encoding
98
+ dynamic_data = encode_dynamic_parameter(type, value)
99
+ dynamic_params << dynamic_data
100
+ dynamic_offset += (dynamic_data.length / 2.0).ceil # Increase offset by byte length, rounded up to nearest whole byte
101
+ else
102
+ raise ArgumentError, "Unsupported ABI type: #{type}"
103
+ end
104
+ end
105
+
106
+ # Encode dynamic parameters
107
+ dynamic_parts = []
108
+ dynamic_params.each do |data|
109
+ dynamic_parts << data
110
+ end
111
+
112
+ (encoded_parts + dynamic_parts).join
113
+ end
114
+
115
+ # Encode a single value
116
+ #
117
+ # @param type [String] the type to encode
118
+ # @param value [Object] the value to encode
119
+ # @return [String] the encoded value as hex
120
+ # @raise [ArgumentError] if the type is unsupported
121
+ def self.encode_value(type, value)
122
+ case type
123
+ when 'address'
124
+ encode_address(value)
125
+ when 'uint256', 'uint'
126
+ encode_uint256(value)
127
+ when 'bool'
128
+ encode_bool(value)
129
+ when /^bytes(\d+)$/
130
+ encode_bytes($1.to_i, value)
131
+ when 'string'
132
+ encode_string(value)
133
+ else
134
+ raise ArgumentError, "Unsupported type: #{type}"
135
+ end
136
+ end
137
+
138
+ # Encode TRON address (T address to hex)
139
+ #
140
+ # @param address [String] the TRON address to encode
141
+ # @return [String] the encoded address as hex
142
+ def self.encode_address(address)
143
+ # Convert TRON T-address to hex address
144
+ # Remove 'T' prefix, convert base58 to hex, pad to 32 bytes
145
+ hex = Address.to_hex(address)
146
+ # Strip the 41 prefix for ABI encoding (only use the 20-byte address)
147
+ hex = hex[2..-1] if hex.start_with?('41')
148
+ hex.rjust(64, '0') # Pad to 64 hex chars (32 bytes)
149
+ end
150
+
151
+ # Encode uint256
152
+ #
153
+ # @param value [Integer, String] the value to encode
154
+ # @return [String] the encoded uint256 as hex
155
+ def self.encode_uint256(value)
156
+ value.to_i.to_s(16).rjust(64, '0')
157
+ end
158
+
159
+ # Encode bool
160
+ #
161
+ # @param value [Boolean] the boolean value to encode
162
+ # @return [String] the encoded boolean as hex
163
+ def self.encode_bool(value)
164
+ value ? '1'.rjust(64, '0') : '0'.rjust(64, '0')
165
+ end
166
+
167
+ # Encode bytes
168
+ #
169
+ # @param size [Integer] the size of the bytes type
170
+ # @param value [String] the value to encode
171
+ # @return [String] the encoded bytes as hex
172
+ def self.encode_bytes(size, value)
173
+ hex = value.is_a?(String) ? value.unpack1('H*') : value.to_s
174
+ # Limit to the size of the bytes type and pad to 32 bytes
175
+ hex_part = hex[0...(size * 2)] # Each byte is 2 hex chars
176
+ hex_part.ljust(64, '0')
177
+ end
178
+
179
+ # Encode string
180
+ #
181
+ # @param value [String] the string to encode
182
+ # @raise [NotImplementedError] since string encoding is handled separately
183
+ def self.encode_string(value)
184
+ # For dynamic types like string, return the length and data separately
185
+ # This will be handled by the main encode_parameters method
186
+ raise NotImplementedError, "String encoding is handled via encode_dynamic_parameter"
187
+ end
188
+
189
+ # Encode bytes (dynamic type)
190
+ #
191
+ # @param value [String] the bytes value to encode
192
+ # @return [String] the encoded dynamic bytes as hex
193
+ def self.encode_bytes_dynamic(value)
194
+ # For dynamic bytes, encode length and data
195
+ bytes_data = value.is_a?(String) ? value : value.to_s
196
+ bytes_array = bytes_data.start_with?('0x') ? bytes_data[2..-1].scan(/../) : bytes_data.scan(/../)
197
+ length = bytes_array.length.to_s(16).rjust(64, '0')
198
+ data = bytes_array.map { |b| b.rjust(2, '0') }.join
199
+ # Pad data to 32-byte boundaries (64 hex chars)
200
+ padded_length = ((data.length / 64.0).ceil * 64).to_i
201
+ padded_data = data.ljust(padded_length, '0')
202
+
203
+ length + padded_data
204
+ end
205
+
206
+ # Decode output
207
+ #
208
+ # @param type [String] the type to decode
209
+ # @param hex_data [String] the hex data to decode
210
+ # @return [Object] the decoded value
211
+ def self.decode_output(type, hex_data)
212
+ case type
213
+ when 'bool'
214
+ hex_data.to_i(16) != 0
215
+ when 'address'
216
+ Address.from_hex(hex_data)
217
+ when 'uint256', 'uint'
218
+ hex_data.to_i(16)
219
+ else
220
+ hex_data
221
+ end
222
+ end
223
+
224
+ # Decodes parameters based on ABI types
225
+ #
226
+ # @param types [Array<String>] the types to decode
227
+ # @param data [String] the hex data to decode
228
+ # @return [Array] the decoded values
229
+ # @raise [ArgumentError] if the data is invalid or type is unsupported
230
+ def self.decode_parameters(types, data)
231
+ # Remove '0x' prefix if present
232
+ raw_data = data.start_with?('0x') ? data[2..-1] : data
233
+
234
+ # Ensure the data length is valid
235
+ if raw_data.length % 64 != 0
236
+ raise ArgumentError, "Invalid data length: must be multiple of 64 hex chars"
237
+ end
238
+
239
+ values = []
240
+ pos = 0
241
+
242
+ types.each do |type|
243
+ # Each parameter is 32 bytes (64 hex chars)
244
+ param_hex = raw_data[pos...(pos + 64)]
245
+ pos += 64
246
+
247
+ case type
248
+ when /address/
249
+ # Extract the address (last 40 hex chars)
250
+ addr_hex = param_hex[-40..-1]
251
+ # Add TRON prefix
252
+ values << "41#{addr_hex}"
253
+ when /uint/, /int/
254
+ # Convert hex to integer
255
+ values << param_hex.to_i(16)
256
+ when /bool/
257
+ # Boolean is 0 or 1
258
+ values << (param_hex.to_i(16) != 0)
259
+ when /string|bytes/
260
+ # Dynamic type - get offset first
261
+ offset = param_hex.to_i(16) * 2 # Convert to byte position in hex string
262
+
263
+ # Extract length of data
264
+ length_hex = raw_data[offset...(offset + 64)]
265
+ length = length_hex.to_i(16) * 2 # Each byte is 2 hex chars
266
+
267
+ # Extract actual data
268
+ actual_data = raw_data[(offset + 64)...(offset + 64 + length)]
269
+
270
+ if type.start_with?('string')
271
+ # Convert hex to string
272
+ values << [actual_data].pack('H*')
273
+ else
274
+ # Return hex string for bytes
275
+ values << actual_data
276
+ end
277
+ else
278
+ raise ArgumentError, "Unsupported ABI type for decoding: #{type}"
279
+ end
280
+ end
281
+
282
+ values
283
+ end
284
+
285
+ private
286
+
287
+ # Helper method to encode dynamic parameters
288
+ #
289
+ # @param type [String] the dynamic type ('string' or 'bytes')
290
+ # @param value [Object] the value to encode
291
+ # @return [String] the encoded dynamic parameter as hex
292
+ # @raise [ArgumentError] if the type is unsupported
293
+ def self.encode_dynamic_parameter(type, value)
294
+ if type.start_with?('string')
295
+ # For strings, we need to encode length and data
296
+ str_bytes = value.bytes
297
+ length = str_bytes.length.to_s(16).rjust(64, '0')
298
+ data = str_bytes.map { |b| b.to_s(16).rjust(2, '0') }.join
299
+ # Pad data to 32-byte boundaries (64 hex chars)
300
+ padded_length = ((data.length / 64.0).ceil * 64).to_i
301
+ padded_data = data.ljust(padded_length, '0')
302
+
303
+ length + padded_data
304
+ elsif type.start_with?('bytes')
305
+ # For bytes, similar to string
306
+ bytes_data = value.is_a?(String) ? value : value.to_s
307
+ bytes_array = bytes_data.start_with?('0x') ? bytes_data[2..-1].scan(/../) : bytes_data.scan(/../)
308
+ length = bytes_array.length.to_s(16).rjust(64, '0')
309
+ data = bytes_array.map { |b| b.rjust(2, '0') }.join
310
+ # Pad data to 32-byte boundaries (64 hex chars)
311
+ padded_length = ((data.length / 64.0).ceil * 64).to_i
312
+ padded_data = data.ljust(padded_length, '0')
313
+
314
+ length + padded_data
315
+ else
316
+ raise ArgumentError, "Unsupported dynamic type: #{type}"
317
+ end
318
+ end
319
+ end
320
+ end
321
+ end