hyperliquid-rb 0.1.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.
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Hyperliquid
6
+ module Utils
7
+ module_function
8
+
9
+ # Convert float to wire format string: 8 decimal precision, trailing zeros removed.
10
+ # Examples: 100 -> "100", 1670.1 -> "1670.1", 0.0147 -> "0.0147"
11
+ def float_to_wire(x)
12
+ rounded = format("%.8f", x)
13
+ raise SigningError, "float_to_wire causes rounding: #{x}" if (Float(rounded) - x).abs >= 1e-12
14
+
15
+ rounded = "0" if rounded == "-0"
16
+ # Normalize via BigDecimal to strip trailing zeros, format as fixed-point
17
+ normalized = BigDecimal(rounded).to_s("F")
18
+ # BigDecimal("100.00000000").to_s("F") => "100.0", we want "100"
19
+ normalized.sub(/\.0$/, "")
20
+ end
21
+
22
+ # Convert float to integer by multiplying by 10^power, validating precision.
23
+ def float_to_int(x, power)
24
+ with_decimals = x * (10**power)
25
+ rounded = with_decimals.round
26
+ raise SigningError, "float_to_int causes rounding: #{x}" if (rounded - with_decimals).abs >= 1e-3
27
+
28
+ rounded
29
+ end
30
+
31
+ # Convert float to int * 10^8 for action hashing.
32
+ def float_to_int_for_hashing(x)
33
+ float_to_int(x, 8)
34
+ end
35
+
36
+ # Convert USD float to int * 10^6.
37
+ def float_to_usd_int(x)
38
+ float_to_int(x, 6)
39
+ end
40
+
41
+ # Convert an OrderRequest hash to wire format for signing.
42
+ def order_request_to_order_wire(order, asset)
43
+ wire = {
44
+ "a" => asset,
45
+ "b" => order[:is_buy],
46
+ "p" => float_to_wire(order[:limit_px]),
47
+ "s" => float_to_wire(order[:sz]),
48
+ "r" => order[:reduce_only],
49
+ "t" => order_type_to_wire(order[:order_type])
50
+ }
51
+ wire["c"] = order[:cloid].to_raw if order[:cloid]
52
+ wire
53
+ end
54
+
55
+ # Convert order type to wire format.
56
+ def order_type_to_wire(order_type)
57
+ if order_type[:limit]
58
+ { "limit" => order_type[:limit] }
59
+ elsif order_type[:trigger]
60
+ t = order_type[:trigger]
61
+ {
62
+ "trigger" => {
63
+ "isMarket" => t[:isMarket],
64
+ "triggerPx" => float_to_wire(t[:triggerPx]),
65
+ "tpsl" => t[:tpsl]
66
+ }
67
+ }
68
+ else
69
+ raise SigningError, "Unknown order type: #{order_type}"
70
+ end
71
+ end
72
+
73
+ # Convert hex address string to 20-byte binary.
74
+ def address_to_bytes(address)
75
+ [address.delete_prefix("0x")].pack("H40")
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperliquid
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "websocket-client-simple"
5
+
6
+ module Hyperliquid
7
+ ActiveSubscription = Struct.new(:callback, :subscription_id)
8
+
9
+ class WebsocketManager
10
+ PING_INTERVAL = 50 # seconds
11
+
12
+ def initialize(base_url)
13
+ @subscription_id_counter = 0
14
+ @ws_ready = false
15
+ @queued_subscriptions = []
16
+ @active_subscriptions = Hash.new { |h, k| h[k] = [] }
17
+ @mutex = Mutex.new
18
+ @stop_event = false
19
+
20
+ ws_url = "ws#{base_url[4..]}/ws"
21
+ @ws_url = ws_url
22
+ @ws = nil
23
+ end
24
+
25
+ def start
26
+ manager = self
27
+ @ws = WebSocket::Client::Simple.connect(@ws_url)
28
+
29
+ @ws.on :message do |msg|
30
+ manager.send(:handle_message, msg.data)
31
+ end
32
+
33
+ @ws.on :open do
34
+ manager.send(:handle_open)
35
+ end
36
+
37
+ @ping_thread = Thread.new { send_ping }
38
+ end
39
+
40
+ def stop
41
+ @mutex.synchronize { @stop_event = true }
42
+ @ws&.close
43
+ @ping_thread&.join
44
+ end
45
+
46
+ def subscribe(subscription, callback, subscription_id: nil)
47
+ @mutex.synchronize do
48
+ if subscription_id.nil?
49
+ @subscription_id_counter += 1
50
+ subscription_id = @subscription_id_counter
51
+ end
52
+
53
+ if @ws_ready
54
+ identifier = self.class.subscription_to_identifier(subscription)
55
+ if %w[userEvents orderUpdates].include?(identifier) && !@active_subscriptions[identifier].empty?
56
+ raise NotImplementedError, "Cannot subscribe to #{identifier} multiple times"
57
+ end
58
+
59
+ @active_subscriptions[identifier] << ActiveSubscription.new(callback, subscription_id)
60
+ @ws.send(JSON.generate({ "method" => "subscribe", "subscription" => subscription }))
61
+ else
62
+ @queued_subscriptions << [subscription, ActiveSubscription.new(callback, subscription_id)]
63
+ end
64
+
65
+ subscription_id
66
+ end
67
+ end
68
+
69
+ def unsubscribe(subscription, subscription_id)
70
+ @mutex.synchronize do
71
+ raise NotImplementedError, "Can't unsubscribe before websocket connected" unless @ws_ready
72
+
73
+ identifier = self.class.subscription_to_identifier(subscription)
74
+ active = @active_subscriptions[identifier]
75
+ new_active = active.reject { |s| s.subscription_id == subscription_id }
76
+ @ws.send(JSON.generate({ "method" => "unsubscribe", "subscription" => subscription })) if new_active.empty?
77
+ @active_subscriptions[identifier] = new_active
78
+ active.length != new_active.length
79
+ end
80
+ end
81
+
82
+ # Maps a subscription request to its identifier string.
83
+ def self.subscription_to_identifier(subscription)
84
+ type = subscription["type"]
85
+ case type
86
+ when "allMids"
87
+ "allMids"
88
+ when "l2Book"
89
+ "l2Book:#{subscription["coin"].downcase}"
90
+ when "trades"
91
+ "trades:#{subscription["coin"].downcase}"
92
+ when "userEvents"
93
+ "userEvents"
94
+ when "userFills"
95
+ "userFills:#{subscription["user"].downcase}"
96
+ when "candle"
97
+ "candle:#{subscription["coin"].downcase},#{subscription["interval"]}"
98
+ when "orderUpdates"
99
+ "orderUpdates"
100
+ when "userFundings"
101
+ "userFundings:#{subscription["user"].downcase}"
102
+ when "userNonFundingLedgerUpdates"
103
+ "userNonFundingLedgerUpdates:#{subscription["user"].downcase}"
104
+ when "webData2"
105
+ "webData2:#{subscription["user"].downcase}"
106
+ when "bbo"
107
+ "bbo:#{subscription["coin"].downcase}"
108
+ when "activeAssetCtx"
109
+ "activeAssetCtx:#{subscription["coin"].downcase}"
110
+ when "activeAssetData"
111
+ "activeAssetData:#{subscription["coin"].downcase},#{subscription["user"].downcase}"
112
+ end
113
+ end
114
+
115
+ # Maps an incoming WS message to its identifier string.
116
+ def self.ws_msg_to_identifier(ws_msg)
117
+ channel = ws_msg["channel"]
118
+ case channel
119
+ when "pong"
120
+ "pong"
121
+ when "allMids"
122
+ "allMids"
123
+ when "l2Book"
124
+ "l2Book:#{ws_msg["data"]["coin"].downcase}"
125
+ when "trades"
126
+ trades = ws_msg["data"]
127
+ return nil if trades.empty?
128
+
129
+ "trades:#{trades[0]["coin"].downcase}"
130
+ when "user"
131
+ "userEvents"
132
+ when "userFills"
133
+ "userFills:#{ws_msg["data"]["user"].downcase}"
134
+ when "candle"
135
+ "candle:#{ws_msg["data"]["s"].downcase},#{ws_msg["data"]["i"]}"
136
+ when "orderUpdates"
137
+ "orderUpdates"
138
+ when "userFundings"
139
+ "userFundings:#{ws_msg["data"]["user"].downcase}"
140
+ when "userNonFundingLedgerUpdates"
141
+ "userNonFundingLedgerUpdates:#{ws_msg["data"]["user"].downcase}"
142
+ when "webData2"
143
+ "webData2:#{ws_msg["data"]["user"].downcase}"
144
+ when "bbo"
145
+ "bbo:#{ws_msg["data"]["coin"].downcase}"
146
+ when "activeAssetCtx", "activeSpotAssetCtx"
147
+ "activeAssetCtx:#{ws_msg["data"]["coin"].downcase}"
148
+ when "activeAssetData"
149
+ "activeAssetData:#{ws_msg["data"]["coin"].downcase},#{ws_msg["data"]["user"].downcase}"
150
+ end
151
+ end
152
+
153
+ private
154
+
155
+ def handle_message(data)
156
+ return if data == "Websocket connection established."
157
+
158
+ ws_msg = JSON.parse(data)
159
+ identifier = self.class.ws_msg_to_identifier(ws_msg)
160
+ return if identifier == "pong"
161
+ return if identifier.nil?
162
+
163
+ active = @mutex.synchronize { @active_subscriptions[identifier].dup }
164
+ if active.empty?
165
+ warn "Websocket message from an unexpected subscription: #{data} #{identifier}"
166
+ else
167
+ active.each { |sub| sub.callback.call(ws_msg) }
168
+ end
169
+ end
170
+
171
+ def handle_open
172
+ @mutex.synchronize do
173
+ @ws_ready = true
174
+ @queued_subscriptions.each do |subscription, active_sub|
175
+ subscribe(subscription, active_sub.callback, subscription_id: active_sub.subscription_id)
176
+ end
177
+ @queued_subscriptions.clear
178
+ end
179
+ end
180
+
181
+ def send_ping
182
+ loop do
183
+ sleep 1
184
+ elapsed = 0
185
+ loop do
186
+ break if @mutex.synchronize { @stop_event } || elapsed >= PING_INTERVAL
187
+
188
+ sleep 1
189
+ elapsed += 1
190
+ end
191
+ break if @mutex.synchronize { @stop_event }
192
+
193
+ @ws&.send(JSON.generate({ "method" => "ping" }))
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hyperliquid/version"
4
+ require_relative "hyperliquid/error"
5
+ require_relative "hyperliquid/constants"
6
+ require_relative "hyperliquid/utils"
7
+ require_relative "hyperliquid/cloid"
8
+ require_relative "hyperliquid/transport"
9
+ require_relative "hyperliquid/signer"
10
+ require_relative "hyperliquid/websocket_manager"
11
+ require_relative "hyperliquid/info"
12
+ require_relative "hyperliquid/exchange"
13
+
14
+ module Hyperliquid
15
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hyperliquid-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Deltabadger
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: eth
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.5.17
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.5.17
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: msgpack
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.7'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.7'
54
+ - !ruby/object:Gem::Dependency
55
+ name: websocket-client-simple
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.9'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.9'
68
+ description: Complete Ruby SDK for Hyperliquid — trading, market data, EIP-712 signing,
69
+ and WebSocket subscriptions.
70
+ email:
71
+ - hello@deltabadger.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - LICENSE.txt
77
+ - README.md
78
+ - lib/hyperliquid.rb
79
+ - lib/hyperliquid/cloid.rb
80
+ - lib/hyperliquid/constants.rb
81
+ - lib/hyperliquid/error.rb
82
+ - lib/hyperliquid/exchange.rb
83
+ - lib/hyperliquid/info.rb
84
+ - lib/hyperliquid/signer.rb
85
+ - lib/hyperliquid/transport.rb
86
+ - lib/hyperliquid/utils.rb
87
+ - lib/hyperliquid/version.rb
88
+ - lib/hyperliquid/websocket_manager.rb
89
+ homepage: https://github.com/deltabadger/hyperliquid-rb
90
+ licenses:
91
+ - MIT
92
+ metadata:
93
+ homepage_uri: https://github.com/deltabadger/hyperliquid-rb
94
+ source_code_uri: https://github.com/deltabadger/hyperliquid-rb
95
+ changelog_uri: https://github.com/deltabadger/hyperliquid-rb/commits/main
96
+ rubygems_mfa_required: 'true'
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '3.1'
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubygems_version: 4.0.3
112
+ specification_version: 4
113
+ summary: Ruby SDK for the Hyperliquid DEX API
114
+ test_files: []