honeymaker 0.12.4-aarch64-linux
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 +7 -0
- data/.github/workflows/native-gems.yml +100 -0
- data/.github/workflows/release.yml +36 -0
- data/.github/workflows/test.yml +33 -0
- data/LICENSE +21 -0
- data/README.md +219 -0
- data/Rakefile +78 -0
- data/crates/honeymaker/tests/vectors/decimal.json +146 -0
- data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
- data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
- data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
- data/lib/honeymaker/3.4/honeymaker_native.so +0 -0
- data/lib/honeymaker/4.0/honeymaker_native.so +0 -0
- data/lib/honeymaker/client.rb +139 -0
- data/lib/honeymaker/clients/binance.rb +687 -0
- data/lib/honeymaker/clients/binance_us.rb +42 -0
- data/lib/honeymaker/clients/bingx.rb +242 -0
- data/lib/honeymaker/clients/bitget.rb +318 -0
- data/lib/honeymaker/clients/bitrue.rb +245 -0
- data/lib/honeymaker/clients/bitvavo.rb +244 -0
- data/lib/honeymaker/clients/bybit.rb +308 -0
- data/lib/honeymaker/clients/coinbase.rb +326 -0
- data/lib/honeymaker/clients/gemini.rb +189 -0
- data/lib/honeymaker/clients/hyperliquid.rb +232 -0
- data/lib/honeymaker/clients/kraken.rb +379 -0
- data/lib/honeymaker/clients/kraken_futures.rb +78 -0
- data/lib/honeymaker/clients/kucoin.rb +310 -0
- data/lib/honeymaker/clients/mexc.rb +249 -0
- data/lib/honeymaker/exchange.rb +129 -0
- data/lib/honeymaker/exchanges/binance.rb +55 -0
- data/lib/honeymaker/exchanges/binance_us.rb +9 -0
- data/lib/honeymaker/exchanges/bingx.rb +56 -0
- data/lib/honeymaker/exchanges/bitget.rb +52 -0
- data/lib/honeymaker/exchanges/bitrue.rb +53 -0
- data/lib/honeymaker/exchanges/bitvavo.rb +65 -0
- data/lib/honeymaker/exchanges/bybit.rb +57 -0
- data/lib/honeymaker/exchanges/coinbase.rb +69 -0
- data/lib/honeymaker/exchanges/gemini.rb +134 -0
- data/lib/honeymaker/exchanges/hyperliquid.rb +67 -0
- data/lib/honeymaker/exchanges/kraken.rb +122 -0
- data/lib/honeymaker/exchanges/kucoin.rb +52 -0
- data/lib/honeymaker/exchanges/mexc.rb +56 -0
- data/lib/honeymaker/native/kraken.rb +263 -0
- data/lib/honeymaker/native.rb +42 -0
- data/lib/honeymaker/result.rb +46 -0
- data/lib/honeymaker/utils.rb +39 -0
- data/lib/honeymaker/version.rb +5 -0
- data/lib/honeymaker.rb +97 -0
- data/script/parity/kraken_client_live.rb +191 -0
- data/script/parity/kraken_live.rb +70 -0
- data/script/parity/kraken_transport.rb +604 -0
- data/script/parity/resolve.c +49 -0
- data/script/release/push_gems.sh +28 -0
- data/script/vectors/kraken.rb +50 -0
- data/script/vectors/kraken_stage2.rb +146 -0
- data/sig/honeymaker.rbs +4 -0
- data/test/fixtures/binance_book_ticker.json +7 -0
- data/test/fixtures/binance_exchange_info.json +30 -0
- data/test/fixtures/bingx_symbols.json +26 -0
- data/test/fixtures/bingx_ticker.json +12 -0
- data/test/fixtures/bitget_symbols.json +28 -0
- data/test/fixtures/bitget_tickers.json +14 -0
- data/test/fixtures/bitmart_symbols.json +26 -0
- data/test/fixtures/bitmart_ticker.json +12 -0
- data/test/fixtures/bitrue_book_ticker.json +7 -0
- data/test/fixtures/bitrue_exchange_info.json +34 -0
- data/test/fixtures/bitvavo_book_ticker.json +7 -0
- data/test/fixtures/bitvavo_markets.json +30 -0
- data/test/fixtures/bybit_instruments.json +23 -0
- data/test/fixtures/bybit_tickers.json +19 -0
- data/test/fixtures/coinbase_product.json +7 -0
- data/test/fixtures/coinbase_products.json +24 -0
- data/test/fixtures/gemini_pubticker.json +10 -0
- data/test/fixtures/gemini_symbol_detail.json +10 -0
- data/test/fixtures/gemini_symbols.json +1 -0
- data/test/fixtures/hyperliquid_all_mids.json +5 -0
- data/test/fixtures/hyperliquid_spot_meta.json +12 -0
- data/test/fixtures/kraken_asset_pairs.json +43 -0
- data/test/fixtures/kraken_ticker.json +16 -0
- data/test/fixtures/kucoin_orderbook_level1.json +10 -0
- data/test/fixtures/kucoin_symbols.json +17 -0
- data/test/fixtures/mexc_book_ticker.json +7 -0
- data/test/fixtures/mexc_exchange_info.json +64 -0
- data/test/honeymaker/client_test.rb +90 -0
- data/test/honeymaker/clients/binance_client_test.rb +151 -0
- data/test/honeymaker/clients/binance_us_client_test.rb +25 -0
- data/test/honeymaker/clients/bingx_client_test.rb +106 -0
- data/test/honeymaker/clients/bitget_client_test.rb +146 -0
- data/test/honeymaker/clients/bitrue_client_test.rb +69 -0
- data/test/honeymaker/clients/bitvavo_client_test.rb +156 -0
- data/test/honeymaker/clients/bybit_client_test.rb +137 -0
- data/test/honeymaker/clients/coinbase_client_test.rb +153 -0
- data/test/honeymaker/clients/gemini_client_test.rb +100 -0
- data/test/honeymaker/clients/honeymaker_client_registry_test.rb +39 -0
- data/test/honeymaker/clients/hyperliquid_client_test.rb +292 -0
- data/test/honeymaker/clients/kraken_client_test.rb +255 -0
- data/test/honeymaker/clients/kraken_futures_client_test.rb +54 -0
- data/test/honeymaker/clients/kucoin_client_test.rb +149 -0
- data/test/honeymaker/clients/mexc_client_test.rb +98 -0
- data/test/honeymaker/clients/validation_test.rb +167 -0
- data/test/honeymaker/error_chain_test.rb +133 -0
- data/test/honeymaker/exchange_test.rb +138 -0
- data/test/honeymaker/exchanges/binance_test.rb +83 -0
- data/test/honeymaker/exchanges/binance_us_test.rb +53 -0
- data/test/honeymaker/exchanges/bingx_test.rb +65 -0
- data/test/honeymaker/exchanges/bitget_test.rb +65 -0
- data/test/honeymaker/exchanges/bitrue_test.rb +65 -0
- data/test/honeymaker/exchanges/bitvavo_test.rb +65 -0
- data/test/honeymaker/exchanges/bybit_test.rb +55 -0
- data/test/honeymaker/exchanges/coinbase_test.rb +106 -0
- data/test/honeymaker/exchanges/gemini_catalogue_test.rb +118 -0
- data/test/honeymaker/exchanges/gemini_test.rb +184 -0
- data/test/honeymaker/exchanges/hyperliquid_test.rb +64 -0
- data/test/honeymaker/exchanges/kraken_test.rb +191 -0
- data/test/honeymaker/exchanges/kucoin_test.rb +55 -0
- data/test/honeymaker/exchanges/mexc_test.rb +97 -0
- data/test/honeymaker/honeymaker_test.rb +27 -0
- data/test/honeymaker/result_test.rb +42 -0
- data/test/honeymaker/utils_test.rb +64 -0
- data/test/native/backend_switch_test.rb +53 -0
- data/test/native/convert_test.rb +137 -0
- data/test/native/gem_isolation_test.rb +17 -0
- data/test/native/loader_test.rb +29 -0
- data/test/native/lockstep_test.rb +23 -0
- data/test/native/signing_smoke_test.rb +28 -0
- data/test/parity/kraken_client_parity_test.rb +398 -0
- data/test/parity/kraken_exchange_parity_test.rb +172 -0
- data/test/release/branch_hardening_test.rb +34 -0
- data/test/release/push_gems_test.rb +79 -0
- data/test/smoke.rb +24 -0
- data/test/support/fixture_helper.rb +13 -0
- data/test/support/kraken_signing_smoke.rb +21 -0
- data/test/support/parity_helper.rb +62 -0
- data/test/support/venue_double.rb +100 -0
- data/test/test_helper.rb +9 -0
- metadata +254 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Ruby-vs-Rust transport parity. Each legacy transport mode (spec §4) runs against the gem's own
|
|
4
|
+
# Faraday transport (Honeymaker::Clients::Kraken) and against honeymaker-client (examples/probe),
|
|
5
|
+
# each on a fresh copy of the same local double. Compared: the outcome class deltabadger derives
|
|
6
|
+
# (see `oracle`), rejection/complete HTTP error strings, success values, and signed wire requests.
|
|
7
|
+
# One mode mismatch (both calls) is expected and asserted: a proxy that never answers CONNECT (Ruling R2). The
|
|
8
|
+
# plan's "Divergences from the gem" section lists every other difference; none shows up as a row.
|
|
9
|
+
# bundle exec ruby -Ilib script/parity/kraken_transport.rb
|
|
10
|
+
require "honeymaker"
|
|
11
|
+
require "json"
|
|
12
|
+
require "open3"
|
|
13
|
+
require "openssl"
|
|
14
|
+
require "rbconfig"
|
|
15
|
+
require "socket"
|
|
16
|
+
require "stringio"
|
|
17
|
+
require "tmpdir"
|
|
18
|
+
require "timeout"
|
|
19
|
+
require "zlib"
|
|
20
|
+
|
|
21
|
+
ROOT = File.expand_path("../..", __dir__)
|
|
22
|
+
|
|
23
|
+
def issue(dir, name)
|
|
24
|
+
ca_key = OpenSSL::PKey::EC.generate("prime256v1")
|
|
25
|
+
ca = OpenSSL::X509::Certificate.new
|
|
26
|
+
ca.version = 2
|
|
27
|
+
ca.serial = rand(1..(2**31))
|
|
28
|
+
ca.subject = ca.issuer = OpenSSL::X509::Name.parse("/CN=parity-#{name}-ca")
|
|
29
|
+
ca.public_key = ca_key
|
|
30
|
+
ca.not_before = Time.now - 60
|
|
31
|
+
ca.not_after = Time.now + 3600
|
|
32
|
+
ef = OpenSSL::X509::ExtensionFactory.new(ca, ca)
|
|
33
|
+
ca.add_extension(ef.create_extension("basicConstraints", "CA:TRUE", true))
|
|
34
|
+
ca.add_extension(ef.create_extension("keyUsage", "keyCertSign,cRLSign", true))
|
|
35
|
+
ca.sign(ca_key, OpenSSL::Digest.new("SHA256"))
|
|
36
|
+
key = OpenSSL::PKey::EC.generate("prime256v1")
|
|
37
|
+
leaf = OpenSSL::X509::Certificate.new
|
|
38
|
+
leaf.version = 2
|
|
39
|
+
leaf.serial = rand(1..(2**31))
|
|
40
|
+
leaf.subject = OpenSSL::X509::Name.parse("/CN=localhost")
|
|
41
|
+
leaf.issuer = ca.subject
|
|
42
|
+
leaf.public_key = key
|
|
43
|
+
leaf.not_before = ca.not_before
|
|
44
|
+
leaf.not_after = ca.not_after
|
|
45
|
+
ef = OpenSSL::X509::ExtensionFactory.new(ca, leaf)
|
|
46
|
+
leaf.add_extension(ef.create_extension("subjectAltName", "DNS:localhost,IP:127.0.0.1"))
|
|
47
|
+
leaf.add_extension(ef.create_extension("extendedKeyUsage", "serverAuth"))
|
|
48
|
+
leaf.sign(ca_key, OpenSSL::Digest.new("SHA256"))
|
|
49
|
+
File.write(File.join(dir, "#{name}_ca.pem"), ca.to_pem)
|
|
50
|
+
File.write(File.join(dir, "#{name}_leaf.pem"), leaf.to_pem)
|
|
51
|
+
File.write(File.join(dir, "#{name}_key.pem"), key.to_pem)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Trust and the resolver double must be installed before Ruby/OpenSSL starts. The parent
|
|
55
|
+
# owns the temporary PKI/library and removes them even if the child fails. Clear ambient proxies.
|
|
56
|
+
unless ENV["HM_PARITY_DIR"]
|
|
57
|
+
system("cargo", "build", "--locked", "--quiet", "-p", "honeymaker-client", "--example", "probe",
|
|
58
|
+
chdir: ROOT, exception: true)
|
|
59
|
+
Dir.mktmpdir("hm-parity") do |dir|
|
|
60
|
+
issue(dir, "trusted")
|
|
61
|
+
issue(dir, "untrusted")
|
|
62
|
+
library = File.join(dir, "resolve.so")
|
|
63
|
+
darwin = RUBY_PLATFORM.include?("darwin")
|
|
64
|
+
system("cc", "-Wall", "-Wextra", "-Werror", darwin ? "-dynamiclib" : "-shared", "-fPIC",
|
|
65
|
+
File.join(__dir__, "resolve.c"), "-o", library, exception: true)
|
|
66
|
+
env = %w[http_proxy https_proxy all_proxy no_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY].to_h { |k| [k, nil] }
|
|
67
|
+
env.merge!("HM_PARITY_DIR" => dir, "SSL_CERT_FILE" => File.join(dir, "trusted_ca.pem"),
|
|
68
|
+
"SSL_CERT_DIR" => dir, darwin ? "DYLD_INSERT_LIBRARIES" : "LD_PRELOAD" => library)
|
|
69
|
+
exit(system(env, RbConfig.ruby, "-I#{ROOT}/lib", __FILE__, *ARGV) ? 0 : 1)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
DIR = ENV.fetch("HM_PARITY_DIR")
|
|
73
|
+
$stdout.sync = true
|
|
74
|
+
|
|
75
|
+
def ctx(name)
|
|
76
|
+
OpenSSL::SSL::SSLContext.new.tap do |c|
|
|
77
|
+
c.cert = OpenSSL::X509::Certificate.new(File.read(File.join(DIR, "#{name}_leaf.pem")))
|
|
78
|
+
c.key = OpenSSL::PKey.read(File.read(File.join(DIR, "#{name}_key.pem")))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
TRUSTED = ctx("trusted")
|
|
82
|
+
UNTRUSTED = ctx("untrusted")
|
|
83
|
+
|
|
84
|
+
PROBE = File.join(ROOT, "target/debug/examples/probe")
|
|
85
|
+
|
|
86
|
+
Honeymaker::Client.send(:remove_const, :OPTIONS)
|
|
87
|
+
Honeymaker::Client.const_set(:OPTIONS, { request: { open_timeout: 1, read_timeout: 1.5, write_timeout: 2 } }.freeze)
|
|
88
|
+
|
|
89
|
+
SECRET = Base64.strict_encode64("test_secret_key_1234567890123456")
|
|
90
|
+
NONCE = 1_727_000_000_000_001
|
|
91
|
+
ORDER = { ordertype: "market", type: "buy", volume: "0.0012", pair: "XBTEUR", oflags: ["viqc"],
|
|
92
|
+
cl_ord_id: "6f1c1a52-7c8e-4d0e-9a57-0b6f0f1d2e3a", deadline: "2026-09-30T12:00:10.000Z" }.freeze
|
|
93
|
+
RUST_ORDER = { pair: "XBTEUR", kind: "market", volume: "0.0012", quote_volume: true,
|
|
94
|
+
cl_ord_id: ORDER[:cl_ord_id], deadline: ORDER[:deadline] }.freeze
|
|
95
|
+
TICKER = '{"error":[],"result":{"XXBTZEUR":{"a":["50000.2","1","1.000"],"b":["49990.1","1","1.000"],"c":["49995.3","0.001"]}}}'
|
|
96
|
+
ADDED = '{"error":[],"result":{"descr":{"order":"buy"},"txid":["OTX-1"]}}'
|
|
97
|
+
|
|
98
|
+
# --- doubles (as in the 2026-09-30 capture) ---
|
|
99
|
+
# Every fixture is disposed before the next one. Unexpected server errors fail the row.
|
|
100
|
+
class Doubles
|
|
101
|
+
attr_reader :requests, :connects, :errors
|
|
102
|
+
|
|
103
|
+
def initialize
|
|
104
|
+
@sockets, @threads, @requests, @connects, @errors = [], [], [], [], []
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def socket(s)
|
|
108
|
+
@sockets << s
|
|
109
|
+
s
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def thread(&block)
|
|
113
|
+
worker = Thread.new do
|
|
114
|
+
block.call
|
|
115
|
+
rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError
|
|
116
|
+
# Clients intentionally abort TLS and close timed-out streams.
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
@errors << e
|
|
119
|
+
end
|
|
120
|
+
@threads << worker
|
|
121
|
+
worker
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def close
|
|
125
|
+
@threads.each(&:kill)
|
|
126
|
+
@threads.each(&:join)
|
|
127
|
+
@sockets.reverse_each { |s| s.close rescue nil }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
Req = Struct.new(:line, :accept_encoding, :body, :api_key, :api_sign)
|
|
132
|
+
|
|
133
|
+
def read_request(io)
|
|
134
|
+
buf = +""
|
|
135
|
+
buf << io.readpartial(4096) until buf.include?("\r\n\r\n")
|
|
136
|
+
head, body = buf.split("\r\n\r\n", 2)
|
|
137
|
+
body = body.to_s
|
|
138
|
+
len = head[/content-length: (\d+)/i, 1].to_i
|
|
139
|
+
body << io.readpartial(4096) while body.bytesize < len
|
|
140
|
+
Req.new(head.lines.first.strip, head[/^accept-encoding: ([^\r]*)/i, 1], body,
|
|
141
|
+
head[/^api-key: ([^\r]*)/i, 1], head[/^api-sign: ([^\r]*)/i, 1])
|
|
142
|
+
rescue EOFError, IOError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def server(tls: nil, &handler)
|
|
147
|
+
tcp = @doubles.socket(TCPServer.new("127.0.0.1", 0))
|
|
148
|
+
seen = @doubles.requests
|
|
149
|
+
scope = @doubles
|
|
150
|
+
scope.thread do
|
|
151
|
+
loop do
|
|
152
|
+
sock = scope.socket(tcp.accept)
|
|
153
|
+
scope.thread do
|
|
154
|
+
io = if tls
|
|
155
|
+
scope.socket(OpenSSL::SSL::SSLSocket.new(sock, tls)).tap { |s| s.sync_close = true; s.accept }
|
|
156
|
+
else
|
|
157
|
+
sock
|
|
158
|
+
end
|
|
159
|
+
handler.call(io, sock, seen)
|
|
160
|
+
ensure
|
|
161
|
+
io&.close rescue nil
|
|
162
|
+
sock.close rescue nil
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
[tcp.addr[1], seen]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def respond(io, status, ctype, body, encoding: nil)
|
|
170
|
+
io.write "HTTP/1.1 #{status} X\r\n#{ctype ? "Content-Type: #{ctype}\r\n" : ''}" \
|
|
171
|
+
"#{encoding ? "Content-Encoding: #{encoding}\r\n" : ''}Content-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n"
|
|
172
|
+
io.write body
|
|
173
|
+
io.close
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def gz(text)
|
|
177
|
+
z = StringIO.new
|
|
178
|
+
g = Zlib::GzipWriter.new(z)
|
|
179
|
+
g.mtime = 0
|
|
180
|
+
g.write(text)
|
|
181
|
+
g.close
|
|
182
|
+
z.string
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def encoded(status, body, encoding) = after_request { |io, _| respond(io, status, "application/json", body, encoding: encoding) }
|
|
186
|
+
|
|
187
|
+
def after_request(&act) = ->(io, sock, seen) { (r = read_request(io)) and seen << r; act.call(io, sock) }
|
|
188
|
+
def answering(status, ctype, body) = after_request { |io, _| respond(io, status, ctype, body) }
|
|
189
|
+
def closed_port = TCPServer.new("127.0.0.1", 0).then { |s| s.addr[1].tap { s.close } }
|
|
190
|
+
|
|
191
|
+
def proxy(&connect)
|
|
192
|
+
scope = @doubles
|
|
193
|
+
server do |io, sock, _|
|
|
194
|
+
request = read_request(io)
|
|
195
|
+
raise "missing CONNECT" unless request&.line&.start_with?("CONNECT ")
|
|
196
|
+
scope.connects << request
|
|
197
|
+
connect.call(io, sock)
|
|
198
|
+
end.first
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def tunnel_to(port)
|
|
202
|
+
scope = @doubles
|
|
203
|
+
proxy do |io, _|
|
|
204
|
+
up = scope.socket(TCPSocket.new("127.0.0.1", port))
|
|
205
|
+
io.write "HTTP/1.1 200 Connection established\r\n\r\n"
|
|
206
|
+
forward = scope.thread { IO.copy_stream(io, up); up.close_write rescue nil }
|
|
207
|
+
begin
|
|
208
|
+
IO.copy_stream(up, io)
|
|
209
|
+
ensure
|
|
210
|
+
forward.kill
|
|
211
|
+
forward.join
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# A bound, non-listening socket black-holes SYNs on macOS. Linux instead needs a full
|
|
217
|
+
# accept queue. Retain all sockets until the row finishes; never rely on an unroutable host.
|
|
218
|
+
def stalled_tcp
|
|
219
|
+
sock = @doubles.socket(Socket.new(:INET, :STREAM))
|
|
220
|
+
sock.bind(Socket.sockaddr_in(0, "127.0.0.1"))
|
|
221
|
+
port = sock.local_address.ip_port
|
|
222
|
+
unless RUBY_PLATFORM.include?("darwin")
|
|
223
|
+
sock.listen(0)
|
|
224
|
+
loop do
|
|
225
|
+
filler = @doubles.socket(Socket.new(:INET, :STREAM))
|
|
226
|
+
result = filler.connect_nonblock(Socket.sockaddr_in(port, "127.0.0.1"), exception: false)
|
|
227
|
+
break if result == :wait_writable && !IO.select(nil, [filler], nil, 0.1)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
["https://127.0.0.1:#{port}", nil, []]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def tls(handler) = server(tls: TRUSTED, &handler).then { |port, seen| ["https://127.0.0.1:#{port}", nil, seen] }
|
|
234
|
+
def plain(handler) = server(&handler).then { |port, seen| ["http://127.0.0.1:#{port}", nil, seen] }
|
|
235
|
+
def via(proxy_port) = ["https://127.0.0.1:443", "http://127.0.0.1:#{proxy_port}", []]
|
|
236
|
+
|
|
237
|
+
# Repeated header fields are joined with ", " by Net::HTTP. Use both orders so the
|
|
238
|
+
# result cannot accidentally pass with a first-header-only or last-header-only parser.
|
|
239
|
+
def raw_answer(headers, body, framing: :length)
|
|
240
|
+
after_request do |io, _|
|
|
241
|
+
tail = case framing
|
|
242
|
+
when :length then "Content-Length: #{body.bytesize}\r\n"
|
|
243
|
+
when :chunked then "Transfer-Encoding: chunked\r\n"
|
|
244
|
+
when :close then ""
|
|
245
|
+
end
|
|
246
|
+
io.write "HTTP/1.1 200 OK\r\n#{headers}#{tail}Connection: close\r\n\r\n"
|
|
247
|
+
io.write(framing == :chunked ? "#{body.bytesize.to_s(16)}\r\n#{body}\r\n0\r\n\r\n".b : body)
|
|
248
|
+
io.close
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def good_body(call) = call == :add_order ? ADDED : TICKER
|
|
253
|
+
|
|
254
|
+
def truncated_gzip(framing)
|
|
255
|
+
tls(raw_answer("Content-Type: application/json\r\nContent-Encoding: gzip\r\n",
|
|
256
|
+
gz('{"a":1}').byteslice(0, 12), framing: framing))
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
MODES = {
|
|
260
|
+
refused: ->(_) { ["https://127.0.0.1:#{closed_port}", nil, []] },
|
|
261
|
+
dns: ->(_) { ["https://honeymaker-nonexistent.invalid", nil, []] },
|
|
262
|
+
tcp_timeout: ->(_) { stalled_tcp },
|
|
263
|
+
tls_not_tls: ->(_) { ["https://127.0.0.1:#{server { |io, _, _| sleep 0.2; io.write("garbage\r\n\r\n"); io.close }.first}", nil, []] },
|
|
264
|
+
tls_untrusted: ->(_) { ["https://127.0.0.1:#{server(tls: UNTRUSTED) { |io, _, _| io.close }.first}", nil, []] },
|
|
265
|
+
tls_handshake_hang: ->(_) { ["https://127.0.0.1:#{server { |io, _, _| sleep 3; io.close }.first}", nil, []] },
|
|
266
|
+
read_timeout: ->(_) { tls(after_request { |io, _| sleep 3; io.close }) },
|
|
267
|
+
eof_after_request: ->(_) { plain(after_request { |io, _| io.close }) },
|
|
268
|
+
tls_abrupt_close: ->(_) { tls(after_request { |_, sock| sock.close }) },
|
|
269
|
+
rst_after_request: ->(_) { plain(after_request { |_, sock| sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_LINGER, [1, 0].pack("ii")); sock.close }) },
|
|
270
|
+
partial_body: ->(_) { tls(after_request { |io, _| io.write "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 50\r\n\r\n{\"a\""; io.close }) },
|
|
271
|
+
http_500_json: ->(_) { tls(answering(500, "application/json", '{"error":["EService:Unavailable"]}')) },
|
|
272
|
+
http_500_empty: ->(_) { tls(answering(500, "text/plain", "")) },
|
|
273
|
+
http_404_html: ->(_) { tls(answering(404, "text/html", "<h1>nf</h1> é")) },
|
|
274
|
+
http_429_no_ctype: ->(_) { tls(answering(429, nil, "no")) },
|
|
275
|
+
ok_invalid_json: ->(_) { tls(answering(200, "application/json", "{not json")) },
|
|
276
|
+
ok_duplicate_keys: ->(_) { tls(answering(200, "application/json", '{"error":[],"result":{"txid":["A"],"txid":["B"]}}')) },
|
|
277
|
+
ok_html: ->(_) { tls(answering(200, "text/html", "<p>maintenance</p>")) },
|
|
278
|
+
ok_blank: ->(_) { tls(answering(200, "application/json", " \n ")) },
|
|
279
|
+
ok_top_array: ->(_) { tls(answering(200, "application/json", '[1,2.5,"x"]')) },
|
|
280
|
+
venue_refusal: ->(_) { tls(answering(200, "application/json", '{"error":["EOrder:Insufficient funds"]}')) },
|
|
281
|
+
venue_no_txid: ->(_) { tls(answering(200, "application/json", '{"error":[],"result":{"descr":{"order":"buy"}}}')) },
|
|
282
|
+
venue_transient_refusal: ->(_) { tls(answering(200, "application/json", '{"error":["EService:Busy"]}')) },
|
|
283
|
+
answered: ->(call) { tls(answering(200, "application/json", call == :add_order ? ADDED : TICKER)) },
|
|
284
|
+
compressed_ok: ->(call) { tls(encoded(200, gz(call == :add_order ? ADDED : TICKER), "gzip")) },
|
|
285
|
+
compressed_deflate_ok: ->(call) { tls(encoded(200, Zlib::Deflate.deflate(call == :add_order ? ADDED : TICKER), "deflate")) },
|
|
286
|
+
compressed_refusal: ->(_) { tls(encoded(200, gz('{"error":["EOrder:Insufficient funds"]}'), "gzip")) },
|
|
287
|
+
compressed_malformed: ->(_) { tls(encoded(200, "not gzip at all", "gzip")) },
|
|
288
|
+
proxy_refused: ->(_) { via(closed_port) },
|
|
289
|
+
proxy_connect_403: ->(_) { via(proxy { |io, _| io.write "HTTP/1.1 403 Filtered\r\nContent-Length: 0\r\n\r\n"; sleep 0.2; io.close }) },
|
|
290
|
+
proxy_connect_500: ->(_) { via(proxy { |io, _| io.write "HTTP/1.1 500 Unable to connect\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nnope!"; sleep 0.2; io.close }) },
|
|
291
|
+
proxy_connect_eof: ->(_) { via(proxy { |io, _| io.close }) },
|
|
292
|
+
proxy_connect_hang: ->(_) { via(proxy { |io, _| sleep 3; io.close }) },
|
|
293
|
+
proxy_tunnel_read_timeout: ->(_) {
|
|
294
|
+
port, seen = server(tls: TRUSTED, &after_request { |io, _| sleep 3; io.close })
|
|
295
|
+
["https://localhost:#{port}", "http://127.0.0.1:#{tunnel_to(port)}", seen]
|
|
296
|
+
},
|
|
297
|
+
proxy_tunnel_ok: ->(call) {
|
|
298
|
+
port, seen = server(tls: TRUSTED, &answering(200, "application/json", call == :add_order ? ADDED : TICKER))
|
|
299
|
+
["https://localhost:#{port}", "http://127.0.0.1:#{tunnel_to(port)}", seen]
|
|
300
|
+
},
|
|
301
|
+
address_fallback: ->(call) {
|
|
302
|
+
port, seen = server(&answering(200, "application/json", good_body(call)))
|
|
303
|
+
["http://fallback.parity.invalid:#{port}", nil, seen]
|
|
304
|
+
},
|
|
305
|
+
gzip_truncated_length: ->(_) { truncated_gzip(:length) },
|
|
306
|
+
gzip_truncated_chunked: ->(_) { truncated_gzip(:chunked) },
|
|
307
|
+
gzip_truncated_close: ->(_) { truncated_gzip(:close) },
|
|
308
|
+
repeated_type_json_first: ->(call) {
|
|
309
|
+
tls(raw_answer("Content-Type: application/json\r\nContent-Type: text/plain\r\n", good_body(call)))
|
|
310
|
+
},
|
|
311
|
+
repeated_type_json_last: ->(call) {
|
|
312
|
+
# Joined value is "text/plain, application/json", which still matches Faraday's /\bjson$/.
|
|
313
|
+
tls(raw_answer("Content-Type: text/plain\r\nContent-Type: application/json\r\n", good_body(call)))
|
|
314
|
+
},
|
|
315
|
+
repeated_encoding: ->(call) {
|
|
316
|
+
# The joined, unrecognized encoding passes plain JSON through. Taking either field
|
|
317
|
+
# alone would instead attempt decompression and fail with Zlib::DataError.
|
|
318
|
+
tls(raw_answer("Content-Type: application/json\r\nContent-Encoding: gzip\r\nContent-Encoding: deflate\r\n",
|
|
319
|
+
good_body(call)))
|
|
320
|
+
},
|
|
321
|
+
json_invalid_utf8_string: ->(call) {
|
|
322
|
+
body = good_body(call).b.sub('"error":[]', '"error":[],"ignored":"'.b + "\xff".b + '"')
|
|
323
|
+
tls(answering(200, "application/json", body))
|
|
324
|
+
},
|
|
325
|
+
http_407_empty: ->(_) { tls(answering(407, nil, "")) },
|
|
326
|
+
http_404_empty: ->(_) { tls(answering(404, nil, "")) }
|
|
327
|
+
}.freeze
|
|
328
|
+
EXPECTED_DIVERGENCE = { proxy_connect_hang: { legacy: [:ambiguous], rust: [:transient] } }.freeze
|
|
329
|
+
|
|
330
|
+
# Pin the mode as well as parity: two clients failing the same broken fixture must not pass.
|
|
331
|
+
EXPECTED = {
|
|
332
|
+
refused: :transient, dns: :transient, tcp_timeout: :transient, tls_not_tls: :ambiguous,
|
|
333
|
+
tls_untrusted: :ambiguous, tls_handshake_hang: :transient, read_timeout: :ambiguous,
|
|
334
|
+
eof_after_request: :ambiguous, tls_abrupt_close: :ambiguous, rst_after_request: :ambiguous,
|
|
335
|
+
partial_body: :ambiguous, http_500_json: :ambiguous, http_500_empty: :ambiguous,
|
|
336
|
+
http_404_html: :rejected, http_429_no_ctype: :rejected, ok_invalid_json: :ambiguous,
|
|
337
|
+
ok_duplicate_keys: :ambiguous, ok_html: :ambiguous, ok_blank: :ambiguous, ok_top_array: :ambiguous,
|
|
338
|
+
venue_refusal: :rejected, venue_no_txid: :ambiguous,
|
|
339
|
+
venue_transient_refusal: { add_order: :ambiguous, prices: :rejected },
|
|
340
|
+
answered: :ok, compressed_ok: :ok, compressed_deflate_ok: :ok, compressed_refusal: :rejected,
|
|
341
|
+
compressed_malformed: :ambiguous, proxy_refused: :transient, proxy_connect_403: :transient,
|
|
342
|
+
proxy_connect_500: :ambiguous, proxy_connect_eof: :ambiguous, proxy_connect_hang: :ambiguous,
|
|
343
|
+
proxy_tunnel_read_timeout: :ambiguous, proxy_tunnel_ok: :ok, address_fallback: :ok,
|
|
344
|
+
gzip_truncated_length: :ambiguous, gzip_truncated_chunked: :ambiguous, gzip_truncated_close: :ambiguous,
|
|
345
|
+
repeated_type_json_first: :ambiguous, repeated_type_json_last: :ok, repeated_encoding: :ok,
|
|
346
|
+
json_invalid_utf8_string: :ok, http_407_empty: :rejected, http_404_empty: :rejected
|
|
347
|
+
}.freeze
|
|
348
|
+
NO_REQUEST = %i[refused dns tcp_timeout tls_not_tls tls_untrusted tls_handshake_hang
|
|
349
|
+
proxy_refused proxy_connect_403 proxy_connect_500 proxy_connect_eof proxy_connect_hang].freeze
|
|
350
|
+
raise "unclassified modes" unless EXPECTED.keys.sort == MODES.keys.sort
|
|
351
|
+
|
|
352
|
+
# deltabadger's Exchange#ambiguous_placement_error? without its text rules (the caller applies those
|
|
353
|
+
# to rejection strings), using Client.most_specific_cause and Client.pre_transmission?.
|
|
354
|
+
PRE = %w[Net::OpenTimeout SocketError Socket::ResolutionError Resolv::ResolvError Errno::ECONNREFUSED Errno::EHOSTUNREACH
|
|
355
|
+
Errno::EHOSTDOWN Errno::ENETUNREACH Errno::ENETDOWN Errno::EADDRNOTAVAIL Net::HTTPClientException].freeze
|
|
356
|
+
PREFERRED = [/\ANet::(Open|Read)Timeout\z/, /\AErrno::/, /\A(SocketError|Socket::Resolution|Resolv::)/,
|
|
357
|
+
/\A(EOFError|OpenSSL::SSL::SSLError|Net::HTTPClientException)\z/].freeze
|
|
358
|
+
|
|
359
|
+
def cause(chain)
|
|
360
|
+
PREFERRED.each { |p| (m = chain.find { |n| n.match?(p) }) and return m }
|
|
361
|
+
chain.first
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def utf8(errors) = errors.map { |e| e.to_s.dup.force_encoding(Encoding::UTF_8) }
|
|
365
|
+
|
|
366
|
+
# The text rules of ambiguous_placement_error?, which the Rust client applies to AddOrder (R19).
|
|
367
|
+
PLACEMENT_SAFE = ["Timestamp for this request is outside of the recvWindow", "Timestamp for this request was"].freeze
|
|
368
|
+
TRANSIENT_TEXT = ["Net::ReadTimeout", "Net::OpenTimeout", "Faraday::TimeoutError", "Faraday::ConnectionFailed", "execution expired",
|
|
369
|
+
"Connection reset", "Errno::ECONNRESET", "connection refused", "Connection refused", "Errno::ECONNREFUSED",
|
|
370
|
+
"end of file reached", "unexpected eof while reading", "EGeneral:Internal error", "EAPI:Invalid nonce",
|
|
371
|
+
"EService:Unavailable", "EService:Busy", "EService:Deadline elapsed"].freeze
|
|
372
|
+
|
|
373
|
+
def oracle(call, r)
|
|
374
|
+
if r.success?
|
|
375
|
+
return r.data[:order_id].to_s.empty? ? [:ambiguous] : [:ok] if call == :add_order
|
|
376
|
+
return [:ambiguous] unless r.data.is_a?(Hash) # Rails' dig_or_raise raises on anything else
|
|
377
|
+
|
|
378
|
+
errors = r.data["error"]
|
|
379
|
+
return errors.is_a?(Array) && errors.any? ? [:rejected, utf8(errors)] : [:ok]
|
|
380
|
+
end
|
|
381
|
+
data = r.data.is_a?(Hash) ? r.data : {}
|
|
382
|
+
chain = data[:error_chain]
|
|
383
|
+
if chain && !chain.empty?
|
|
384
|
+
return [PRE.include?(cause(chain)) || r.errors.join(" ").match?(/connection refused/i) ? :transient : :ambiguous]
|
|
385
|
+
end
|
|
386
|
+
if call == :add_order
|
|
387
|
+
text = r.errors.map(&:to_s)
|
|
388
|
+
return [:rejected, utf8(r.errors)] if text.any? { |m| PLACEMENT_SAFE.any? { |p| m.include?(p) } }
|
|
389
|
+
return [:ambiguous] if text.any? { |m| TRANSIENT_TEXT.any? { |p| m.include?(p) } }
|
|
390
|
+
end
|
|
391
|
+
return [:ambiguous] if data[:unreadable] || data[:client_error]
|
|
392
|
+
|
|
393
|
+
status = data[:status].to_i
|
|
394
|
+
return [:ambiguous] if status >= 500 || (200..299).cover?(status)
|
|
395
|
+
return [:ambiguous] if call == :add_order && r.errors.any? { |e| e.to_s.match?(/\bHTTP 5\d\d\b/) }
|
|
396
|
+
|
|
397
|
+
[:rejected, utf8(r.errors)]
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
401
|
+
|
|
402
|
+
def legacy(call, url, proxy)
|
|
403
|
+
c = Honeymaker::Clients::Kraken.new(api_key: "key", api_secret: SECRET, proxy: proxy)
|
|
404
|
+
c.define_singleton_method(:nonce) { NONCE }
|
|
405
|
+
c.instance_variable_set(:@connection, c.send(:build_client_connection, url))
|
|
406
|
+
started = clock
|
|
407
|
+
r = call == :add_order ? c.add_order(**ORDER) : c.get_ticker_information(pair: "XBTEUR")
|
|
408
|
+
[oracle(call, r), clock - started, r]
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def probe(input)
|
|
412
|
+
Open3.popen3(PROBE) do |stdin, stdout, stderr, wait|
|
|
413
|
+
errors = Thread.new { stderr.read }
|
|
414
|
+
begin
|
|
415
|
+
out = Timeout.timeout(10) do
|
|
416
|
+
stdin.write(JSON.generate(input))
|
|
417
|
+
stdin.close
|
|
418
|
+
stdout.read
|
|
419
|
+
end
|
|
420
|
+
raise "probe failed: #{errors.value}" unless wait.value.success?
|
|
421
|
+
r = JSON.parse(out)
|
|
422
|
+
raise "probe omitted elapsed time: #{r.inspect}" unless r["ms"].is_a?(Numeric) && r["ms"] >= 0
|
|
423
|
+
r
|
|
424
|
+
ensure
|
|
425
|
+
Process.kill("KILL", wait.pid) if wait.alive?
|
|
426
|
+
wait.join
|
|
427
|
+
errors.join
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def rust(call, url, proxy)
|
|
433
|
+
input = { base_url: url, proxy: proxy, timeouts: [1, 1.5, 2], ca_pem: File.join(DIR, "trusted_ca.pem"),
|
|
434
|
+
fixed_nonce: NONCE, api_key: "key", api_secret: SECRET,
|
|
435
|
+
call: call == :add_order ? "add_order" : "prices", args: call == :add_order ? RUST_ORDER : { pair: "XBTEUR" } }
|
|
436
|
+
r = probe(input)
|
|
437
|
+
[[r["class"].to_sym, *(r["class"] == "rejected" ? [r["errors"]] : [])], r.fetch("ms") / 1000.0, r]
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Run each stack on a fresh copy, with a watchdog independent of transport timeouts.
|
|
441
|
+
def exercise(stack, build, call)
|
|
442
|
+
@doubles = Doubles.new
|
|
443
|
+
url, prox, = build.call(call)
|
|
444
|
+
result = Timeout.timeout(12) { send(stack, call, url, prox) }
|
|
445
|
+
@doubles.close
|
|
446
|
+
raise "double errors: #{@doubles.errors.inspect}" unless @doubles.errors.empty?
|
|
447
|
+
[*result, @doubles.requests.map(&:to_a), @doubles.connects.map(&:to_a), url]
|
|
448
|
+
ensure
|
|
449
|
+
@doubles&.close
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def check(condition, message)
|
|
453
|
+
raise message unless condition
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def compare_row(name, call, l, r)
|
|
457
|
+
lo, ls, lr, lw, lc, lu = l
|
|
458
|
+
ro, rs, rr, rw, rc, ru = r
|
|
459
|
+
expected = EXPECTED.fetch(name)
|
|
460
|
+
expected = expected.fetch(call) if expected.is_a?(Hash)
|
|
461
|
+
check(lo.first == expected, "legacy class #{lo.inspect}, expected #{expected}: #{lr.inspect}")
|
|
462
|
+
if (want = EXPECTED_DIVERGENCE[name])
|
|
463
|
+
check(lo == want[:legacy] && ro == want[:rust], "CONNECT ruling: #{lo.inspect} / #{ro.inspect}")
|
|
464
|
+
check(rs.between?(0.8, 2.5) && ls >= 2.5, "CONNECT timing: legacy=#{ls}, rust=#{rs}")
|
|
465
|
+
else
|
|
466
|
+
normalized = ->(outcome, url) { outcome.first == :rejected ? [:rejected, outcome.last.map { |m| m.gsub(url, "<double>") }] : outcome }
|
|
467
|
+
check(normalized.call(lo, lu) == normalized.call(ro, ru), "outcomes: #{lo.inspect} / #{ro.inspect}; legacy=#{lr.inspect}; rust=#{rr.inspect}")
|
|
468
|
+
end
|
|
469
|
+
check(lw == rw, "wire: #{lw.inspect} / #{rw.inspect}")
|
|
470
|
+
count = NO_REQUEST.include?(name) ? 0 : 1
|
|
471
|
+
check(lw.size == count && rw.size == count, "expected #{count} requests, got #{lw.size}/#{rw.size}")
|
|
472
|
+
if count == 1
|
|
473
|
+
line = call == :add_order ? "POST /0/private/AddOrder HTTP/1.1" : "GET /0/public/Ticker?pair=XBTEUR HTTP/1.1"
|
|
474
|
+
check(lw.first[0] == line, "wrong request line: #{lw.first[0]}")
|
|
475
|
+
check(lw.first[1] == "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "wrong Accept-Encoding")
|
|
476
|
+
check(call != :add_order || (lw.first[3] == "key" && !lw.first[4].to_s.empty?), "missing signing headers")
|
|
477
|
+
end
|
|
478
|
+
proxy_count = name.to_s.start_with?("proxy_") && name != :proxy_refused ? 1 : 0
|
|
479
|
+
check(lc.size == proxy_count && rc.size == proxy_count, "CONNECT counts: #{lc.size}/#{rc.size}")
|
|
480
|
+
if proxy_count == 1
|
|
481
|
+
# A fresh double has a fresh port; compare each CONNECT to its own exact authority.
|
|
482
|
+
[[lc, lu], [rc, ru]].each do |connects, url|
|
|
483
|
+
uri = URI(url)
|
|
484
|
+
check(connects.first[0] == "CONNECT #{uri.host}:#{uri.port} HTTP/1.1", "wrong CONNECT target")
|
|
485
|
+
check(connects.first[2].empty?, "CONNECT unexpectedly has a body")
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
if name == :tcp_timeout || name == :tls_handshake_hang
|
|
489
|
+
check(ls.between?(0.8, 2.5) && rs.between?(0.8, 2.5), "not an open timeout: #{ls}/#{rs}")
|
|
490
|
+
check(lr.data[:error_chain].include?("Net::OpenTimeout"), "legacy did not time out opening")
|
|
491
|
+
prefix = name == :tcp_timeout ? "Failed to open TCP connection to " : "Net::OpenTimeout"
|
|
492
|
+
check(rr["message"].start_with?(prefix), "Rust did not fail in the open phase: #{rr.inspect}")
|
|
493
|
+
end
|
|
494
|
+
if %i[read_timeout proxy_tunnel_read_timeout].include?(name)
|
|
495
|
+
check(ls.between?(1.3, 2.5) && rs.between?(1.3, 2.5), "not a read timeout: #{ls}/#{rs}")
|
|
496
|
+
check(lr.data[:error_chain].include?("Net::ReadTimeout") && rr["message"].include?("Net::ReadTimeout"), "wrong timeout phase")
|
|
497
|
+
end
|
|
498
|
+
# R14: status/rejection text is exact; low-level OS/OpenSSL diagnostics are version-specific.
|
|
499
|
+
# partial_body is an incomplete transport read in hyper (EOF), while Net::HTTP hands
|
|
500
|
+
# partial bytes to Faraday. R14 permits different transport diagnostics; the brief compares
|
|
501
|
+
# its ambiguous class. Complete HTTP responses below retain exact status/parser error text.
|
|
502
|
+
if lr.failure? && lr.data.is_a?(Hash) && lr.data[:status] && name != :partial_body
|
|
503
|
+
ruby_text = utf8(lr.errors).first.gsub(lu, "<double>")
|
|
504
|
+
rust_text = (rr["message"] || rr.fetch("errors").first).gsub(ru, "<double>")
|
|
505
|
+
check(ruby_text == rust_text, "HTTP text: #{ruby_text.inspect} / #{rust_text.inspect}")
|
|
506
|
+
end
|
|
507
|
+
if %i[http_500_empty http_404_empty].include?(name)
|
|
508
|
+
method = call == :add_order ? "POST" : "GET"
|
|
509
|
+
check(lr.errors.first.include?(" for #{method} "), "fallback method must be uppercase")
|
|
510
|
+
end
|
|
511
|
+
if name == :http_407_empty
|
|
512
|
+
check(lo == [:rejected, ['407 "Proxy Authentication Required"']], "wrong empty-407 fallback")
|
|
513
|
+
end
|
|
514
|
+
if name == :dns
|
|
515
|
+
check(lr.data[:error_chain].any? { |c| c.match?(/Socket.*Error|Resolv/) }, "legacy did not fail resolution")
|
|
516
|
+
check(rr["message"].include?("getaddrinfo"), "Rust did not fail resolution")
|
|
517
|
+
end
|
|
518
|
+
if name == :compressed_malformed
|
|
519
|
+
check(lr.data == { client_error: true } && lr.errors.first.start_with?("Zlib::DataError:"), "missing Ruby DataError")
|
|
520
|
+
check(rr["message"].start_with?("Zlib::DataError:"), "missing Rust DataError")
|
|
521
|
+
end
|
|
522
|
+
if %i[gzip_truncated_length gzip_truncated_chunked].include?(name)
|
|
523
|
+
check(call == :add_order ? lr.data == { unreadable: true } : lr.success? && lr.data.nil?,
|
|
524
|
+
"length/chunked gzip must produce an empty successful transport body")
|
|
525
|
+
check(rr["message"] == Honeymaker::Clients::Kraken::UNREADABLE, "unexpected gzip result")
|
|
526
|
+
end
|
|
527
|
+
if name == :gzip_truncated_close
|
|
528
|
+
check(lr.data == { client_error: true } && lr.errors.first.start_with?("Zlib::BufError:"), "missing Ruby BufError")
|
|
529
|
+
check(rr["message"] == lr.errors.first, "BufError text differs")
|
|
530
|
+
end
|
|
531
|
+
if lo.first == :ok
|
|
532
|
+
value = call == :add_order ? lr.data[:order_id] :
|
|
533
|
+
lr.data.fetch("result").values.first.then { |t| { "bid" => t.fetch("b").first, "ask" => t.fetch("a").first, "last" => t.fetch("c").first } }
|
|
534
|
+
check(value == rr.fetch("value"), "success values: #{value.inspect} / #{rr["value"].inspect}")
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Exercise all seven probe dispatch branches and their JSON projections, including fields
|
|
539
|
+
# unused by the two-call transport matrix. This is a local protocol check for Task 12's consumer.
|
|
540
|
+
def probe_contract!
|
|
541
|
+
raw_order = { "status" => "open", "vol" => "2", "vol_exec" => "1", "cost" => "3", "price" => "3",
|
|
542
|
+
"cl_ord_id" => ORDER[:cl_ord_id], "descr" => { "ordertype" => "limit", "type" => "sell", "price" => "3" } }
|
|
543
|
+
state = { "txid" => "OTX-1", "status" => "open", "price" => "3", "amount" => "2", "quote_amount" => nil,
|
|
544
|
+
"amount_exec" => "1", "quote_amount_exec" => "3", "limit" => true, "sell" => true }
|
|
545
|
+
trade = { "ordertxid" => "OTX-1", "vol" => "1", "cost" => "3", "fee" => "0", "type" => "sell", "ordertype" => "limit" }
|
|
546
|
+
envelope = ->(r) { JSON.generate(error: [], result: r) }
|
|
547
|
+
since = "2026-09-30T00:00:00Z"
|
|
548
|
+
cases = [
|
|
549
|
+
["prices", { pair: "XBTEUR" }, TICKER, { "bid" => "49990.1", "ask" => "50000.2", "last" => "49995.3" }],
|
|
550
|
+
["add_order", RUST_ORDER, ADDED, "OTX-1"],
|
|
551
|
+
["add_order_validate", RUST_ORDER.merge(kind: "limit", price: "3"), envelope.call({}), true],
|
|
552
|
+
["orders", { txids: ["OTX-1"] }, envelope.call("OTX-1" => raw_order), [state]],
|
|
553
|
+
["order_by_client_id", { cl_ord_id: ORDER[:cl_ord_id], since: since }, envelope.call("open" => { "OTX-1" => raw_order }), state],
|
|
554
|
+
["fills_from_trades", { txids: ["OTX-1"], since: since }, envelope.call("trades" => { "T1" => trade }, "count" => 1),
|
|
555
|
+
[state.merge("status" => "closed", "amount" => nil, "price" => "3.000000000000000000000000000000000")]],
|
|
556
|
+
["balance", { asset: "EUR" }, envelope.call("ZEUR" => { "balance" => "1000", "hold_trade" => "1" }), "999"]
|
|
557
|
+
]
|
|
558
|
+
cases.each do |call, args, body, value|
|
|
559
|
+
begin
|
|
560
|
+
@doubles = Doubles.new
|
|
561
|
+
url, = tls(answering(200, "application/json", body))
|
|
562
|
+
r = probe(base_url: url, ca_pem: File.join(DIR, "trusted_ca.pem"), fixed_nonce: NONCE,
|
|
563
|
+
api_key: "key", api_secret: SECRET, call: call, args: args)
|
|
564
|
+
check(r["class"] == "ok" && r["value"] == value, "probe #{call}: #{r.inspect}, expected #{value.inspect}")
|
|
565
|
+
check(@doubles.requests.size == 1, "probe #{call} must send exactly once")
|
|
566
|
+
if call == "add_order_validate"
|
|
567
|
+
form = URI.decode_www_form(@doubles.requests.first.body).to_h
|
|
568
|
+
check(form["validate"] == "true" && form["ordertype"] == "limit" && form["price"] == "3", "probe limit/validate order")
|
|
569
|
+
end
|
|
570
|
+
check(@doubles.errors.empty?, "probe double errors: #{@doubles.errors.inspect}")
|
|
571
|
+
ensure
|
|
572
|
+
@doubles.close
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
r = probe(base_url: "http://127.0.0.1", proxy: "socks5://127.0.0.1:1", call: "prices", args: { pair: "XBTEUR" })
|
|
576
|
+
check(r["class"] == "config" && r["message"].is_a?(String), "probe config: #{r.inspect}")
|
|
577
|
+
puts "8 probe contract checks passed (7 calls + config)"
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
failures = 0
|
|
581
|
+
rows = 0
|
|
582
|
+
unknown = ARGV - MODES.keys.map(&:to_s)
|
|
583
|
+
abort "Unknown modes: #{unknown.join(', ')}" unless unknown.empty?
|
|
584
|
+
puts "Ruby #{RUBY_VERSION}; #{MODES.size} modes available; local doubles only"
|
|
585
|
+
probe_contract!
|
|
586
|
+
MODES.each do |name, build|
|
|
587
|
+
next if ARGV.any? && !ARGV.include?(name.to_s)
|
|
588
|
+
%i[add_order prices].each do |call|
|
|
589
|
+
next if call == :prices && name == :venue_no_txid
|
|
590
|
+
|
|
591
|
+
rows += 1
|
|
592
|
+
begin
|
|
593
|
+
l = exercise(:legacy, build, call)
|
|
594
|
+
r = exercise(:rust, build, call)
|
|
595
|
+
compare_row(name, call, l, r)
|
|
596
|
+
printf("%-32s %-10s legacy=%-12s rust=%-12s ok (%.3fs / %.3fs)\n", name, call, l[0].first, r[0].first, l[1], r[1])
|
|
597
|
+
rescue StandardError => e
|
|
598
|
+
failures += 1
|
|
599
|
+
puts "#{name} #{call} FAIL #{e.full_message}"
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
abort "#{failures} of #{rows} rows differ" if failures.positive?
|
|
604
|
+
puts "#{rows} rows: all match (#{ARGV.empty? ? '1 expected divergence asserted: proxy_connect_hang' : 'selected modes'})"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* Process-local resolver double for the transport harness. No DNS packets: only numeric
|
|
2
|
+
* loopback addresses reach libc. Both Ruby and Rust still use their real socket/TLS/HTTP stacks.
|
|
3
|
+
* macOS: DYLD interposition; Linux: LD_PRELOAD. Built into the harness's temporary directory. */
|
|
4
|
+
#define _GNU_SOURCE
|
|
5
|
+
#include <dlfcn.h>
|
|
6
|
+
#include <netdb.h>
|
|
7
|
+
#include <string.h>
|
|
8
|
+
|
|
9
|
+
typedef int (*resolver)(const char *, const char *, const struct addrinfo *, struct addrinfo **);
|
|
10
|
+
|
|
11
|
+
static int local_resolve(const char *node, const char *service,
|
|
12
|
+
const struct addrinfo *hints, struct addrinfo **result) {
|
|
13
|
+
#ifdef __APPLE__
|
|
14
|
+
resolver real_resolve = getaddrinfo;
|
|
15
|
+
#else
|
|
16
|
+
resolver real_resolve = (resolver)dlsym(RTLD_NEXT, "getaddrinfo");
|
|
17
|
+
#endif
|
|
18
|
+
*result = NULL;
|
|
19
|
+
if (!node) return EAI_NONAME;
|
|
20
|
+
struct addrinfo numeric = {0};
|
|
21
|
+
if (hints) numeric = *hints;
|
|
22
|
+
numeric.ai_flags |= AI_NUMERICHOST;
|
|
23
|
+
if (!strcmp(node, "fallback.parity.invalid")) {
|
|
24
|
+
/* First address refuses; the second has the local listener. */
|
|
25
|
+
int error = real_resolve("127.0.0.2", service, &numeric, result);
|
|
26
|
+
if (error) return error;
|
|
27
|
+
struct addrinfo *tail = *result;
|
|
28
|
+
while (tail->ai_next) tail = tail->ai_next;
|
|
29
|
+
error = real_resolve("127.0.0.1", service, &numeric, &tail->ai_next);
|
|
30
|
+
if (error) { freeaddrinfo(*result); *result = NULL; }
|
|
31
|
+
return error;
|
|
32
|
+
}
|
|
33
|
+
if (!strcmp(node, "localhost")) node = "127.0.0.1";
|
|
34
|
+
if (strcmp(node, "127.0.0.1") && strcmp(node, "127.0.0.2") && strcmp(node, "::1"))
|
|
35
|
+
return EAI_NONAME;
|
|
36
|
+
return real_resolve(node, service, &numeric, result);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#ifdef __APPLE__
|
|
40
|
+
__attribute__((used)) static struct { const void *replacement; const void *original; }
|
|
41
|
+
interpose __attribute__((section("__DATA,__interpose"))) = {
|
|
42
|
+
(const void *)local_resolve, (const void *)getaddrinfo
|
|
43
|
+
};
|
|
44
|
+
#else
|
|
45
|
+
int getaddrinfo(const char *node, const char *service,
|
|
46
|
+
const struct addrinfo *hints, struct addrinfo **result) {
|
|
47
|
+
return local_resolve(node, service, hints, result);
|
|
48
|
+
}
|
|
49
|
+
#endif
|