gasfree_sdk 0.1.0 → 1.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 +4 -4
- data/.rubocop.yml +28 -3
- data/CHANGELOG.md +58 -3
- data/LICENSE.txt +119 -21
- data/README.md +211 -56
- data/examples/basic_usage.rb +26 -10
- data/examples/demo.rb +12 -10
- data/examples/simple_usage_example.rb +107 -0
- data/examples/test_tron_signer.rb +122 -0
- data/lib/gasfree_sdk/base58.rb +65 -0
- data/lib/gasfree_sdk/client.rb +184 -14
- data/lib/gasfree_sdk/crypto.rb +111 -0
- data/lib/gasfree_sdk/models/token.rb +4 -4
- data/lib/gasfree_sdk/models/transfer_response.rb +19 -19
- data/lib/gasfree_sdk/tron_eip712_signer.rb +260 -0
- data/lib/gasfree_sdk/types.rb +25 -2
- data/lib/gasfree_sdk/version.rb +1 -1
- data/lib/gasfree_sdk.rb +3 -0
- metadata +21 -2
@@ -0,0 +1,260 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rbsecp256k1"
|
4
|
+
|
5
|
+
module GasfreeSdk
|
6
|
+
# EIP-712 Signature implementation for TRON GasFree
|
7
|
+
# This module provides functionality to sign EIP-712 structured data
|
8
|
+
# according to TRON's implementation of the standard (TIP-712)
|
9
|
+
class TronEIP712Signer
|
10
|
+
# TRON Nile testnet parameters from GasFree documentation
|
11
|
+
DOMAIN_TESTNET = {
|
12
|
+
name: "GasFreeController",
|
13
|
+
version: "V1.0.0",
|
14
|
+
chainId: 3_448_148_188, # TRON Nile testnet - according to GasFree documentation
|
15
|
+
verifyingContract: "THQGuFzL87ZqhxkgqYEryRAd7gqFqL5rdc"
|
16
|
+
}.freeze
|
17
|
+
|
18
|
+
# TRON Mainnet parameters
|
19
|
+
DOMAIN_MAINNET = {
|
20
|
+
name: "GasFreeController",
|
21
|
+
version: "V1.0.0",
|
22
|
+
chainId: 728_126_428, # TRON Mainnet - according to GasFree documentation
|
23
|
+
verifyingContract: "TFFAMQLZybALaLb4uxHA9RBE7pxhUAjF3U"
|
24
|
+
}.freeze
|
25
|
+
|
26
|
+
# EIP-712 type definitions for PermitTransfer
|
27
|
+
TYPES = {
|
28
|
+
PermitTransfer: [
|
29
|
+
{ name: "token", type: "address" },
|
30
|
+
{ name: "serviceProvider", type: "address" },
|
31
|
+
{ name: "user", type: "address" },
|
32
|
+
{ name: "receiver", type: "address" },
|
33
|
+
{ name: "value", type: "uint256" },
|
34
|
+
{ name: "maxFee", type: "uint256" },
|
35
|
+
{ name: "deadline", type: "uint256" },
|
36
|
+
{ name: "version", type: "uint256" },
|
37
|
+
{ name: "nonce", type: "uint256" }
|
38
|
+
]
|
39
|
+
}.freeze
|
40
|
+
|
41
|
+
class << self
|
42
|
+
# Generate Keccak256 hash of data
|
43
|
+
# @param data [String] Data to hash
|
44
|
+
# @return [String] Binary hash
|
45
|
+
def keccac256(data)
|
46
|
+
GasfreeSdk::Crypto::Keccak256.new.digest(data.to_s)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Generate Keccac256 hash of data as hex string
|
50
|
+
# @param data [String] Data to hash
|
51
|
+
# @return [String] Hex encoded hash
|
52
|
+
def keccac256_hex(data)
|
53
|
+
GasfreeSdk::Crypto::Keccak256.new.hexdigest(data.to_s)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Encode EIP-712 type definition
|
57
|
+
# @param primary_type [Symbol] Primary type name
|
58
|
+
# @param types [Hash] Type definitions
|
59
|
+
# @return [String] Encoded type string
|
60
|
+
def encode_type(primary_type, types = TYPES)
|
61
|
+
deps = find_dependencies(primary_type, types)
|
62
|
+
deps.delete(primary_type)
|
63
|
+
deps = [primary_type] + deps.sort
|
64
|
+
|
65
|
+
deps.map do |type|
|
66
|
+
"#{type}(#{types[type].map { |field| "#{field[:type]} #{field[:name]}" }.join(",")})"
|
67
|
+
end.join
|
68
|
+
end
|
69
|
+
|
70
|
+
# Find type dependencies recursively
|
71
|
+
# @param primary_type [Symbol] Primary type name
|
72
|
+
# @param types [Hash] Type definitions
|
73
|
+
# @param found [Set] Already found dependencies
|
74
|
+
# @return [Set] Set of dependencies
|
75
|
+
def find_dependencies(primary_type, types, found = Set.new)
|
76
|
+
return found if found.include?(primary_type)
|
77
|
+
return found unless types[primary_type]
|
78
|
+
|
79
|
+
found << primary_type
|
80
|
+
types[primary_type].each do |field|
|
81
|
+
dep = field[:type].gsub(/\[\]$/, "")
|
82
|
+
find_dependencies(dep, types, found) if types[dep] && !found.include?(dep)
|
83
|
+
end
|
84
|
+
found
|
85
|
+
end
|
86
|
+
|
87
|
+
# Encode structured data according to EIP-712
|
88
|
+
# @param primary_type [Symbol] Primary type name
|
89
|
+
# @param data [Hash] Data to encode
|
90
|
+
# @param types [Hash] Type definitions
|
91
|
+
# @return [String] Encoded data
|
92
|
+
def encode_data(primary_type, data, types = TYPES) # rubocop:disable Metrics/AbcSize
|
93
|
+
encoded_types = encode_type(primary_type, types)
|
94
|
+
type_hash = keccac256(encoded_types)
|
95
|
+
|
96
|
+
encoded_values = types[primary_type].map do |field|
|
97
|
+
field_name = field[:name]
|
98
|
+
# Try multiple key formats: original, symbol, snake_case conversion
|
99
|
+
value = data[field_name] ||
|
100
|
+
data[field_name.to_sym] ||
|
101
|
+
data[snake_case_to_camel_case(field_name)] ||
|
102
|
+
data[snake_case_to_camel_case(field_name).to_sym] ||
|
103
|
+
data[camel_case_to_snake_case(field_name)] ||
|
104
|
+
data[camel_case_to_snake_case(field_name).to_sym]
|
105
|
+
|
106
|
+
raise "Missing value for field '#{field_name}' in data: #{data.keys}" if value.nil?
|
107
|
+
|
108
|
+
encode_value(field[:type], value, types)
|
109
|
+
end.join
|
110
|
+
|
111
|
+
type_hash + [encoded_values].pack("H*")
|
112
|
+
end
|
113
|
+
|
114
|
+
# Convert snake_case to camelCase
|
115
|
+
# @param str [String] String to convert
|
116
|
+
# @return [String] Converted string
|
117
|
+
def snake_case_to_camel_case(str)
|
118
|
+
str.to_s.split("_").map.with_index { |word, i| i.zero? ? word : word.capitalize }.join
|
119
|
+
end
|
120
|
+
|
121
|
+
# Convert camelCase to snake_case
|
122
|
+
# @param str [String] String to convert
|
123
|
+
# @return [String] Converted string
|
124
|
+
def camel_case_to_snake_case(str)
|
125
|
+
str.to_s.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "")
|
126
|
+
end
|
127
|
+
|
128
|
+
# Encode a single value according to its type
|
129
|
+
# @param type [String] Value type
|
130
|
+
# @param value [Object] Value to encode
|
131
|
+
# @param types [Hash] Type definitions
|
132
|
+
# @return [String] Encoded value as hex string
|
133
|
+
def encode_value(type, value, types = TYPES) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
134
|
+
case type
|
135
|
+
when "string"
|
136
|
+
keccac256_hex(value.to_s)
|
137
|
+
when "uint256"
|
138
|
+
# Convert to 32-byte hex string - handle both string and integer input
|
139
|
+
# For string values (like deadline), convert to integer first
|
140
|
+
int_value = value.to_i
|
141
|
+
int_value.to_s(16).rjust(64, "0")
|
142
|
+
when "address"
|
143
|
+
# TRON addresses need to be converted to hex format according to TIP-712
|
144
|
+
# TIP-712: "address: need to remove TRON unique prefix(0x41) and encoded as uint160"
|
145
|
+
addr_bytes = GasfreeSdk::Base58.base58_to_binary(value.to_s)
|
146
|
+
# Take the middle 20 bytes (skip version byte 0x41 and checksum)
|
147
|
+
hex_addr = addr_bytes[1, 20].unpack1("H*")
|
148
|
+
hex_addr.rjust(64, "0")
|
149
|
+
when /\[\]$/
|
150
|
+
# Array type
|
151
|
+
item_type = type.gsub(/\[\]$/, "")
|
152
|
+
array_items = value.map { |item| encode_value(item_type, item, types) }
|
153
|
+
keccac256_hex(array_items.join)
|
154
|
+
else
|
155
|
+
raise "Unknown type: #{type}" unless types[type.to_sym]
|
156
|
+
|
157
|
+
# Custom type
|
158
|
+
encoded_data = encode_data(type.to_sym, value, types)
|
159
|
+
keccac256_hex(encoded_data)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
# Hash structured data
|
164
|
+
# @param primary_type [Symbol] Primary type name
|
165
|
+
# @param data [Hash] Data to hash
|
166
|
+
# @param types [Hash] Type definitions
|
167
|
+
# @return [String] Hash as binary string
|
168
|
+
def hash_struct(primary_type, data, types = TYPES)
|
169
|
+
encoded_data = encode_data(primary_type, data, types)
|
170
|
+
keccac256(encoded_data)
|
171
|
+
end
|
172
|
+
|
173
|
+
# Hash domain separator
|
174
|
+
# @param domain [Hash] Domain parameters
|
175
|
+
# @return [String] Domain hash as binary string
|
176
|
+
def hash_domain(domain = DOMAIN_TESTNET)
|
177
|
+
# Create EIP712Domain type manually since it's not in TYPES
|
178
|
+
eip712_domain_type = [
|
179
|
+
{ name: "name", type: "string" },
|
180
|
+
{ name: "version", type: "string" },
|
181
|
+
{ name: "chainId", type: "uint256" },
|
182
|
+
{ name: "verifyingContract", type: "address" }
|
183
|
+
]
|
184
|
+
|
185
|
+
# Temporarily add to types for encoding
|
186
|
+
temp_types = TYPES.merge({ EIP712Domain: eip712_domain_type })
|
187
|
+
hash_struct(:EIP712Domain, domain, temp_types)
|
188
|
+
end
|
189
|
+
|
190
|
+
# Sign typed data according to EIP-712
|
191
|
+
# @param private_key_hex [String] Private key as hex string
|
192
|
+
# @param message_data [Hash] Message data to sign
|
193
|
+
# @param domain [Hash] Domain parameters (defaults to testnet)
|
194
|
+
# @param use_ethereum_v [Boolean] Whether to use Ethereum-style V value (recovery_id + 27)
|
195
|
+
# @return [String] Signature as hex string
|
196
|
+
def sign_typed_data(private_key_hex, message_data, domain: DOMAIN_TESTNET, use_ethereum_v: true) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
197
|
+
# EIP-712 signature implementation for TRON GasFree
|
198
|
+
# This implementation has been verified to be mathematically correct
|
199
|
+
# through local signature verification
|
200
|
+
|
201
|
+
# Create the EIP-712 hash
|
202
|
+
domain_separator = hash_domain(domain)
|
203
|
+
message_hash = hash_struct(:PermitTransfer, message_data)
|
204
|
+
|
205
|
+
# Create the final hash according to EIP-712
|
206
|
+
prefix = "\x19\x01"
|
207
|
+
digest_input = prefix + domain_separator + message_hash
|
208
|
+
digest = keccac256(digest_input)
|
209
|
+
|
210
|
+
# Ensure digest is exactly 32 bytes
|
211
|
+
raise "Hash is #{digest.length} bytes, expected 32" if digest.length != 32
|
212
|
+
|
213
|
+
# Sign with secp256k1
|
214
|
+
context = Secp256k1::Context.new
|
215
|
+
private_key = [private_key_hex].pack("H*")
|
216
|
+
key_pair = context.key_pair_from_private_key(private_key)
|
217
|
+
|
218
|
+
# Create recoverable signature
|
219
|
+
compact, recovery_id = context.sign_recoverable(
|
220
|
+
key_pair.private_key,
|
221
|
+
digest
|
222
|
+
).compact
|
223
|
+
|
224
|
+
# Format signature as r + s + v
|
225
|
+
signature_bytes = compact.bytes
|
226
|
+
|
227
|
+
v_value = if use_ethereum_v
|
228
|
+
# TRON/Ethereum style V (recovery_id + 27)
|
229
|
+
recovery_id + 27
|
230
|
+
else
|
231
|
+
# Standard format: just recovery_id
|
232
|
+
recovery_id
|
233
|
+
end
|
234
|
+
|
235
|
+
signature_bytes << v_value
|
236
|
+
|
237
|
+
# Convert to hex string without 0x prefix
|
238
|
+
signature_bytes.pack("C*").unpack1("H*")
|
239
|
+
end
|
240
|
+
|
241
|
+
# Sign for mainnet
|
242
|
+
# @param private_key_hex [String] Private key as hex string
|
243
|
+
# @param message_data [Hash] Message data to sign
|
244
|
+
# @param use_ethereum_v [Boolean] Whether to use Ethereum-style V value
|
245
|
+
# @return [String] Signature as hex string
|
246
|
+
def sign_typed_data_mainnet(private_key_hex, message_data, use_ethereum_v: true)
|
247
|
+
sign_typed_data(private_key_hex, message_data, domain: DOMAIN_MAINNET, use_ethereum_v: use_ethereum_v)
|
248
|
+
end
|
249
|
+
|
250
|
+
# Sign for testnet (alias for default behavior)
|
251
|
+
# @param private_key_hex [String] Private key as hex string
|
252
|
+
# @param message_data [Hash] Message data to sign
|
253
|
+
# @param use_ethereum_v [Boolean] Whether to use Ethereum-style V value
|
254
|
+
# @return [String] Signature as hex string
|
255
|
+
def sign_typed_data_testnet(private_key_hex, message_data, use_ethereum_v: true)
|
256
|
+
sign_typed_data(private_key_hex, message_data, domain: DOMAIN_TESTNET, use_ethereum_v: use_ethereum_v)
|
257
|
+
end
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|
data/lib/gasfree_sdk/types.rb
CHANGED
@@ -7,8 +7,12 @@ module GasfreeSdk
|
|
7
7
|
module Types
|
8
8
|
include Dry.Types()
|
9
9
|
|
10
|
-
# Address type with validation
|
11
|
-
|
10
|
+
# Address type with validation for both Ethereum and TRON addresses
|
11
|
+
# Ethereum: 0x followed by 40 hex characters OR just 40 hex characters
|
12
|
+
# TRON: Starts with T followed by 33 characters (base58)
|
13
|
+
Address = Types::String.constrained(
|
14
|
+
format: /\A(0x[0-9a-fA-F]{40}|[0-9a-fA-F]{40}|T[0-9A-Za-z]{33})\z/
|
15
|
+
)
|
12
16
|
|
13
17
|
# Hash type with validation
|
14
18
|
Hash = Types::String.constrained(format: /\A(0x)?[0-9a-fA-F]{64}\z/)
|
@@ -36,5 +40,24 @@ module GasfreeSdk
|
|
36
40
|
SUCCEED
|
37
41
|
FAILED
|
38
42
|
])
|
43
|
+
|
44
|
+
# JSON namespace for API response parsing
|
45
|
+
module JSON
|
46
|
+
# Convert millisecond timestamp or ISO date string to Time object
|
47
|
+
# Takes an integer (milliseconds) or string (ISO format) and returns a Time
|
48
|
+
Time = Types.Constructor(::Time) do |value|
|
49
|
+
case value
|
50
|
+
when ::Integer
|
51
|
+
::Time.at(value / 1000.0)
|
52
|
+
when ::String
|
53
|
+
::Time.parse(value)
|
54
|
+
else
|
55
|
+
raise ArgumentError, "Expected Integer or String, got #{value.class}"
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Convert integer amount to string
|
60
|
+
Amount = Types.Constructor(Types::String, &:to_s)
|
61
|
+
end
|
39
62
|
end
|
40
63
|
end
|
data/lib/gasfree_sdk/version.rb
CHANGED
data/lib/gasfree_sdk.rb
CHANGED
@@ -13,6 +13,9 @@ require_relative "gasfree_sdk/types"
|
|
13
13
|
require_relative "gasfree_sdk/client"
|
14
14
|
require_relative "gasfree_sdk/errors"
|
15
15
|
require_relative "gasfree_sdk/models"
|
16
|
+
require_relative "gasfree_sdk/crypto"
|
17
|
+
require_relative "gasfree_sdk/base58"
|
18
|
+
require_relative "gasfree_sdk/tron_eip712_signer"
|
16
19
|
|
17
20
|
# Main module for GasFree SDK
|
18
21
|
module GasfreeSdk
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: gasfree_sdk
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 1.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- madmatvey
|
@@ -107,6 +107,20 @@ dependencies:
|
|
107
107
|
- - "~>"
|
108
108
|
- !ruby/object:Gem::Version
|
109
109
|
version: '2.2'
|
110
|
+
- !ruby/object:Gem::Dependency
|
111
|
+
name: rbsecp256k1
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
113
|
+
requirements:
|
114
|
+
- - "~>"
|
115
|
+
- !ruby/object:Gem::Version
|
116
|
+
version: '6.0'
|
117
|
+
type: :runtime
|
118
|
+
prerelease: false
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
120
|
+
requirements:
|
121
|
+
- - "~>"
|
122
|
+
- !ruby/object:Gem::Version
|
123
|
+
version: '6.0'
|
110
124
|
- !ruby/object:Gem::Dependency
|
111
125
|
name: rake
|
112
126
|
requirement: !ruby/object:Gem::Requirement
|
@@ -236,8 +250,12 @@ files:
|
|
236
250
|
- Rakefile
|
237
251
|
- examples/basic_usage.rb
|
238
252
|
- examples/demo.rb
|
253
|
+
- examples/simple_usage_example.rb
|
254
|
+
- examples/test_tron_signer.rb
|
239
255
|
- lib/gasfree_sdk.rb
|
256
|
+
- lib/gasfree_sdk/base58.rb
|
240
257
|
- lib/gasfree_sdk/client.rb
|
258
|
+
- lib/gasfree_sdk/crypto.rb
|
241
259
|
- lib/gasfree_sdk/errors.rb
|
242
260
|
- lib/gasfree_sdk/models.rb
|
243
261
|
- lib/gasfree_sdk/models/gas_free_address.rb
|
@@ -245,12 +263,13 @@ files:
|
|
245
263
|
- lib/gasfree_sdk/models/token.rb
|
246
264
|
- lib/gasfree_sdk/models/transfer_request.rb
|
247
265
|
- lib/gasfree_sdk/models/transfer_response.rb
|
266
|
+
- lib/gasfree_sdk/tron_eip712_signer.rb
|
248
267
|
- lib/gasfree_sdk/types.rb
|
249
268
|
- lib/gasfree_sdk/version.rb
|
250
269
|
- sig/gasfree_sdk.rbs
|
251
270
|
homepage: https://github.com/madmatvey/gasfree_sdk
|
252
271
|
licenses:
|
253
|
-
-
|
272
|
+
- LGPL-3.0
|
254
273
|
metadata:
|
255
274
|
allowed_push_host: https://rubygems.org
|
256
275
|
homepage_uri: https://github.com/madmatvey/gasfree_sdk
|